mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-30 12:59:55 +00:00
* Fix loading workflow from of edited webp * Update test expectations [skip ci] --------- Co-authored-by: github-actions <github-actions@github.com>
598 lines
15 KiB
TypeScript
598 lines
15 KiB
TypeScript
import type { Page, Locator } from '@playwright/test'
|
|
import { test as base } from '@playwright/test'
|
|
import dotenv from 'dotenv'
|
|
dotenv.config()
|
|
import * as fs from 'fs'
|
|
import * as path from 'path'
|
|
|
|
interface Position {
|
|
x: number
|
|
y: number
|
|
}
|
|
|
|
interface Size {
|
|
width: number
|
|
height: number
|
|
}
|
|
|
|
class ComfyNodeSearchBox {
|
|
public readonly input: Locator
|
|
public readonly dropdown: Locator
|
|
|
|
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'
|
|
)
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|
|
|
|
class NodeLibrarySidebarTab {
|
|
public readonly tabId: string = 'node-library'
|
|
constructor(public readonly page: Page) {}
|
|
|
|
get tabButton() {
|
|
return this.page.locator(`.${this.tabId}-tab-button`)
|
|
}
|
|
|
|
get selectedTabButton() {
|
|
return this.page.locator(
|
|
`.${this.tabId}-tab-button.side-bar-button-selected`
|
|
)
|
|
}
|
|
|
|
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() {
|
|
if (await this.selectedTabButton.isVisible()) {
|
|
return
|
|
}
|
|
|
|
await this.tabButton.click()
|
|
await this.nodeLibraryTree.waitFor({ state: 'visible' })
|
|
}
|
|
|
|
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 ComfyMenu {
|
|
public readonly sideToolbar: Locator
|
|
public readonly themeToggleButton: Locator
|
|
|
|
constructor(public readonly page: Page) {
|
|
this.sideToolbar = page.locator('.side-tool-bar-container')
|
|
this.themeToggleButton = page.locator('.comfy-vue-theme-toggle')
|
|
}
|
|
|
|
get nodeLibraryTab() {
|
|
return new NodeLibrarySidebarTab(this.page)
|
|
}
|
|
|
|
async toggleTheme() {
|
|
await this.themeToggleButton.click()
|
|
await this.page.evaluate(() => {
|
|
return new Promise((resolve) => {
|
|
window['app'].ui.settings.addEventListener(
|
|
'Comfy.ColorPalette.change',
|
|
resolve,
|
|
{ once: true }
|
|
)
|
|
|
|
setTimeout(resolve, 5000)
|
|
})
|
|
})
|
|
}
|
|
|
|
async getThemeId() {
|
|
return await this.page.evaluate(async () => {
|
|
return await window['app'].ui.settings.getSettingValue(
|
|
'Comfy.ColorPalette'
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
export class ComfyPage {
|
|
public readonly url: string
|
|
// All canvas position operations are based on default view of canvas.
|
|
public readonly canvas: Locator
|
|
public readonly widgetTextBox: Locator
|
|
|
|
// Buttons
|
|
public readonly resetViewButton: Locator
|
|
public readonly queueButton: Locator
|
|
|
|
// Inputs
|
|
public readonly workflowUploadInput: Locator
|
|
|
|
// Components
|
|
public readonly searchBox: ComfyNodeSearchBox
|
|
public readonly menu: ComfyMenu
|
|
|
|
constructor(public readonly page: Page) {
|
|
this.url = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
|
this.canvas = page.locator('#graph-canvas')
|
|
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
|
|
this.resetViewButton = page.getByRole('button', { name: 'Reset View' })
|
|
this.queueButton = page.getByRole('button', { name: 'Queue Prompt' })
|
|
this.workflowUploadInput = page.locator('#comfy-file-input')
|
|
this.searchBox = new ComfyNodeSearchBox(page)
|
|
this.menu = new ComfyMenu(page)
|
|
}
|
|
|
|
async getGraphNodesCount(): Promise<number> {
|
|
return await this.page.evaluate(() => {
|
|
return window['app']?.graph?._nodes?.length || 0
|
|
})
|
|
}
|
|
|
|
async setup() {
|
|
await this.goto()
|
|
// Unify font for consistent screenshots.
|
|
await this.page.addStyleTag({
|
|
url: 'https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap'
|
|
})
|
|
await this.page.addStyleTag({
|
|
url: 'https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap'
|
|
})
|
|
await this.page.addStyleTag({
|
|
content: `
|
|
* {
|
|
font-family: 'Roboto Mono', 'Noto Color Emoji';
|
|
}`
|
|
})
|
|
await this.page.waitForFunction(() => document.fonts.ready)
|
|
await this.page.waitForFunction(
|
|
() => window['app'] !== undefined && window['app'].vueAppReady
|
|
)
|
|
await this.page.evaluate(() => {
|
|
window['app']['canvas'].show_info = false
|
|
})
|
|
await this.nextFrame()
|
|
// Reset view to force re-rendering of canvas. So that info fields like fps
|
|
// become hidden.
|
|
await this.resetView()
|
|
}
|
|
|
|
public assetPath(fileName: string) {
|
|
return `./browser_tests/assets/${fileName}`
|
|
}
|
|
|
|
async setSetting(settingId: string, settingValue: any) {
|
|
return await this.page.evaluate(
|
|
async ({ id, value }) => {
|
|
await window['app'].ui.settings.setSettingValueAsync(id, value)
|
|
},
|
|
{ id: settingId, value: settingValue }
|
|
)
|
|
}
|
|
|
|
async getSetting(settingId: string) {
|
|
return await this.page.evaluate(async (id) => {
|
|
return await window['app'].ui.settings.getSettingValue(id)
|
|
}, settingId)
|
|
}
|
|
|
|
async reload() {
|
|
await this.page.reload({ timeout: 15000 })
|
|
await this.setup()
|
|
}
|
|
|
|
async goto() {
|
|
await this.page.goto(this.url)
|
|
}
|
|
|
|
async nextFrame() {
|
|
await this.page.evaluate(() => {
|
|
return new Promise<number>(requestAnimationFrame)
|
|
})
|
|
}
|
|
|
|
async delay(ms: number) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
}
|
|
|
|
async loadWorkflow(workflowName: string) {
|
|
await this.workflowUploadInput.setInputFiles(
|
|
this.assetPath(`${workflowName}.json`)
|
|
)
|
|
await this.nextFrame()
|
|
}
|
|
|
|
async resetView() {
|
|
if (await this.resetViewButton.isVisible()) {
|
|
await this.resetViewButton.click()
|
|
}
|
|
// Avoid "Reset View" button highlight.
|
|
await this.page.mouse.move(10, 10)
|
|
await this.nextFrame()
|
|
}
|
|
|
|
async clickTextEncodeNode1() {
|
|
await this.canvas.click({
|
|
position: {
|
|
x: 618,
|
|
y: 191
|
|
}
|
|
})
|
|
await this.nextFrame()
|
|
}
|
|
|
|
async clickTextEncodeNodeToggler() {
|
|
await this.canvas.click({
|
|
position: {
|
|
x: 430,
|
|
y: 171
|
|
}
|
|
})
|
|
await this.nextFrame()
|
|
}
|
|
|
|
async clickTextEncodeNode2() {
|
|
await this.canvas.click({
|
|
position: {
|
|
x: 622,
|
|
y: 400
|
|
}
|
|
})
|
|
await this.nextFrame()
|
|
}
|
|
|
|
async clickEmptySpace() {
|
|
await this.canvas.click({
|
|
position: {
|
|
x: 35,
|
|
y: 31
|
|
}
|
|
})
|
|
await this.nextFrame()
|
|
}
|
|
|
|
async dragAndDrop(source: Position, target: Position) {
|
|
await this.page.mouse.move(source.x, source.y)
|
|
await this.page.mouse.down()
|
|
await this.page.mouse.move(target.x, target.y)
|
|
await this.page.mouse.up()
|
|
await this.nextFrame()
|
|
}
|
|
|
|
async dragAndDropFile(fileName: string) {
|
|
const filePath = this.assetPath(fileName)
|
|
|
|
// Read the file content
|
|
const buffer = fs.readFileSync(filePath)
|
|
|
|
// Get file type
|
|
const getFileType = (fileName: string) => {
|
|
if (fileName.endsWith('.png')) return 'image/png'
|
|
if (fileName.endsWith('.webp')) return 'image/webp'
|
|
if (fileName.endsWith('.json')) return 'application/json'
|
|
return 'application/octet-stream'
|
|
}
|
|
|
|
const fileType = getFileType(fileName)
|
|
|
|
await this.page.evaluate(
|
|
async ({ buffer, fileName, fileType }) => {
|
|
const file = new File([new Uint8Array(buffer)], fileName, {
|
|
type: fileType
|
|
})
|
|
const dataTransfer = new DataTransfer()
|
|
dataTransfer.items.add(file)
|
|
|
|
const dropEvent = new DragEvent('drop', {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
dataTransfer
|
|
})
|
|
|
|
Object.defineProperty(dropEvent, 'preventDefault', {
|
|
value: () => {},
|
|
writable: false
|
|
})
|
|
|
|
Object.defineProperty(dropEvent, 'stopPropagation', {
|
|
value: () => {},
|
|
writable: false
|
|
})
|
|
|
|
document.dispatchEvent(dropEvent)
|
|
},
|
|
{ buffer: [...new Uint8Array(buffer)], fileName, fileType }
|
|
)
|
|
|
|
await this.nextFrame()
|
|
}
|
|
|
|
async dragNode2() {
|
|
await this.dragAndDrop({ x: 622, y: 400 }, { x: 622, y: 300 })
|
|
await this.nextFrame()
|
|
}
|
|
|
|
async disconnectEdge() {
|
|
// CLIP input anchor
|
|
await this.page.mouse.move(427, 198)
|
|
await this.page.mouse.down()
|
|
await this.page.mouse.move(427, 98)
|
|
await this.page.mouse.up()
|
|
// Move out the way to avoid highlight of menu item.
|
|
await this.page.mouse.move(10, 10)
|
|
await this.nextFrame()
|
|
}
|
|
|
|
async connectEdge() {
|
|
// CLIP output anchor on Load Checkpoint Node.
|
|
await this.page.mouse.move(332, 509)
|
|
await this.page.mouse.down()
|
|
// CLIP input anchor on CLIP Text Encode Node.
|
|
await this.page.mouse.move(427, 198)
|
|
await this.page.mouse.up()
|
|
await this.nextFrame()
|
|
}
|
|
|
|
async adjustWidgetValue() {
|
|
// Adjust Empty Latent Image's width input.
|
|
const page = this.page
|
|
await page.locator('#graph-canvas').click({
|
|
position: {
|
|
x: 724,
|
|
y: 645
|
|
}
|
|
})
|
|
await page.locator('input[type="text"]').click()
|
|
await page.locator('input[type="text"]').fill('128')
|
|
await page.locator('input[type="text"]').press('Enter')
|
|
await this.nextFrame()
|
|
}
|
|
|
|
async zoom(deltaY: number, steps: number = 1) {
|
|
await this.page.mouse.move(10, 10)
|
|
for (let i = 0; i < steps; i++) {
|
|
await this.page.mouse.wheel(0, deltaY)
|
|
}
|
|
await this.nextFrame()
|
|
}
|
|
|
|
async pan(offset: Position, safeSpot?: Position) {
|
|
safeSpot = safeSpot || { x: 10, y: 10 }
|
|
await this.page.mouse.move(safeSpot.x, safeSpot.y)
|
|
await this.page.mouse.down()
|
|
// TEMPORARY HACK: Multiple pans open the search menu, so cheat and keep it closed.
|
|
// TODO: Fix that (double-click at not-the-same-coordinations should not open the menu)
|
|
await this.page.keyboard.press('Escape')
|
|
await this.page.mouse.move(offset.x + safeSpot.x, offset.y + safeSpot.y)
|
|
await this.page.mouse.up()
|
|
await this.nextFrame()
|
|
}
|
|
|
|
async rightClickCanvas() {
|
|
await this.page.mouse.click(10, 10, { button: 'right' })
|
|
await this.nextFrame()
|
|
}
|
|
|
|
async doubleClickCanvas() {
|
|
await this.page.mouse.dblclick(10, 10)
|
|
await this.nextFrame()
|
|
}
|
|
|
|
async clickEmptyLatentNode() {
|
|
await this.canvas.click({
|
|
position: {
|
|
x: 724,
|
|
y: 625
|
|
}
|
|
})
|
|
this.page.mouse.move(10, 10)
|
|
await this.nextFrame()
|
|
}
|
|
|
|
async rightClickEmptyLatentNode() {
|
|
await this.canvas.click({
|
|
position: {
|
|
x: 724,
|
|
y: 645
|
|
},
|
|
button: 'right'
|
|
})
|
|
this.page.mouse.move(10, 10)
|
|
await this.nextFrame()
|
|
}
|
|
|
|
async select2Nodes() {
|
|
// Select 2 CLIP nodes.
|
|
await this.page.keyboard.down('Control')
|
|
await this.clickTextEncodeNode1()
|
|
await this.clickTextEncodeNode2()
|
|
await this.page.keyboard.up('Control')
|
|
await this.nextFrame()
|
|
}
|
|
|
|
async ctrlC() {
|
|
await this.page.keyboard.down('Control')
|
|
await this.page.keyboard.press('KeyC')
|
|
await this.page.keyboard.up('Control')
|
|
await this.nextFrame()
|
|
}
|
|
|
|
async ctrlV() {
|
|
await this.page.keyboard.down('Control')
|
|
await this.page.keyboard.press('KeyV')
|
|
await this.page.keyboard.up('Control')
|
|
await this.nextFrame()
|
|
}
|
|
|
|
async ctrlZ() {
|
|
await this.page.keyboard.down('Control')
|
|
await this.page.keyboard.press('KeyZ')
|
|
await this.page.keyboard.up('Control')
|
|
await this.nextFrame()
|
|
}
|
|
|
|
async ctrlY() {
|
|
await this.page.keyboard.down('Control')
|
|
await this.page.keyboard.press('KeyY')
|
|
await this.page.keyboard.up('Control')
|
|
await this.nextFrame()
|
|
}
|
|
|
|
async ctrlArrowUp() {
|
|
await this.page.keyboard.down('Control')
|
|
await this.page.keyboard.press('ArrowUp')
|
|
await this.page.keyboard.up('Control')
|
|
await this.nextFrame()
|
|
}
|
|
|
|
async ctrlArrowDown() {
|
|
await this.page.keyboard.down('Control')
|
|
await this.page.keyboard.press('ArrowDown')
|
|
await this.page.keyboard.up('Control')
|
|
await this.nextFrame()
|
|
}
|
|
|
|
async closeMenu() {
|
|
await this.page.click('button.comfy-close-menu-btn')
|
|
await this.nextFrame()
|
|
}
|
|
|
|
async resizeNode(
|
|
nodePos: Position,
|
|
nodeSize: Size,
|
|
ratioX: number,
|
|
ratioY: number,
|
|
revertAfter: boolean = false
|
|
) {
|
|
const bottomRight = {
|
|
x: nodePos.x + nodeSize.width,
|
|
y: nodePos.y + nodeSize.height
|
|
}
|
|
const target = {
|
|
x: nodePos.x + nodeSize.width * ratioX,
|
|
y: nodePos.y + nodeSize.height * ratioY
|
|
}
|
|
await this.dragAndDrop(bottomRight, target)
|
|
await this.nextFrame()
|
|
if (revertAfter) {
|
|
await this.dragAndDrop(target, bottomRight)
|
|
await this.nextFrame()
|
|
}
|
|
}
|
|
|
|
async resizeKsamplerNode(
|
|
percentX: number,
|
|
percentY: number,
|
|
revertAfter: boolean = false
|
|
) {
|
|
const ksamplerPos = {
|
|
x: 864,
|
|
y: 157
|
|
}
|
|
const ksamplerSize = {
|
|
width: 315,
|
|
height: 292
|
|
}
|
|
this.resizeNode(ksamplerPos, ksamplerSize, percentX, percentY, revertAfter)
|
|
}
|
|
|
|
async resizeLoadCheckpointNode(
|
|
percentX: number,
|
|
percentY: number,
|
|
revertAfter: boolean = false
|
|
) {
|
|
const loadCheckpointPos = {
|
|
x: 25,
|
|
y: 440
|
|
}
|
|
const loadCheckpointSize = {
|
|
width: 320,
|
|
height: 120
|
|
}
|
|
this.resizeNode(
|
|
loadCheckpointPos,
|
|
loadCheckpointSize,
|
|
percentX,
|
|
percentY,
|
|
revertAfter
|
|
)
|
|
}
|
|
|
|
async resizeEmptyLatentNode(
|
|
percentX: number,
|
|
percentY: number,
|
|
revertAfter: boolean = false
|
|
) {
|
|
const emptyLatentPos = {
|
|
x: 475,
|
|
y: 580
|
|
}
|
|
const emptyLatentSize = {
|
|
width: 303,
|
|
height: 132
|
|
}
|
|
this.resizeNode(
|
|
emptyLatentPos,
|
|
emptyLatentSize,
|
|
percentX,
|
|
percentY,
|
|
revertAfter
|
|
)
|
|
}
|
|
}
|
|
|
|
export const comfyPageFixture = base.extend<{ comfyPage: ComfyPage }>({
|
|
comfyPage: async ({ page }, use) => {
|
|
const comfyPage = new ComfyPage(page)
|
|
await comfyPage.setup()
|
|
await use(comfyPage)
|
|
}
|
|
})
|