diff --git a/browser_tests/fixtures/helpers/CanvasHelper.ts b/browser_tests/fixtures/helpers/CanvasHelper.ts index cc46133071..9b6808839d 100644 --- a/browser_tests/fixtures/helpers/CanvasHelper.ts +++ b/browser_tests/fixtures/helpers/CanvasHelper.ts @@ -1,5 +1,6 @@ import type { Locator, Page } from '@playwright/test' +import { DefaultGraphPositions } from '../constants/defaultGraphPositions' import type { Position } from '../types' export class CanvasHelper { @@ -125,4 +126,59 @@ export class CanvasHelper { return { x: clientX, y: clientY } }, title) } + + async getGroupPosition(title: string): Promise { + const pos = await this.page.evaluate((title) => { + const groups = window['app'].graph.groups + const group = groups.find((g: { title: string }) => g.title === title) + if (!group) return null + return { x: group.pos[0], y: group.pos[1] } + }, title) + if (!pos) throw new Error(`Group "${title}" not found`) + return pos + } + + async dragGroup(options: { + name: string + deltaX: number + deltaY: number + }): Promise { + const { name, deltaX, deltaY } = options + const screenPos = await this.page.evaluate((title) => { + const app = window['app'] + const groups = app.graph.groups + const group = groups.find((g: { title: string }) => g.title === title) + if (!group) return null + const clientPos = app.canvasPosToClientPos([ + group.pos[0] + 50, + group.pos[1] + 15 + ]) + return { x: clientPos[0], y: clientPos[1] } + }, name) + if (!screenPos) throw new Error(`Group "${name}" not found`) + + await this.dragAndDrop(screenPos, { + x: screenPos.x + deltaX, + y: screenPos.y + deltaY + }) + } + + async disconnectEdge(): Promise { + await this.dragAndDrop( + DefaultGraphPositions.clipTextEncodeNode1InputSlot, + DefaultGraphPositions.emptySpace + ) + } + + async connectEdge(options: { reverse?: boolean } = {}): Promise { + const { reverse = false } = options + const start = reverse + ? DefaultGraphPositions.clipTextEncodeNode1InputSlot + : DefaultGraphPositions.loadCheckpointNodeClipOutputSlot + const end = reverse + ? DefaultGraphPositions.loadCheckpointNodeClipOutputSlot + : DefaultGraphPositions.clipTextEncodeNode1InputSlot + + await this.dragAndDrop(start, end) + } } diff --git a/browser_tests/fixtures/helpers/CommandHelper.ts b/browser_tests/fixtures/helpers/CommandHelper.ts new file mode 100644 index 0000000000..c58ca03003 --- /dev/null +++ b/browser_tests/fixtures/helpers/CommandHelper.ts @@ -0,0 +1,68 @@ +import type { Page } from '@playwright/test' + +import type { KeyCombo } from '../../../src/platform/keybindings' + +export class CommandHelper { + constructor(private readonly page: Page) {} + + async executeCommand(commandId: string): Promise { + await this.page.evaluate((id: string) => { + return window['app'].extensionManager.command.execute(id) + }, commandId) + } + + async registerCommand( + commandId: string, + command: (() => void) | (() => Promise) + ): Promise { + await this.page.evaluate( + ({ commandId, commandStr }) => { + const app = window['app'] + const randomSuffix = Math.random().toString(36).substring(2, 8) + const extensionName = `TestExtension_${randomSuffix}` + + app.registerExtension({ + name: extensionName, + commands: [ + { + id: commandId, + function: eval(commandStr) + } + ] + }) + }, + { commandId, commandStr: command.toString() } + ) + } + + async registerKeybinding( + keyCombo: KeyCombo, + command: () => void + ): Promise { + await this.page.evaluate( + ({ keyCombo, commandStr }) => { + const app = window['app'] + const randomSuffix = Math.random().toString(36).substring(2, 8) + const extensionName = `TestExtension_${randomSuffix}` + const commandId = `TestCommand_${randomSuffix}` + + app.registerExtension({ + name: extensionName, + keybindings: [ + { + combo: keyCombo, + commandId: commandId + } + ], + commands: [ + { + id: commandId, + function: eval(commandStr) + } + ] + }) + }, + { keyCombo, commandStr: command.toString() } + ) + } +} diff --git a/browser_tests/fixtures/helpers/DragDropHelper.ts b/browser_tests/fixtures/helpers/DragDropHelper.ts new file mode 100644 index 0000000000..12affe8fb6 --- /dev/null +++ b/browser_tests/fixtures/helpers/DragDropHelper.ts @@ -0,0 +1,159 @@ +import { readFileSync } from 'fs' + +import type { Page } from '@playwright/test' + +import type { Position } from '../types' + +export class DragDropHelper { + constructor( + private readonly page: Page, + private readonly assetPath: (fileName: string) => string + ) {} + + private async nextFrame(): Promise { + await this.page.evaluate(() => { + return new Promise((resolve) => { + requestAnimationFrame(() => resolve()) + }) + }) + } + + async dragAndDropExternalResource( + options: { + fileName?: string + url?: string + dropPosition?: Position + waitForUpload?: boolean + } = {} + ): Promise { + const { + dropPosition = { x: 100, y: 100 }, + fileName, + url, + waitForUpload = false + } = options + + if (!fileName && !url) + throw new Error('Must provide either fileName or url') + + const evaluateParams: { + dropPosition: Position + fileName?: string + fileType?: string + buffer?: Uint8Array | number[] + url?: string + } = { dropPosition } + + if (fileName) { + const filePath = this.assetPath(fileName) + const buffer = readFileSync(filePath) + + const getFileType = (fileName: string) => { + if (fileName.endsWith('.png')) return 'image/png' + if (fileName.endsWith('.svg')) return 'image/svg+xml' + if (fileName.endsWith('.webp')) return 'image/webp' + if (fileName.endsWith('.webm')) return 'video/webm' + if (fileName.endsWith('.json')) return 'application/json' + if (fileName.endsWith('.glb')) return 'model/gltf-binary' + if (fileName.endsWith('.avif')) return 'image/avif' + return 'application/octet-stream' + } + + evaluateParams.fileName = fileName + evaluateParams.fileType = getFileType(fileName) + evaluateParams.buffer = [...new Uint8Array(buffer)] + } + + if (url) evaluateParams.url = url + + const uploadResponsePromise = waitForUpload + ? this.page.waitForResponse( + (resp) => resp.url().includes('/upload/') && resp.status() === 200, + { timeout: 10000 } + ) + : null + + await this.page.evaluate(async (params) => { + const dataTransfer = new DataTransfer() + + if (params.buffer && params.fileName && params.fileType) { + const file = new File( + [new Uint8Array(params.buffer)], + params.fileName, + { + type: params.fileType + } + ) + dataTransfer.items.add(file) + } + + if (params.url) { + dataTransfer.setData('text/uri-list', params.url) + dataTransfer.setData('text/x-moz-url', params.url) + } + + const targetElement = document.elementFromPoint( + params.dropPosition.x, + params.dropPosition.y + ) + + if (!targetElement) { + console.error('No element found at drop position:', params.dropPosition) + return { success: false, error: 'No element at position' } + } + + const eventOptions = { + bubbles: true, + cancelable: true, + dataTransfer, + clientX: params.dropPosition.x, + clientY: params.dropPosition.y + } + + const dragOverEvent = new DragEvent('dragover', eventOptions) + const dropEvent = new DragEvent('drop', eventOptions) + + Object.defineProperty(dropEvent, 'preventDefault', { + value: () => {}, + writable: false + }) + + Object.defineProperty(dropEvent, 'stopPropagation', { + value: () => {}, + writable: false + }) + + targetElement.dispatchEvent(dragOverEvent) + targetElement.dispatchEvent(dropEvent) + + return { + success: true, + targetInfo: { + tagName: targetElement.tagName, + id: targetElement.id, + classList: Array.from(targetElement.classList) + } + } + }, evaluateParams) + + if (uploadResponsePromise) { + await uploadResponsePromise + } + + await this.nextFrame() + } + + async dragAndDropFile( + fileName: string, + options: { dropPosition?: Position; waitForUpload?: boolean } = {} + ): Promise { + return this.dragAndDropExternalResource({ fileName, ...options }) + } + + async dragAndDropURL( + url: string, + options: { dropPosition?: Position } = {} + ): Promise { + return this.dragAndDropExternalResource({ url, ...options }) + } +} diff --git a/browser_tests/fixtures/helpers/NodeOperationsHelper.ts b/browser_tests/fixtures/helpers/NodeOperationsHelper.ts index b67b91cb39..d7984ddb60 100644 --- a/browser_tests/fixtures/helpers/NodeOperationsHelper.ts +++ b/browser_tests/fixtures/helpers/NodeOperationsHelper.ts @@ -1,9 +1,12 @@ +import type { Locator } from '@playwright/test' + import type { LGraph, LGraphNode } from '../../../src/lib/litegraph/src/litegraph' import type { NodeId } from '../../../src/platform/workflow/validation/schemas/workflowSchema' import type { ComfyPage } from '../ComfyPage' +import { DefaultGraphPositions } from '../constants/defaultGraphPositions' import type { Position, Size } from '../types' import { NodeReference } from '../utils/litegraphUtils' @@ -137,4 +140,37 @@ export class NodeOperationsHelper { await this.comfyPage.fillPromptDialog(groupNodeName) await this.comfyPage.nextFrame() } + + get promptDialogInput(): Locator { + return this.page.locator('.p-dialog-content input[type="text"]') + } + + async fillPromptDialog(value: string): Promise { + await this.promptDialogInput.fill(value) + await this.page.keyboard.press('Enter') + await this.promptDialogInput.waitFor({ state: 'hidden' }) + await this.comfyPage.nextFrame() + } + + async dragTextEncodeNode2(): Promise { + await this.comfyPage.canvasOps.dragAndDrop( + DefaultGraphPositions.textEncodeNode2, + { + x: DefaultGraphPositions.textEncodeNode2.x, + y: 300 + } + ) + await this.comfyPage.nextFrame() + } + + async adjustEmptyLatentWidth(): Promise { + await this.page.locator('#graph-canvas').click({ + position: DefaultGraphPositions.emptyLatentWidgetClick + }) + const dialogInput = this.page.locator('.graphdialog input[type="text"]') + await dialogInput.click() + await dialogInput.fill('128') + await dialogInput.press('Enter') + await this.comfyPage.nextFrame() + } } diff --git a/browser_tests/fixtures/helpers/ToastHelper.ts b/browser_tests/fixtures/helpers/ToastHelper.ts new file mode 100644 index 0000000000..167055dcd2 --- /dev/null +++ b/browser_tests/fixtures/helpers/ToastHelper.ts @@ -0,0 +1,41 @@ +import type { Locator, Page } from '@playwright/test' + +export class ToastHelper { + constructor(private readonly page: Page) {} + + get visibleToasts(): Locator { + return this.page.locator('.p-toast-message:visible') + } + + async getToastErrorCount(): Promise { + return await this.page + .locator('.p-toast-message.p-toast-message-error') + .count() + } + + async getVisibleToastCount(): Promise { + return await this.visibleToasts.count() + } + + async closeToasts(requireCount = 0): Promise { + if (requireCount) { + await this.visibleToasts + .nth(requireCount - 1) + .waitFor({ state: 'visible' }) + } + + // Clear all toasts + const toastCloseButtons = await this.page + .locator('.p-toast-close-button') + .all() + for (const button of toastCloseButtons) { + await button.click() + } + + // Wait for toasts to disappear + await this.visibleToasts + .first() + .waitFor({ state: 'hidden', timeout: 1000 }) + .catch(() => {}) + } +} diff --git a/browser_tests/fixtures/helpers/WorkflowHelper.ts b/browser_tests/fixtures/helpers/WorkflowHelper.ts index 82f8ec15e8..7964477532 100644 --- a/browser_tests/fixtures/helpers/WorkflowHelper.ts +++ b/browser_tests/fixtures/helpers/WorkflowHelper.ts @@ -1,7 +1,10 @@ import { readFileSync } from 'fs' +import type { useWorkspaceStore } from '../../../src/stores/workspaceStore' import type { ComfyPage } from '../ComfyPage' +type WorkspaceStore = ReturnType + export type FolderStructure = { [key: string]: FolderStructure | string } @@ -80,4 +83,34 @@ export class WorkflowHelper { await this.comfyPage.closeToasts(1) await workflowsTab.close() } + + async getUndoQueueSize(): Promise { + return this.comfyPage.page.evaluate(() => { + const workflow = (window['app'].extensionManager as WorkspaceStore) + .workflow.activeWorkflow + return workflow?.changeTracker.undoQueue.length + }) + } + + async getRedoQueueSize(): Promise { + return this.comfyPage.page.evaluate(() => { + const workflow = (window['app'].extensionManager as WorkspaceStore) + .workflow.activeWorkflow + return workflow?.changeTracker.redoQueue.length + }) + } + + async isCurrentWorkflowModified(): Promise { + return this.comfyPage.page.evaluate(() => { + return (window['app'].extensionManager as WorkspaceStore).workflow + .activeWorkflow?.isModified + }) + } + + async getExportedWorkflow(options?: { api?: boolean }): Promise { + const api = options?.api ?? false + return this.comfyPage.page.evaluate(async (api) => { + return (await window['app'].graphToPrompt())[api ? 'output' : 'workflow'] + }, api) + } }