From 59c999324ec8dafa4423e5b98d9165894b6fd350 Mon Sep 17 00:00:00 2001 From: Chenlei Hu Date: Fri, 25 Oct 2024 08:29:02 -0400 Subject: [PATCH] Split ComfyPage fixture (#1305) * Split down page components * Move litegraph utils * nit --- browser_tests/fixtures/ComfyPage.ts | 533 +----------------- .../fixtures/components/ComfyNodeSearchBox.ts | 79 +++ .../fixtures/components/SidebarTab.ts | 120 ++++ browser_tests/fixtures/components/Topbar.ts | 66 +++ browser_tests/fixtures/types.ts | 9 + .../fixtures/utils/litegraphUtils.ts | 257 +++++++++ browser_tests/groupNode.spec.ts | 7 +- browser_tests/primitiveNode.spec.ts | 6 +- 8 files changed, 543 insertions(+), 534 deletions(-) create mode 100644 browser_tests/fixtures/components/ComfyNodeSearchBox.ts create mode 100644 browser_tests/fixtures/components/SidebarTab.ts create mode 100644 browser_tests/fixtures/components/Topbar.ts create mode 100644 browser_tests/fixtures/types.ts create mode 100644 browser_tests/fixtures/utils/litegraphUtils.ts diff --git a/browser_tests/fixtures/ComfyPage.ts b/browser_tests/fixtures/ComfyPage.ts index 4938ec9362..c18dfb9c67 100644 --- a/browser_tests/fixtures/ComfyPage.ts +++ b/browser_tests/fixtures/ComfyPage.ts @@ -8,280 +8,15 @@ import * as fs from 'fs' import { NodeBadgeMode } from '../../src/types/nodeSource' import type { NodeId } from '../../src/types/comfyWorkflow' import type { KeyCombo } from '../../src/types/keyBindingTypes' -import { ManageGroupNode } from '../helpers/manageGroupNode' import { ComfyTemplates } from '../helpers/templates' - -interface Position { - x: number - y: number -} - -interface Size { - width: number - height: number -} - -class ComfyNodeSearchFilterSelectionPanel { - constructor(public readonly page: Page) {} - - async selectFilterType(filterType: string) { - await this.page - .locator( - `.filter-type-select .p-togglebutton-label:has-text("${filterType}")` - ) - .click() - } - - async selectFilterValue(filterValue: string) { - await this.page.locator('.filter-value-select .p-select-dropdown').click() - await this.page - .locator( - `.p-select-overlay .p-select-list .p-select-option-label:text-is("${filterValue}")` - ) - .click() - } - - async addFilter(filterValue: string, filterType: string) { - await this.selectFilterType(filterType) - await this.selectFilterValue(filterValue) - await this.page.locator('.p-button-label:has-text("Add")').click() - } -} - -class ComfyNodeSearchBox { - public readonly input: Locator - public readonly dropdown: Locator - public readonly filterSelectionPanel: ComfyNodeSearchFilterSelectionPanel - - constructor(public readonly page: Page) { - this.input = page.locator( - '.comfy-vue-node-search-container input[type="text"]' - ) - this.dropdown = page.locator( - '.comfy-vue-node-search-container .p-autocomplete-list' - ) - this.filterSelectionPanel = new ComfyNodeSearchFilterSelectionPanel(page) - } - - get filterButton() { - return this.page.locator('.comfy-vue-node-search-container ._filter-button') - } - - async fillAndSelectFirstNode( - nodeName: string, - options?: { suggestionIndex: number } - ) { - await this.input.waitFor({ state: 'visible' }) - await this.input.fill(nodeName) - await this.dropdown.waitFor({ state: 'visible' }) - // Wait for some time for the auto complete list to update. - // The auto complete list is debounced and may take some time to update. - await this.page.waitForTimeout(500) - await this.dropdown - .locator('li') - .nth(options?.suggestionIndex || 0) - .click() - } - - async addFilter(filterValue: string, filterType: string) { - await this.filterButton.click() - await this.filterSelectionPanel.addFilter(filterValue, filterType) - } - - get filterChips() { - return this.page.locator( - '.comfy-vue-node-search-container .p-autocomplete-chip-item' - ) - } - - async removeFilter(index: number) { - await this.filterChips.nth(index).locator('.p-chip-remove-icon').click() - } -} - -class SidebarTab { - constructor( - public readonly page: Page, - public readonly tabId: string - ) {} - - get tabButton() { - return this.page.locator(`.${this.tabId}-tab-button`) - } - - get selectedTabButton() { - return this.page.locator( - `.${this.tabId}-tab-button.side-bar-button-selected` - ) - } - - async open() { - if (await this.selectedTabButton.isVisible()) { - return - } - await this.tabButton.click() - } -} - -class NodeLibrarySidebarTab extends SidebarTab { - constructor(public readonly page: Page) { - super(page, 'node-library') - } - - get nodeLibrarySearchBoxInput() { - return this.page.locator('.node-lib-search-box input[type="text"]') - } - - get nodeLibraryTree() { - return this.page.locator('.node-lib-tree-explorer') - } - - get nodePreview() { - return this.page.locator('.node-lib-node-preview') - } - - get tabContainer() { - return this.page.locator('.sidebar-content-container') - } - - get newFolderButton() { - return this.tabContainer.locator('.new-folder-button') - } - - async open() { - await super.open() - await this.nodeLibraryTree.waitFor({ state: 'visible' }) - } - - async close() { - if (!this.tabButton.isVisible()) { - return - } - - await this.tabButton.click() - await this.nodeLibraryTree.waitFor({ state: 'hidden' }) - } - - folderSelector(folderName: string) { - return `.p-tree-node-content:has(> .tree-explorer-node-label:has(.tree-folder .node-label:has-text("${folderName}")))` - } - - getFolder(folderName: string) { - return this.page.locator(this.folderSelector(folderName)) - } - - nodeSelector(nodeName: string) { - return `.p-tree-node-content:has(> .tree-explorer-node-label:has(.tree-leaf .node-label:has-text("${nodeName}")))` - } - - getNode(nodeName: string) { - return this.page.locator(this.nodeSelector(nodeName)) - } -} - -class WorkflowsSidebarTab extends SidebarTab { - constructor(public readonly page: Page) { - super(page, 'workflows') - } - - get browseGalleryButton() { - return this.page.locator('.browse-templates-button') - } - - get newBlankWorkflowButton() { - return this.page.locator('.new-blank-workflow-button') - } - - get openWorkflowButton() { - return this.page.locator('.open-workflow-button') - } - - async getOpenedWorkflowNames() { - return await this.page - .locator('.comfyui-workflows-open .node-label') - .allInnerTexts() - } - - async getTopLevelSavedWorkflowNames() { - return await this.page - .locator('.comfyui-workflows-browse .node-label') - .allInnerTexts() - } - - async switchToWorkflow(workflowName: string) { - const workflowLocator = this.page.locator( - '.comfyui-workflows-open .node-label', - { hasText: workflowName } - ) - await workflowLocator.click() - await this.page.waitForTimeout(300) - } -} - -class Topbar { - constructor(public readonly page: Page) {} - - async getTabNames(): Promise { - return await this.page - .locator('.workflow-tabs .workflow-label') - .allInnerTexts() - } - - async openSubmenuMobile() { - await this.page.locator('.p-menubar-mobile .p-menubar-button').click() - } - - async getMenuItem(itemLabel: string): Promise { - return this.page.locator(`.p-menubar-item-label:text-is("${itemLabel}")`) - } - - async getWorkflowTab(tabName: string): Promise { - return this.page - .locator(`.workflow-tabs .workflow-label:has-text("${tabName}")`) - .locator('..') - } - - async closeWorkflowTab(tabName: string) { - const tab = await this.getWorkflowTab(tabName) - await tab.locator('.close-button').click({ force: true }) - } - - async saveWorkflow(workflowName: string) { - await this.triggerTopbarCommand(['Workflow', 'Save']) - await this.page.locator('.p-dialog-content input').fill(workflowName) - await this.page.keyboard.press('Enter') - // Wait for the dialog to close. - await this.page.waitForTimeout(300) - } - - async triggerTopbarCommand(path: string[]) { - if (path.length < 2) { - throw new Error('Path is too short') - } - - const tabName = path[0] - const topLevelMenu = this.page.locator( - `.top-menubar .p-menubar-item-label:text-is("${tabName}")` - ) - await topLevelMenu.waitFor({ state: 'visible' }) - await topLevelMenu.click() - - for (let i = 1; i < path.length; i++) { - const commandName = path[i] - const menuItem = this.page - .locator( - `.top-menubar .p-menubar-submenu .p-menubar-item:has-text("${commandName}")` - ) - .first() - await menuItem.waitFor({ state: 'visible' }) - await menuItem.hover() - - if (i === path.length - 1) { - await menuItem.click() - } - } - } -} +import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox' +import { + NodeLibrarySidebarTab, + WorkflowsSidebarTab +} from './components/SidebarTab' +import { Topbar } from './components/Topbar' +import type { NodeReference } from './utils/litegraphUtils' +import type { Position, Size } from './types' class ComfyMenu { public readonly sideToolbar: Locator @@ -984,258 +719,6 @@ export class ComfyPage { } } -export class NodeSlotReference { - constructor( - readonly type: 'input' | 'output', - readonly index: number, - readonly node: NodeReference - ) {} - async getPosition() { - const pos: [number, number] = await this.node.comfyPage.page.evaluate( - ([type, id, index]) => { - const node = window['app'].graph.getNodeById(id) - if (!node) throw new Error(`Node ${id} not found.`) - return window['app'].canvas.ds.convertOffsetToCanvas( - node.getConnectionPos(type === 'input', index) - ) - }, - [this.type, this.node.id, this.index] as const - ) - return { - x: pos[0], - y: pos[1] - } - } - async getLinkCount() { - return await this.node.comfyPage.page.evaluate( - ([type, id, index]) => { - const node = window['app'].graph.getNodeById(id) - if (!node) throw new Error(`Node ${id} not found.`) - if (type === 'input') { - return node.inputs[index].link == null ? 0 : 1 - } - return node.outputs[index].links?.length ?? 0 - }, - [this.type, this.node.id, this.index] as const - ) - } - async removeLinks() { - await this.node.comfyPage.page.evaluate( - ([type, id, index]) => { - const node = window['app'].graph.getNodeById(id) - if (!node) throw new Error(`Node ${id} not found.`) - if (type === 'input') { - node.disconnectInput(index) - } else { - node.disconnectOutput(index) - } - }, - [this.type, this.node.id, this.index] as const - ) - } -} - -export class NodeWidgetReference { - constructor( - readonly index: number, - readonly node: NodeReference - ) {} - - async getPosition(): Promise { - const pos: [number, number] = await this.node.comfyPage.page.evaluate( - ([id, index]) => { - const node = window['app'].graph.getNodeById(id) - if (!node) throw new Error(`Node ${id} not found.`) - const widget = node.widgets[index] - if (!widget) throw new Error(`Widget ${index} not found.`) - - const [x, y, w, h] = node.getBounding() - return window['app'].canvas.ds.convertOffsetToCanvas([ - x + w / 2, - y + window['LiteGraph']['NODE_TITLE_HEIGHT'] + widget.last_y + 1 - ]) - }, - [this.node.id, this.index] as const - ) - return { - x: pos[0], - y: pos[1] - } - } -} - -export class NodeReference { - constructor( - readonly id: NodeId, - readonly comfyPage: ComfyPage - ) {} - async exists(): Promise { - return await this.comfyPage.page.evaluate((id) => { - const node = window['app'].graph.getNodeById(id) - return !!node - }, this.id) - } - getType(): Promise { - return this.getProperty('type') - } - async getPosition(): Promise { - const pos = await this.comfyPage.convertOffsetToCanvas( - await this.getProperty<[number, number]>('pos') - ) - return { - x: pos[0], - y: pos[1] - } - } - async getBounding(): Promise { - const [x, y, width, height]: [number, number, number, number] = - await this.comfyPage.page.evaluate((id) => { - const node = window['app'].graph.getNodeById(id) - if (!node) throw new Error('Node not found') - return node.getBounding() - }, this.id) - return { - x, - y, - width, - height - } - } - async getSize(): Promise { - const size = await this.getProperty<[number, number]>('size') - return { - width: size[0], - height: size[1] - } - } - async getFlags(): Promise<{ collapsed?: boolean; pinned?: boolean }> { - return await this.getProperty('flags') - } - async isPinned() { - return !!(await this.getFlags()).pinned - } - async isCollapsed() { - return !!(await this.getFlags()).collapsed - } - async isBypassed() { - return (await this.getProperty('mode')) === 4 - } - async getProperty(prop: string): Promise { - return await this.comfyPage.page.evaluate( - ([id, prop]) => { - const node = window['app'].graph.getNodeById(id) - if (!node) throw new Error('Node not found') - return node[prop] - }, - [this.id, prop] as const - ) - } - async getOutput(index: number) { - return new NodeSlotReference('output', index, this) - } - async getInput(index: number) { - return new NodeSlotReference('input', index, this) - } - async getWidget(index: number) { - return new NodeWidgetReference(index, this) - } - async click( - position: 'title' | 'collapse', - options?: Parameters[1] & { moveMouseToEmptyArea?: boolean } - ) { - const nodePos = await this.getPosition() - const nodeSize = await this.getSize() - let clickPos: Position - switch (position) { - case 'title': - clickPos = { x: nodePos.x + nodeSize.width / 2, y: nodePos.y - 15 } - break - case 'collapse': - clickPos = { x: nodePos.x + 5, y: nodePos.y - 10 } - break - default: - throw new Error(`Invalid click position ${position}`) - } - - const moveMouseToEmptyArea = options?.moveMouseToEmptyArea - if (options) { - delete options.moveMouseToEmptyArea - } - - await this.comfyPage.canvas.click({ - ...options, - position: clickPos - }) - await this.comfyPage.nextFrame() - if (moveMouseToEmptyArea) { - await this.comfyPage.moveMouseToEmptyArea() - } - } - async copy() { - await this.click('title') - await this.comfyPage.ctrlC() - await this.comfyPage.nextFrame() - } - async connectWidget( - originSlotIndex: number, - targetNode: NodeReference, - targetWidgetIndex: number - ) { - const originSlot = await this.getOutput(originSlotIndex) - const targetWidget = await targetNode.getWidget(targetWidgetIndex) - await this.comfyPage.dragAndDrop( - await originSlot.getPosition(), - await targetWidget.getPosition() - ) - return originSlot - } - async connectOutput( - originSlotIndex: number, - targetNode: NodeReference, - targetSlotIndex: number - ) { - const originSlot = await this.getOutput(originSlotIndex) - const targetSlot = await targetNode.getInput(targetSlotIndex) - await this.comfyPage.dragAndDrop( - await originSlot.getPosition(), - await targetSlot.getPosition() - ) - return originSlot - } - async getContextMenuOptionNames() { - await this.click('title', { button: 'right' }) - const ctx = this.comfyPage.page.locator('.litecontextmenu') - return await ctx.locator('.litemenu-entry').allInnerTexts() - } - async clickContextMenuOption(optionText: string) { - await this.click('title', { button: 'right' }) - const ctx = this.comfyPage.page.locator('.litecontextmenu') - await ctx.getByText(optionText).click() - } - async convertToGroupNode(groupNodeName: string = 'GroupNode') { - this.comfyPage.page.once('dialog', async (dialog) => { - await dialog.accept(groupNodeName) - }) - await this.clickContextMenuOption('Convert to Group Node') - await this.comfyPage.nextFrame() - const nodes = await this.comfyPage.getNodeRefsByType( - `workflow>${groupNodeName}` - ) - if (nodes.length !== 1) { - throw new Error(`Did not find single group node (found=${nodes.length})`) - } - return nodes[0] - } - async manageGroupNode() { - await this.clickContextMenuOption('Manage Group Node') - await this.comfyPage.nextFrame() - return new ManageGroupNode( - this.comfyPage.page, - this.comfyPage.page.locator('.comfy-group-manage') - ) - } -} - export const comfyPageFixture = base.extend<{ comfyPage: ComfyPage }>({ comfyPage: async ({ page, request }, use) => { const comfyPage = new ComfyPage(page, request) diff --git a/browser_tests/fixtures/components/ComfyNodeSearchBox.ts b/browser_tests/fixtures/components/ComfyNodeSearchBox.ts new file mode 100644 index 0000000000..061af3dd18 --- /dev/null +++ b/browser_tests/fixtures/components/ComfyNodeSearchBox.ts @@ -0,0 +1,79 @@ +import { Locator, Page } from '@playwright/test' + +export class ComfyNodeSearchFilterSelectionPanel { + constructor(public readonly page: Page) {} + + async selectFilterType(filterType: string) { + await this.page + .locator( + `.filter-type-select .p-togglebutton-label:has-text("${filterType}")` + ) + .click() + } + + async selectFilterValue(filterValue: string) { + await this.page.locator('.filter-value-select .p-select-dropdown').click() + await this.page + .locator( + `.p-select-overlay .p-select-list .p-select-option-label:text-is("${filterValue}")` + ) + .click() + } + + async addFilter(filterValue: string, filterType: string) { + await this.selectFilterType(filterType) + await this.selectFilterValue(filterValue) + await this.page.locator('.p-button-label:has-text("Add")').click() + } +} + +export class ComfyNodeSearchBox { + public readonly input: Locator + public readonly dropdown: Locator + public readonly filterSelectionPanel: ComfyNodeSearchFilterSelectionPanel + + constructor(public readonly page: Page) { + this.input = page.locator( + '.comfy-vue-node-search-container input[type="text"]' + ) + this.dropdown = page.locator( + '.comfy-vue-node-search-container .p-autocomplete-list' + ) + this.filterSelectionPanel = new ComfyNodeSearchFilterSelectionPanel(page) + } + + get filterButton() { + return this.page.locator('.comfy-vue-node-search-container ._filter-button') + } + + async fillAndSelectFirstNode( + nodeName: string, + options?: { suggestionIndex: number } + ) { + await this.input.waitFor({ state: 'visible' }) + await this.input.fill(nodeName) + await this.dropdown.waitFor({ state: 'visible' }) + // Wait for some time for the auto complete list to update. + // The auto complete list is debounced and may take some time to update. + await this.page.waitForTimeout(500) + await this.dropdown + .locator('li') + .nth(options?.suggestionIndex || 0) + .click() + } + + async addFilter(filterValue: string, filterType: string) { + await this.filterButton.click() + await this.filterSelectionPanel.addFilter(filterValue, filterType) + } + + get filterChips() { + return this.page.locator( + '.comfy-vue-node-search-container .p-autocomplete-chip-item' + ) + } + + async removeFilter(index: number) { + await this.filterChips.nth(index).locator('.p-chip-remove-icon').click() + } +} diff --git a/browser_tests/fixtures/components/SidebarTab.ts b/browser_tests/fixtures/components/SidebarTab.ts new file mode 100644 index 0000000000..6e21e49e09 --- /dev/null +++ b/browser_tests/fixtures/components/SidebarTab.ts @@ -0,0 +1,120 @@ +import { Page } from '@playwright/test' + +class SidebarTab { + constructor( + public readonly page: Page, + public readonly tabId: string + ) {} + + get tabButton() { + return this.page.locator(`.${this.tabId}-tab-button`) + } + + get selectedTabButton() { + return this.page.locator( + `.${this.tabId}-tab-button.side-bar-button-selected` + ) + } + + async open() { + if (await this.selectedTabButton.isVisible()) { + return + } + await this.tabButton.click() + } +} + +export class NodeLibrarySidebarTab extends SidebarTab { + constructor(public readonly page: Page) { + super(page, 'node-library') + } + + get nodeLibrarySearchBoxInput() { + return this.page.locator('.node-lib-search-box input[type="text"]') + } + + get nodeLibraryTree() { + return this.page.locator('.node-lib-tree-explorer') + } + + get nodePreview() { + return this.page.locator('.node-lib-node-preview') + } + + get tabContainer() { + return this.page.locator('.sidebar-content-container') + } + + get newFolderButton() { + return this.tabContainer.locator('.new-folder-button') + } + + async open() { + await super.open() + await this.nodeLibraryTree.waitFor({ state: 'visible' }) + } + + async close() { + if (!this.tabButton.isVisible()) { + return + } + + await this.tabButton.click() + await this.nodeLibraryTree.waitFor({ state: 'hidden' }) + } + + folderSelector(folderName: string) { + return `.p-tree-node-content:has(> .tree-explorer-node-label:has(.tree-folder .node-label:has-text("${folderName}")))` + } + + getFolder(folderName: string) { + return this.page.locator(this.folderSelector(folderName)) + } + + nodeSelector(nodeName: string) { + return `.p-tree-node-content:has(> .tree-explorer-node-label:has(.tree-leaf .node-label:has-text("${nodeName}")))` + } + + getNode(nodeName: string) { + return this.page.locator(this.nodeSelector(nodeName)) + } +} + +export class WorkflowsSidebarTab extends SidebarTab { + constructor(public readonly page: Page) { + super(page, 'workflows') + } + + get browseGalleryButton() { + return this.page.locator('.browse-templates-button') + } + + get newBlankWorkflowButton() { + return this.page.locator('.new-blank-workflow-button') + } + + get openWorkflowButton() { + return this.page.locator('.open-workflow-button') + } + + async getOpenedWorkflowNames() { + return await this.page + .locator('.comfyui-workflows-open .node-label') + .allInnerTexts() + } + + async getTopLevelSavedWorkflowNames() { + return await this.page + .locator('.comfyui-workflows-browse .node-label') + .allInnerTexts() + } + + async switchToWorkflow(workflowName: string) { + const workflowLocator = this.page.locator( + '.comfyui-workflows-open .node-label', + { hasText: workflowName } + ) + await workflowLocator.click() + await this.page.waitForTimeout(300) + } +} diff --git a/browser_tests/fixtures/components/Topbar.ts b/browser_tests/fixtures/components/Topbar.ts new file mode 100644 index 0000000000..fc0692b66c --- /dev/null +++ b/browser_tests/fixtures/components/Topbar.ts @@ -0,0 +1,66 @@ +import { Locator, Page } from '@playwright/test' + +export class Topbar { + constructor(public readonly page: Page) {} + + async getTabNames(): Promise { + return await this.page + .locator('.workflow-tabs .workflow-label') + .allInnerTexts() + } + + async openSubmenuMobile() { + await this.page.locator('.p-menubar-mobile .p-menubar-button').click() + } + + async getMenuItem(itemLabel: string): Promise { + return this.page.locator(`.p-menubar-item-label:text-is("${itemLabel}")`) + } + + async getWorkflowTab(tabName: string): Promise { + return this.page + .locator(`.workflow-tabs .workflow-label:has-text("${tabName}")`) + .locator('..') + } + + async closeWorkflowTab(tabName: string) { + const tab = await this.getWorkflowTab(tabName) + await tab.locator('.close-button').click({ force: true }) + } + + async saveWorkflow(workflowName: string) { + await this.triggerTopbarCommand(['Workflow', 'Save']) + await this.page.locator('.p-dialog-content input').fill(workflowName) + await this.page.keyboard.press('Enter') + // Wait for the dialog to close. + await this.page.waitForTimeout(300) + } + + async triggerTopbarCommand(path: string[]) { + if (path.length < 2) { + throw new Error('Path is too short') + } + + const tabName = path[0] + const topLevelMenu = this.page.locator( + `.top-menubar .p-menubar-item-label:text-is("${tabName}")` + ) + await topLevelMenu.waitFor({ state: 'visible' }) + await topLevelMenu.click() + + for (let i = 1; i < path.length; i++) { + const commandName = path[i] + const menuItem = this.page + .locator( + `.top-menubar .p-menubar-submenu .p-menubar-item:has-text("${commandName}")` + ) + .first() + await menuItem.waitFor({ state: 'visible' }) + await menuItem.hover() + + if (i === path.length - 1) { + await menuItem.click() + } + } + } +} diff --git a/browser_tests/fixtures/types.ts b/browser_tests/fixtures/types.ts new file mode 100644 index 0000000000..57aece7a2b --- /dev/null +++ b/browser_tests/fixtures/types.ts @@ -0,0 +1,9 @@ +export interface Position { + x: number + y: number +} + +export interface Size { + width: number + height: number +} diff --git a/browser_tests/fixtures/utils/litegraphUtils.ts b/browser_tests/fixtures/utils/litegraphUtils.ts new file mode 100644 index 0000000000..f065c4abc4 --- /dev/null +++ b/browser_tests/fixtures/utils/litegraphUtils.ts @@ -0,0 +1,257 @@ +import { ManageGroupNode } from '../../helpers/manageGroupNode' +import type { NodeId } from '../../../src/types/comfyWorkflow' +import type { Page } from '@playwright/test' +import type { ComfyPage } from '../ComfyPage' +import type { Position, Size } from '../types' + +export class NodeSlotReference { + constructor( + readonly type: 'input' | 'output', + readonly index: number, + readonly node: NodeReference + ) {} + async getPosition() { + const pos: [number, number] = await this.node.comfyPage.page.evaluate( + ([type, id, index]) => { + const node = window['app'].graph.getNodeById(id) + if (!node) throw new Error(`Node ${id} not found.`) + return window['app'].canvas.ds.convertOffsetToCanvas( + node.getConnectionPos(type === 'input', index) + ) + }, + [this.type, this.node.id, this.index] as const + ) + return { + x: pos[0], + y: pos[1] + } + } + async getLinkCount() { + return await this.node.comfyPage.page.evaluate( + ([type, id, index]) => { + const node = window['app'].graph.getNodeById(id) + if (!node) throw new Error(`Node ${id} not found.`) + if (type === 'input') { + return node.inputs[index].link == null ? 0 : 1 + } + return node.outputs[index].links?.length ?? 0 + }, + [this.type, this.node.id, this.index] as const + ) + } + async removeLinks() { + await this.node.comfyPage.page.evaluate( + ([type, id, index]) => { + const node = window['app'].graph.getNodeById(id) + if (!node) throw new Error(`Node ${id} not found.`) + if (type === 'input') { + node.disconnectInput(index) + } else { + node.disconnectOutput(index) + } + }, + [this.type, this.node.id, this.index] as const + ) + } +} + +export class NodeWidgetReference { + constructor( + readonly index: number, + readonly node: NodeReference + ) {} + + async getPosition(): Promise { + const pos: [number, number] = await this.node.comfyPage.page.evaluate( + ([id, index]) => { + const node = window['app'].graph.getNodeById(id) + if (!node) throw new Error(`Node ${id} not found.`) + const widget = node.widgets[index] + if (!widget) throw new Error(`Widget ${index} not found.`) + + const [x, y, w, h] = node.getBounding() + return window['app'].canvas.ds.convertOffsetToCanvas([ + x + w / 2, + y + window['LiteGraph']['NODE_TITLE_HEIGHT'] + widget.last_y + 1 + ]) + }, + [this.node.id, this.index] as const + ) + return { + x: pos[0], + y: pos[1] + } + } +} + +export class NodeReference { + constructor( + readonly id: NodeId, + readonly comfyPage: ComfyPage + ) {} + async exists(): Promise { + return await this.comfyPage.page.evaluate((id) => { + const node = window['app'].graph.getNodeById(id) + return !!node + }, this.id) + } + getType(): Promise { + return this.getProperty('type') + } + async getPosition(): Promise { + const pos = await this.comfyPage.convertOffsetToCanvas( + await this.getProperty<[number, number]>('pos') + ) + return { + x: pos[0], + y: pos[1] + } + } + async getBounding(): Promise { + const [x, y, width, height]: [number, number, number, number] = + await this.comfyPage.page.evaluate((id) => { + const node = window['app'].graph.getNodeById(id) + if (!node) throw new Error('Node not found') + return node.getBounding() + }, this.id) + return { + x, + y, + width, + height + } + } + async getSize(): Promise { + const size = await this.getProperty<[number, number]>('size') + return { + width: size[0], + height: size[1] + } + } + async getFlags(): Promise<{ collapsed?: boolean; pinned?: boolean }> { + return await this.getProperty('flags') + } + async isPinned() { + return !!(await this.getFlags()).pinned + } + async isCollapsed() { + return !!(await this.getFlags()).collapsed + } + async isBypassed() { + return (await this.getProperty('mode')) === 4 + } + async getProperty(prop: string): Promise { + return await this.comfyPage.page.evaluate( + ([id, prop]) => { + const node = window['app'].graph.getNodeById(id) + if (!node) throw new Error('Node not found') + return node[prop] + }, + [this.id, prop] as const + ) + } + async getOutput(index: number) { + return new NodeSlotReference('output', index, this) + } + async getInput(index: number) { + return new NodeSlotReference('input', index, this) + } + async getWidget(index: number) { + return new NodeWidgetReference(index, this) + } + async click( + position: 'title' | 'collapse', + options?: Parameters[1] & { moveMouseToEmptyArea?: boolean } + ) { + const nodePos = await this.getPosition() + const nodeSize = await this.getSize() + let clickPos: Position + switch (position) { + case 'title': + clickPos = { x: nodePos.x + nodeSize.width / 2, y: nodePos.y - 15 } + break + case 'collapse': + clickPos = { x: nodePos.x + 5, y: nodePos.y - 10 } + break + default: + throw new Error(`Invalid click position ${position}`) + } + + const moveMouseToEmptyArea = options?.moveMouseToEmptyArea + if (options) { + delete options.moveMouseToEmptyArea + } + + await this.comfyPage.canvas.click({ + ...options, + position: clickPos + }) + await this.comfyPage.nextFrame() + if (moveMouseToEmptyArea) { + await this.comfyPage.moveMouseToEmptyArea() + } + } + async copy() { + await this.click('title') + await this.comfyPage.ctrlC() + await this.comfyPage.nextFrame() + } + async connectWidget( + originSlotIndex: number, + targetNode: NodeReference, + targetWidgetIndex: number + ) { + const originSlot = await this.getOutput(originSlotIndex) + const targetWidget = await targetNode.getWidget(targetWidgetIndex) + await this.comfyPage.dragAndDrop( + await originSlot.getPosition(), + await targetWidget.getPosition() + ) + return originSlot + } + async connectOutput( + originSlotIndex: number, + targetNode: NodeReference, + targetSlotIndex: number + ) { + const originSlot = await this.getOutput(originSlotIndex) + const targetSlot = await targetNode.getInput(targetSlotIndex) + await this.comfyPage.dragAndDrop( + await originSlot.getPosition(), + await targetSlot.getPosition() + ) + return originSlot + } + async getContextMenuOptionNames() { + await this.click('title', { button: 'right' }) + const ctx = this.comfyPage.page.locator('.litecontextmenu') + return await ctx.locator('.litemenu-entry').allInnerTexts() + } + async clickContextMenuOption(optionText: string) { + await this.click('title', { button: 'right' }) + const ctx = this.comfyPage.page.locator('.litecontextmenu') + await ctx.getByText(optionText).click() + } + async convertToGroupNode(groupNodeName: string = 'GroupNode') { + this.comfyPage.page.once('dialog', async (dialog) => { + await dialog.accept(groupNodeName) + }) + await this.clickContextMenuOption('Convert to Group Node') + await this.comfyPage.nextFrame() + const nodes = await this.comfyPage.getNodeRefsByType( + `workflow>${groupNodeName}` + ) + if (nodes.length !== 1) { + throw new Error(`Did not find single group node (found=${nodes.length})`) + } + return nodes[0] + } + async manageGroupNode() { + await this.clickContextMenuOption('Manage Group Node') + await this.comfyPage.nextFrame() + return new ManageGroupNode( + this.comfyPage.page, + this.comfyPage.page.locator('.comfy-group-manage') + ) + } +} diff --git a/browser_tests/groupNode.spec.ts b/browser_tests/groupNode.spec.ts index f2d9383ff7..c646c5111b 100644 --- a/browser_tests/groupNode.spec.ts +++ b/browser_tests/groupNode.spec.ts @@ -1,9 +1,6 @@ import { expect } from '@playwright/test' -import { - ComfyPage, - NodeReference, - comfyPageFixture as test -} from './fixtures/ComfyPage' +import { ComfyPage, comfyPageFixture as test } from './fixtures/ComfyPage' +import type { NodeReference } from './fixtures/utils/litegraphUtils' test.describe('Group Node', () => { test.afterEach(async ({ comfyPage }) => { diff --git a/browser_tests/primitiveNode.spec.ts b/browser_tests/primitiveNode.spec.ts index 8bfecffd17..6ac3ef8a2d 100644 --- a/browser_tests/primitiveNode.spec.ts +++ b/browser_tests/primitiveNode.spec.ts @@ -1,8 +1,6 @@ import { expect } from '@playwright/test' -import { - type NodeReference, - comfyPageFixture as test -} from './fixtures/ComfyPage' +import { comfyPageFixture as test } from './fixtures/ComfyPage' +import type { NodeReference } from './fixtures/utils/litegraphUtils' test.describe('Primitive Node', () => { test('Can load with correct size', async ({ comfyPage }) => {