mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-16 16:58:33 +00:00
## Summary Introduces **Subgraph Link Only Promotion** (ADR 0009) — a new model for surfacing inner subgraph widgets on the parent SubgraphNode by *promoting through links* rather than by duplicating widget state on the host. Ships with the hygiene/refactor pass on the migration, store, and event layers that the new model depends on. ## What changes ### Subgraph Link Only Promotion (ADR 0009) Promoted widgets are defined by the link from a SubgraphNode input to the interior node, not by a duplicated widget instance on the host. Consequences: - A SubgraphNode renders inner widgets purely as a **projection** of the interior widgets and links — no host-side state to drift. - **Per-host independence**: multiple instances of the same SubgraphNode render and edit their own values without cross-talk. - **Reversible promote/demote**: structural link operation, so demote preserves host slots and external connections (#12278). ### Supporting refactors - **Migration** — Planner/classifier/repair/quarantine helpers collapsed into a single `proxyWidgetMigration` entry point with black-box round-trip coverage. Honors the source-node-id disambiguator on `proxyWidgets`, so deduplicated names (e.g. `text`, `text_1`) resolve to the right interior widget. - **Widget identity** — `appMode` unified on `WidgetEntityId`; promoted widget state is keyed by entityId across the store, DOM, and migration paths. - **SubgraphNode** — 3-key promoted-view cache replaced with a single version counter + explicit `invalidatePromotedViews()` at mutation sites; `id === -1` sentinel removed. - **Events** — `LGraph.trigger()` now dispatches node trigger payloads through `this.events`, replacing a leaky `onTrigger` monkey-patch. `SubgraphEditor` reactivity is driven from subgraph events instead of imperative refresh. - **Stores** — `appModeStore` migration helpers collapsed into `upgradeAndValidateInput`; `nodeOutputStore.*ByExecutionId` derived from the locator index; `previewExposureStore` cleanup and cycle-detection double-warn fix. - **Misc** — `Outcome` types consolidated; mutable accumulators replaced with `flatMap`; new ESLint rule forbids litegraph imports under `src/world/`. ### Tests - Browser tests for promoted widgets retagged `@vue-nodes` and rewritten to assert against the rendered Vue node DOM (via `getNodeLocator` / `getByRole('textbox')` / `enterSubgraph`) instead of `page.evaluate` graph introspection. - Per-host widget independence asserted via DOM. - Migration coverage moved to black-box round-trip tests. - Added coverage for duplicate-named promoted widget identity (ADR 0009) and the per-parent demote branch in `WidgetActions`. ## Review focus - ADR 0009 conformance of the link-only promotion model. - Disambiguator resolution path in `proxyWidgetMigration`. - Single-version-counter promoted-view cache and its `invalidatePromotedViews()` call sites. - `LGraph.trigger()` event dispatch and the `AppModeWidgetList.vue` migration off `onTrigger` (FE-667 tracks the remaining `useGraphNodeManager` conversion). ## Breaking changes None for users. Internal subgraph promotion APIs changed — see ADR 0009. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-12197-feat-subgraph-link-only-widget-promotion-migration-store-hygiene-35e6d73d365081fd882cf3a69bc09956) by [Unito](https://www.unito.io) --------- Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: GitHub Action <action@github.com> Co-authored-by: github-actions <github-actions@github.com> Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com> Co-authored-by: AustinMroz <austin@comfy.org>
758 lines
25 KiB
TypeScript
758 lines
25 KiB
TypeScript
import { expect } from '@playwright/test'
|
|
|
|
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
|
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
|
import { TestIds } from '@e2e/fixtures/selectors'
|
|
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
|
import {
|
|
getPromotedWidgetNames,
|
|
getPromotedWidgetCount
|
|
} from '@e2e/fixtures/utils/promotedWidgets'
|
|
|
|
async function expectPromotedWidgetNamesToContain(
|
|
comfyPage: ComfyPage,
|
|
nodeId: string,
|
|
widgetName: string
|
|
) {
|
|
await expect
|
|
.poll(() => getPromotedWidgetNames(comfyPage, nodeId))
|
|
.toContain(widgetName)
|
|
}
|
|
|
|
async function expectPromotedWidgetCountToBeGreaterThan(
|
|
comfyPage: ComfyPage,
|
|
nodeId: string,
|
|
count: number
|
|
) {
|
|
await expect
|
|
.poll(() => getPromotedWidgetCount(comfyPage, nodeId))
|
|
.toBeGreaterThan(count)
|
|
}
|
|
|
|
test.describe(
|
|
'Subgraph Widget Promotion',
|
|
{ tag: ['@subgraph', '@widget'] },
|
|
() => {
|
|
test.beforeEach(async ({ comfyPage }) => {
|
|
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
|
})
|
|
|
|
test.describe('Auto-promotion on Convert to Subgraph', () => {
|
|
test('Recommended widgets are auto-promoted when creating a subgraph', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow('default')
|
|
|
|
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
|
|
await ksampler.click('title')
|
|
const subgraphNode = await ksampler.convertToSubgraph()
|
|
await comfyPage.nextFrame()
|
|
|
|
await expect.poll(() => subgraphNode.exists()).toBe(true)
|
|
|
|
const nodeId = String(subgraphNode.id)
|
|
await expectPromotedWidgetNamesToContain(comfyPage, nodeId, 'seed')
|
|
|
|
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, nodeId, 0)
|
|
})
|
|
|
|
test('Preview-capable nodes keep regular and pseudo-widget promotions when converted', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow('default')
|
|
|
|
const clipNode = await comfyPage.nodeOps.getNodeRefById('6')
|
|
await clipNode.click('title')
|
|
const subgraphNode = await clipNode.convertToSubgraph()
|
|
await comfyPage.nextFrame()
|
|
|
|
const nodeId = String(subgraphNode.id)
|
|
await expectPromotedWidgetNamesToContain(comfyPage, nodeId, 'text')
|
|
})
|
|
|
|
test('SaveImage/PreviewImage nodes get pseudo-widget promoted', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow('default')
|
|
|
|
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
|
|
await saveNode.centerOnNode()
|
|
|
|
await saveNode.click('title')
|
|
const subgraphNode = await saveNode.convertToSubgraph()
|
|
await comfyPage.nextFrame()
|
|
|
|
await expectPromotedWidgetNamesToContain(
|
|
comfyPage,
|
|
String(subgraphNode.id),
|
|
'filename_prefix'
|
|
)
|
|
})
|
|
})
|
|
|
|
test.describe(
|
|
'Promoted Widget Visibility in Vue Mode',
|
|
{ tag: ['@vue-nodes'] },
|
|
() => {
|
|
test('Promoted text widget renders and enters the subgraph in Vue mode', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow(
|
|
'subgraphs/subgraph-with-promoted-text-widget'
|
|
)
|
|
|
|
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
|
|
await expect(subgraphVueNode).toBeVisible()
|
|
|
|
const enterButton = subgraphVueNode.getByTestId(
|
|
'subgraph-enter-button'
|
|
)
|
|
await expect(enterButton).toBeVisible()
|
|
|
|
const nodeBody = subgraphVueNode.getByTestId('node-body-11')
|
|
await expect(nodeBody).toBeVisible()
|
|
|
|
const widgets = nodeBody.locator('.lg-node-widgets > div')
|
|
await expect(widgets.first()).toBeVisible()
|
|
await comfyPage.vueNodes.enterSubgraph('11')
|
|
await comfyPage.nextFrame()
|
|
|
|
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
|
})
|
|
}
|
|
)
|
|
|
|
test.describe('Promoted Widget Reactivity', { tag: ['@vue-nodes'] }, () => {
|
|
test.fail(
|
|
'Promoted and interior widgets stay in sync across navigation',
|
|
async ({ comfyPage }) => {
|
|
await comfyPage.workflow.loadWorkflow(
|
|
'subgraphs/subgraph-with-promoted-text-widget'
|
|
)
|
|
|
|
const testContent = 'promoted-value-sync-test'
|
|
|
|
const promotedTextarea = comfyPage.vueNodes
|
|
.getNodeLocator('11')
|
|
.getByRole('textbox', { name: 'text' })
|
|
await promotedTextarea.fill(testContent)
|
|
|
|
await comfyPage.vueNodes.enterSubgraph('11')
|
|
|
|
const interiorTextarea = comfyPage.page
|
|
.locator('[data-node-id]')
|
|
.getByRole('textbox', { name: 'text' })
|
|
.first()
|
|
await expect(interiorTextarea).toHaveValue(testContent)
|
|
|
|
const updatedInteriorContent = 'interior-value-sync-test'
|
|
await interiorTextarea.fill(updatedInteriorContent)
|
|
|
|
await comfyPage.subgraph.exitViaBreadcrumb()
|
|
|
|
await expect(
|
|
comfyPage.vueNodes
|
|
.getNodeLocator('11')
|
|
.getByRole('textbox', { name: 'text' })
|
|
).toHaveValue(updatedInteriorContent)
|
|
}
|
|
)
|
|
})
|
|
|
|
test.describe('Manual Promote/Demote via Context Menu', () => {
|
|
test('Can promote and un-promote a widget from inside a subgraph', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
|
|
|
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
|
await subgraphNode.navigateIntoSubgraph()
|
|
|
|
const ksampler = await comfyPage.nodeOps.getNodeRefById('1')
|
|
await ksampler.click('title')
|
|
|
|
const stepsWidget = await ksampler.getWidget(2)
|
|
const widgetPos = await stepsWidget.getPosition()
|
|
await comfyPage.canvasOps.mouseClickAt(widgetPos, { button: 'right' })
|
|
|
|
const promoteEntry = comfyPage.page
|
|
.locator('.litemenu-entry')
|
|
.filter({ hasText: /Promote Widget/ })
|
|
|
|
await expect(promoteEntry).toBeVisible()
|
|
await promoteEntry.click()
|
|
await expect(promoteEntry).toBeHidden()
|
|
|
|
await comfyPage.subgraph.exitViaBreadcrumb()
|
|
|
|
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, '2', 0)
|
|
})
|
|
|
|
test('Can un-promote a widget from inside a subgraph', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
|
|
|
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
|
await subgraphNode.navigateIntoSubgraph()
|
|
|
|
const ksampler = await comfyPage.nodeOps.getNodeRefById('1')
|
|
const stepsWidget = await ksampler.getWidget(2)
|
|
const widgetPos = await stepsWidget.getPosition()
|
|
|
|
await comfyPage.canvasOps.mouseClickAt(widgetPos, { button: 'right' })
|
|
|
|
const promoteEntry = comfyPage.page
|
|
.locator('.litemenu-entry')
|
|
.filter({ hasText: /Promote Widget/ })
|
|
|
|
await expect(promoteEntry).toBeVisible()
|
|
await promoteEntry.click()
|
|
await expect(promoteEntry).toBeHidden()
|
|
|
|
await comfyPage.subgraph.exitViaBreadcrumb()
|
|
|
|
await fitToViewInstant(comfyPage)
|
|
await comfyPage.nextFrame()
|
|
|
|
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, '2', 0)
|
|
const initialWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
|
|
|
|
const subgraphNode2 = await comfyPage.nodeOps.getNodeRefById('2')
|
|
await subgraphNode2.navigateIntoSubgraph()
|
|
const ksampler2 = await comfyPage.nodeOps.getNodeRefById('1')
|
|
await ksampler2.click('title')
|
|
const stepsWidget2 = await ksampler2.getWidget(2)
|
|
const widgetPos2 = await stepsWidget2.getPosition()
|
|
|
|
await comfyPage.canvasOps.mouseClickAt(widgetPos2, { button: 'right' })
|
|
|
|
const unpromoteEntry = comfyPage.page
|
|
.locator('.litemenu-entry')
|
|
.filter({ hasText: /Un-Promote Widget/ })
|
|
|
|
await expect(unpromoteEntry).toBeVisible()
|
|
await unpromoteEntry.click()
|
|
await expect(unpromoteEntry).toBeHidden()
|
|
|
|
await comfyPage.subgraph.exitViaBreadcrumb()
|
|
|
|
await expect
|
|
.poll(() => getPromotedWidgetCount(comfyPage, '2'))
|
|
.toBeLessThan(initialWidgetCount)
|
|
})
|
|
})
|
|
|
|
test.describe('Textarea Widget Context Menu in Subgraph (Vue Mode)', () => {
|
|
test.beforeEach(async ({ comfyPage }) => {
|
|
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
|
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
|
})
|
|
|
|
test('Right-click on textarea widget inside subgraph shows Promote Widget option', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow(
|
|
'subgraphs/subgraph-with-text-widget'
|
|
)
|
|
|
|
await comfyPage.vueNodes.enterSubgraph('11')
|
|
await comfyPage.nextFrame()
|
|
|
|
const clipNode = comfyPage.vueNodes.getNodeLocator('10')
|
|
await expect(clipNode).toBeVisible()
|
|
|
|
await comfyPage.vueNodes.selectNode('10')
|
|
await comfyPage.nextFrame()
|
|
|
|
const textarea = clipNode.locator('textarea')
|
|
await expect(textarea).toBeVisible()
|
|
await textarea.dispatchEvent('contextmenu', {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
button: 2
|
|
})
|
|
await comfyPage.nextFrame()
|
|
|
|
const promoteEntry = comfyPage.page
|
|
.locator('.p-contextmenu')
|
|
.locator('text=Promote Widget')
|
|
|
|
await expect(promoteEntry.first()).toBeVisible()
|
|
})
|
|
})
|
|
|
|
test.describe('Pseudo-Widget Promotion', () => {
|
|
test('Promoted preview nodes render custom content in Vue mode', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow(
|
|
'subgraphs/subgraph-with-preview-node'
|
|
)
|
|
|
|
await expectPromotedWidgetNamesToContain(
|
|
comfyPage,
|
|
'5',
|
|
'filename_prefix'
|
|
)
|
|
})
|
|
|
|
test('Converting SaveImage to subgraph promotes its widgets', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow('default')
|
|
|
|
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
|
|
await saveNode.centerOnNode()
|
|
|
|
await saveNode.click('title')
|
|
const subgraphNode = await saveNode.convertToSubgraph()
|
|
await comfyPage.nextFrame()
|
|
|
|
const nodeId = String(subgraphNode.id)
|
|
await expectPromotedWidgetNamesToContain(
|
|
comfyPage,
|
|
nodeId,
|
|
'filename_prefix'
|
|
)
|
|
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, nodeId, 0)
|
|
})
|
|
})
|
|
|
|
test.describe('Vue Mode - Promoted Preview Content', () => {
|
|
test.beforeEach(async ({ comfyPage }) => {
|
|
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
|
await comfyPage.workflow.loadWorkflow(
|
|
'subgraphs/subgraph-with-preview-node'
|
|
)
|
|
|
|
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('5')
|
|
await expect(subgraphVueNode).toBeVisible()
|
|
|
|
await expect
|
|
.poll(() => getPromotedWidgetNames(comfyPage, '5'))
|
|
.toEqual(
|
|
expect.arrayContaining([
|
|
'filename_prefix',
|
|
expect.stringMatching(/^\$\$/)
|
|
])
|
|
)
|
|
|
|
const loadImageNode = await comfyPage.nodeOps.getNodeRefById('11')
|
|
const loadImagePosition = await loadImageNode.getPosition()
|
|
await comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
|
|
dropPosition: loadImagePosition
|
|
})
|
|
|
|
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
|
|
|
const nodeBody = subgraphVueNode.getByTestId('node-body-5')
|
|
await expect(nodeBody).toBeVisible()
|
|
await expect(
|
|
nodeBody.locator('.lg-node-widgets > div').first()
|
|
).toBeVisible()
|
|
|
|
await expect(nodeBody.locator('.image-preview img')).toHaveCount(1, {
|
|
timeout: 30_000
|
|
})
|
|
await expect(nodeBody.locator('.lg-node-widgets')).not.toContainText(
|
|
'$$canvas-image-preview'
|
|
)
|
|
})
|
|
})
|
|
|
|
test.describe(
|
|
'Nested Promoted Widget Disabled State',
|
|
{ tag: ['@vue-nodes'] },
|
|
() => {
|
|
test('Externally linked promotions stay disabled while unlinked textareas remain editable', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow(
|
|
'subgraphs/subgraph-nested-promotion'
|
|
)
|
|
|
|
await expect
|
|
.poll(() => getPromotedWidgetNames(comfyPage, '5'))
|
|
.toEqual(expect.arrayContaining(['string_a', 'value']))
|
|
|
|
const subgraphNode = comfyPage.vueNodes.getNodeLocator('5')
|
|
const linkedTextarea = subgraphNode.getByRole('textbox', {
|
|
name: 'string_a',
|
|
exact: true
|
|
})
|
|
await expect(linkedTextarea).toBeVisible()
|
|
await expect(linkedTextarea).toBeDisabled()
|
|
|
|
const allTextareas = subgraphNode.getByRole('textbox')
|
|
await expect(allTextareas.first()).toBeVisible()
|
|
|
|
let editedTextarea = false
|
|
const count = await allTextareas.count()
|
|
for (let i = 0; i < count; i++) {
|
|
const textarea = allTextareas.nth(i)
|
|
if (await textarea.isEditable()) {
|
|
const testContent = `nested-promotion-edit-${i}`
|
|
await textarea.fill(testContent)
|
|
await expect(textarea).toHaveValue(testContent)
|
|
editedTextarea = true
|
|
break
|
|
}
|
|
}
|
|
expect(editedTextarea).toBe(true)
|
|
})
|
|
}
|
|
)
|
|
|
|
test.describe('Promotion Cleanup', () => {
|
|
test('Removing subgraph node clears promotion store entries', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow(
|
|
'subgraphs/subgraph-with-promoted-text-widget'
|
|
)
|
|
|
|
await expect
|
|
.poll(() => getPromotedWidgetNames(comfyPage, '11'))
|
|
.toEqual(expect.arrayContaining([expect.anything()]))
|
|
|
|
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
|
await subgraphNode.delete()
|
|
|
|
await expect.poll(() => subgraphNode.exists()).toBe(false)
|
|
})
|
|
|
|
test('Nested promoted widget entries reflect interior changes after slot removal', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow(
|
|
'subgraphs/subgraph-nested-promotion'
|
|
)
|
|
|
|
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, '5', 0)
|
|
const initialNames = await getPromotedWidgetNames(comfyPage, '5')
|
|
|
|
const outerSubgraph = await comfyPage.nodeOps.getNodeRefById('5')
|
|
await outerSubgraph.navigateIntoSubgraph()
|
|
|
|
await expect
|
|
.poll(async () => {
|
|
return await comfyPage.page.evaluate(() => {
|
|
const graph = window.app!.canvas.graph
|
|
if (!graph || !('inputNode' in graph)) return null
|
|
return graph.inputs?.[0]?.name ?? null
|
|
})
|
|
})
|
|
.not.toBeNull()
|
|
const removedSlotName = await comfyPage.page.evaluate(() => {
|
|
const graph = window.app!.canvas.graph
|
|
if (!graph || !('inputNode' in graph)) return null
|
|
return graph.inputs?.[0]?.name ?? null
|
|
})
|
|
|
|
await comfyPage.subgraph.removeSlot('input')
|
|
|
|
await comfyPage.subgraph.exitViaBreadcrumb()
|
|
|
|
const expectedNames = [...initialNames]
|
|
const removedIndex = expectedNames.indexOf(removedSlotName!)
|
|
expect(removedIndex).toBeGreaterThanOrEqual(0)
|
|
expectedNames.splice(removedIndex, 1)
|
|
|
|
await expect
|
|
.poll(() => getPromotedWidgetNames(comfyPage, '5'))
|
|
.toEqual(expectedNames)
|
|
})
|
|
|
|
test('Removing I/O slot removes associated promoted widget', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow(
|
|
'subgraphs/subgraph-with-promoted-text-widget'
|
|
)
|
|
|
|
let initialWidgetCount = 0
|
|
await expect
|
|
.poll(() => getPromotedWidgetCount(comfyPage, '11'))
|
|
.toBeGreaterThan(0)
|
|
initialWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
|
|
|
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
|
await subgraphNode.navigateIntoSubgraph()
|
|
|
|
await comfyPage.subgraph.removeSlot('input', 'text')
|
|
|
|
await comfyPage.subgraph.exitViaBreadcrumb()
|
|
|
|
await expect
|
|
.poll(() => getPromotedWidgetCount(comfyPage, '11'))
|
|
.toBeLessThan(initialWidgetCount)
|
|
})
|
|
|
|
test('Does not cleanup unconfigured Primitive', async ({ comfyPage }) => {
|
|
await comfyPage.workflow.loadWorkflow(
|
|
'subgraphs/subgraph-with-link-and-proxied-primitive'
|
|
)
|
|
await expect
|
|
.poll(
|
|
() => getPromotedWidgetCount(comfyPage, '2'),
|
|
'Primitive widget is restored on load'
|
|
)
|
|
.toBe(2)
|
|
|
|
await comfyPage.page.evaluate(() => app!.canvas.setDirty(true))
|
|
const subgraphNode = await comfyPage.nodeOps.getFirstNodeRef()
|
|
const promotedPrimitive = await subgraphNode!.getWidget(1)
|
|
await expect
|
|
.poll(
|
|
() => promotedPrimitive.getValue(),
|
|
'Primitive widget is not in a disconnected state'
|
|
)
|
|
.toBe(0)
|
|
})
|
|
})
|
|
|
|
test.fail(
|
|
'Promoted text widget is removed when source node is deleted inside the subgraph',
|
|
{ tag: '@vue-nodes' },
|
|
async ({ comfyPage }) => {
|
|
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
|
|
|
const clipFixture = await comfyPage.vueNodes.getFixtureByTitle(
|
|
'CLIP Text Encode (Prompt)'
|
|
)
|
|
await comfyPage.contextMenu.openForVueNode(clipFixture.header)
|
|
await comfyPage.contextMenu.clickMenuItemExact('Convert to Subgraph')
|
|
|
|
const subgraphNode = comfyPage.vueNodes
|
|
.getNodeByTitle('New Subgraph')
|
|
.first()
|
|
await expect(subgraphNode).toBeVisible()
|
|
|
|
const subgraphNodeId =
|
|
await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
|
|
|
|
await expect
|
|
.poll(() => getPromotedWidgetNames(comfyPage, subgraphNodeId))
|
|
.toContain('text')
|
|
await expect(
|
|
subgraphNode.getByTestId(TestIds.widgets.domWidgetTextarea)
|
|
).toBeVisible()
|
|
|
|
await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
|
|
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
|
|
|
const interiorClip = await comfyPage.vueNodes.getFixtureByTitle(
|
|
'CLIP Text Encode (Prompt)'
|
|
)
|
|
await interiorClip.delete()
|
|
|
|
await comfyPage.subgraph.exitViaBreadcrumb()
|
|
|
|
const subgraphNodeAfter =
|
|
comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
|
|
await expect(subgraphNodeAfter).toBeVisible()
|
|
await expect(
|
|
subgraphNodeAfter.getByTestId(TestIds.widgets.domWidgetTextarea)
|
|
).toBeHidden()
|
|
}
|
|
)
|
|
}
|
|
)
|
|
|
|
test(
|
|
'Promote/Demote by Context Menu',
|
|
{ tag: ['@vue-nodes'] },
|
|
async ({ comfyPage }) => {
|
|
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
|
const ksampler = comfyPage.vueNodes.getNodeLocator('1')
|
|
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
|
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
|
|
|
await test.step('Promote widget', async () => {
|
|
await comfyPage.vueNodes.enterSubgraph('2')
|
|
await comfyPage.subgraph.promoteWidget(ksampler, 'steps')
|
|
await comfyPage.subgraph.exitViaBreadcrumb()
|
|
|
|
await expect(steps).toBeVisible()
|
|
})
|
|
|
|
await test.step('Un-promote widget', async () => {
|
|
await comfyPage.vueNodes.enterSubgraph('2')
|
|
await comfyPage.subgraph.unpromoteWidget(ksampler, 'steps')
|
|
await comfyPage.subgraph.exitViaBreadcrumb()
|
|
|
|
await expect(subgraphNode).toBeVisible()
|
|
await expect(steps).toBeHidden()
|
|
})
|
|
}
|
|
)
|
|
|
|
test(
|
|
'Properties panel operations',
|
|
{ tag: ['@vue-nodes'] },
|
|
async ({ comfyPage }) => {
|
|
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
|
const { editor } = comfyPage.subgraph
|
|
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
|
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
|
const cfg = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'cfg')
|
|
|
|
await editor.togglePromotion(subgraphNode, {
|
|
nodeName: 'KSampler',
|
|
widgetName: 'steps',
|
|
toState: true
|
|
})
|
|
await expect(steps, 'Promote widget').toBeVisible()
|
|
await editor.togglePromotion(subgraphNode, {
|
|
nodeName: 'KSampler',
|
|
widgetName: 'cfg',
|
|
toState: true
|
|
})
|
|
await expect(cfg, 'Promote widget').toBeVisible()
|
|
|
|
await test.step('widgets display in order promoted', async () => {
|
|
await expect(editor.promotionItems.first()).toContainText('steps')
|
|
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
|
|
'steps'
|
|
)
|
|
})
|
|
|
|
await test.step('Reorder widgets', async () => {
|
|
await editor.dragItem(0, 1)
|
|
await expect(editor.promotionItems.first()).toContainText('cfg')
|
|
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
|
|
'cfg'
|
|
)
|
|
})
|
|
|
|
await comfyPage.vueNodes.enterSubgraph('2')
|
|
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
|
await comfyPage.subgraph.unpromoteWidget(ksampler.root, 'steps')
|
|
await comfyPage.subgraph.exitViaBreadcrumb()
|
|
await expect(steps, 'Un-promote widget').toBeHidden()
|
|
}
|
|
)
|
|
|
|
test(
|
|
'Link already promoted widget',
|
|
{ tag: ['@vue-nodes'] },
|
|
async ({ comfyPage }) => {
|
|
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
|
const { editor } = comfyPage.subgraph
|
|
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
|
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
|
|
|
|
await editor.togglePromotion(subgraphNode, {
|
|
nodeName: 'KSampler',
|
|
widgetName: 'steps',
|
|
toState: true
|
|
})
|
|
await expect(steps, 'Promote widget').toBeVisible()
|
|
|
|
await test.step('Enter subgraph and link widget to input', async () => {
|
|
await comfyPage.vueNodes.enterSubgraph('2')
|
|
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
|
|
|
const fromSlot = ksampler.getSlot('steps')
|
|
const toPos = await comfyPage.subgraph
|
|
.getInputSlot()
|
|
.getOpenSlotPosition()
|
|
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
|
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
|
|
await expect.poll(isConnected).toBe(true)
|
|
|
|
await comfyPage.subgraph.exitViaBreadcrumb()
|
|
})
|
|
|
|
await expect(steps).toHaveCount(1)
|
|
}
|
|
)
|
|
|
|
test(
|
|
'Can promote multiple previews',
|
|
{ tag: ['@vue-nodes'] },
|
|
async ({ comfyPage }) => {
|
|
await comfyPage.menu.topbar.newWorkflowButton.click()
|
|
await comfyPage.nextFrame()
|
|
|
|
await test.step('Add and rename a Load Image node', async () => {
|
|
const position = { x: 300, y: 300 }
|
|
await comfyPage.searchBoxV2.addNode('Load Image', { position })
|
|
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
|
|
await loadImage.setTitle('Character Reference')
|
|
})
|
|
|
|
await test.step('Add a second Load Image node', async () => {
|
|
const position = { x: 600, y: 300 }
|
|
await comfyPage.searchBoxV2.addNode('Load Image', { position })
|
|
})
|
|
|
|
await test.step('Convert both nodes to subgraph', async () => {
|
|
await comfyPage.canvas.focus()
|
|
await comfyPage.page.keyboard.press('Control+a')
|
|
await comfyPage.contextMenu
|
|
.openFor(comfyPage.vueNodes.getNodeLocator('1'))
|
|
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
|
|
})
|
|
|
|
const { editor } = comfyPage.subgraph
|
|
const subgraph = await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
|
|
|
|
await test.step('Promote both image previews', async () => {
|
|
await editor.togglePromotion(subgraph.root, {
|
|
nodeId: '1',
|
|
widgetName: '$$canvas-image-preview',
|
|
toState: true
|
|
})
|
|
await expect(subgraph.content).toHaveCount(1)
|
|
|
|
await editor.togglePromotion(subgraph.root, {
|
|
nodeId: '2',
|
|
widgetName: '$$canvas-image-preview',
|
|
toState: true
|
|
})
|
|
|
|
await expect(subgraph.content).toHaveCount(2)
|
|
})
|
|
|
|
await test.step('Demote image', async () => {
|
|
await editor.togglePromotion(subgraph.root, {
|
|
nodeId: '1',
|
|
widgetName: '$$canvas-image-preview',
|
|
toState: false
|
|
})
|
|
await expect(subgraph.content).toHaveCount(1)
|
|
})
|
|
}
|
|
)
|
|
|
|
test(
|
|
'Linked widgets can not be demoted',
|
|
{ tag: ['@vue-nodes'] },
|
|
async ({ comfyPage }) => {
|
|
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
|
const { editor } = comfyPage.subgraph
|
|
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
|
|
|
|
await test.step('Enter subgraph and link widget to input', async () => {
|
|
await comfyPage.vueNodes.enterSubgraph('2')
|
|
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
|
|
|
const fromSlot = ksampler.getSlot('steps')
|
|
const toPos = await comfyPage.subgraph
|
|
.getInputSlot()
|
|
.getOpenSlotPosition()
|
|
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
|
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
|
|
await expect.poll(isConnected).toBe(true)
|
|
|
|
await comfyPage.subgraph.exitViaBreadcrumb()
|
|
})
|
|
|
|
await editor.ensureOpen(subgraphNode)
|
|
const stepsItem = await editor.resolveItem({ widgetName: 'steps' })
|
|
await expect(editor.getToggleButton(stepsItem)).toBeDisabled()
|
|
}
|
|
)
|