mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 18:52:19 +00:00
Compare commits
7 Commits
feat/story
...
test/curve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd6eb57199 | ||
|
|
17d2870ef4 | ||
|
|
7a68943839 | ||
|
|
8912f4159a | ||
|
|
794b986954 | ||
|
|
a7b3515692 | ||
|
|
26f3f11a3e |
@@ -69,6 +69,9 @@
|
||||
/src/renderer/extensions/vueNodes/widgets/composables/usePainterWidget.ts @jtydhr88
|
||||
/src/lib/litegraph/src/widgets/PainterWidget.ts @jtydhr88
|
||||
|
||||
# GLSL
|
||||
/src/renderer/glsl/ @jtydhr88 @pythongosssss @christian-byrne
|
||||
|
||||
# 3D
|
||||
/src/extensions/core/load3d.ts @jtydhr88
|
||||
/src/extensions/core/load3dLazy.ts @jtydhr88
|
||||
|
||||
27
browser_tests/assets/vueNodes/widgets/curve_widget.json
Normal file
27
browser_tests/assets/vueNodes/widgets/curve_widget.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [100, 100],
|
||||
"size": [450, 450],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"title": "Curve",
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [""]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -19,7 +19,9 @@ import { QueuePanel } from '@e2e/fixtures/components/QueuePanel'
|
||||
import { SettingDialog } from '@e2e/fixtures/components/SettingDialog'
|
||||
import {
|
||||
AssetsSidebarTab,
|
||||
ModelLibrarySidebarTab,
|
||||
NodeLibrarySidebarTab,
|
||||
NodeLibrarySidebarTabV2,
|
||||
WorkflowsSidebarTab
|
||||
} from '@e2e/fixtures/components/SidebarTab'
|
||||
import { Topbar } from '@e2e/fixtures/components/Topbar'
|
||||
@@ -31,12 +33,14 @@ import { CommandHelper } from '@e2e/fixtures/helpers/CommandHelper'
|
||||
import { DragDropHelper } from '@e2e/fixtures/helpers/DragDropHelper'
|
||||
import { FeatureFlagHelper } from '@e2e/fixtures/helpers/FeatureFlagHelper'
|
||||
import { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper'
|
||||
import { ModelLibraryHelper } from '@e2e/fixtures/helpers/ModelLibraryHelper'
|
||||
import { NodeOperationsHelper } from '@e2e/fixtures/helpers/NodeOperationsHelper'
|
||||
import { PerformanceHelper } from '@e2e/fixtures/helpers/PerformanceHelper'
|
||||
import { SettingsHelper } from '@e2e/fixtures/helpers/SettingsHelper'
|
||||
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
|
||||
import { ToastHelper } from '@e2e/fixtures/helpers/ToastHelper'
|
||||
import { WorkflowHelper } from '@e2e/fixtures/helpers/WorkflowHelper'
|
||||
import { CurveWidgetHelper } from './helpers/CurveWidgetHelper'
|
||||
import type { WorkspaceStore } from '../types/globals'
|
||||
|
||||
dotenvConfig()
|
||||
@@ -55,7 +59,9 @@ class ComfyPropertiesPanel {
|
||||
|
||||
class ComfyMenu {
|
||||
private _assetsTab: AssetsSidebarTab | null = null
|
||||
private _modelLibraryTab: ModelLibrarySidebarTab | null = null
|
||||
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
|
||||
private _nodeLibraryTabV2: NodeLibrarySidebarTabV2 | null = null
|
||||
private _workflowsTab: WorkflowsSidebarTab | null = null
|
||||
private _topbar: Topbar | null = null
|
||||
|
||||
@@ -73,11 +79,21 @@ class ComfyMenu {
|
||||
return this.sideToolbar.locator('.side-bar-button')
|
||||
}
|
||||
|
||||
get modelLibraryTab() {
|
||||
this._modelLibraryTab ??= new ModelLibrarySidebarTab(this.page)
|
||||
return this._modelLibraryTab
|
||||
}
|
||||
|
||||
get nodeLibraryTab() {
|
||||
this._nodeLibraryTab ??= new NodeLibrarySidebarTab(this.page)
|
||||
return this._nodeLibraryTab
|
||||
}
|
||||
|
||||
get nodeLibraryTabV2() {
|
||||
this._nodeLibraryTabV2 ??= new NodeLibrarySidebarTabV2(this.page)
|
||||
return this._nodeLibraryTabV2
|
||||
}
|
||||
|
||||
get assetsTab() {
|
||||
this._assetsTab ??= new AssetsSidebarTab(this.page)
|
||||
return this._assetsTab
|
||||
@@ -199,6 +215,7 @@ export class ComfyPage {
|
||||
public readonly queuePanel: QueuePanel
|
||||
public readonly perf: PerformanceHelper
|
||||
public readonly assets: AssetsHelper
|
||||
public readonly modelLibrary: ModelLibraryHelper
|
||||
|
||||
/** Worker index to test user ID */
|
||||
public readonly userIds: string[] = []
|
||||
@@ -246,27 +263,37 @@ export class ComfyPage {
|
||||
this.queuePanel = new QueuePanel(page)
|
||||
this.perf = new PerformanceHelper(page)
|
||||
this.assets = new AssetsHelper(page)
|
||||
this.modelLibrary = new ModelLibraryHelper(page)
|
||||
}
|
||||
|
||||
get visibleToasts() {
|
||||
return this.toast.visibleToasts
|
||||
}
|
||||
|
||||
async setupUser(username: string) {
|
||||
async setupUser(username: string): Promise<string> {
|
||||
const res = await this.request.get(`${this.url}/api/users`)
|
||||
if (res.status() !== 200)
|
||||
throw new Error(`Failed to retrieve users: ${await res.text()}`)
|
||||
|
||||
const apiRes = await res.json()
|
||||
const user = Object.entries(apiRes?.users ?? {}).find(
|
||||
([, name]) => name === username
|
||||
)
|
||||
const users = apiRes?.users ?? {}
|
||||
const user = Object.entries(users).find(([, name]) => name === username)
|
||||
const id = user?.[0]
|
||||
|
||||
return id ? id : await this.createUser(username)
|
||||
if (id) return id
|
||||
|
||||
try {
|
||||
return await this.createUser(username)
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.includes('Duplicate username')) {
|
||||
const fallbackName = `${username}-${Math.floor(Math.random() * 9999)}`
|
||||
return await this.createUser(fallbackName)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async createUser(username: string) {
|
||||
async createUser(username: string): Promise<string> {
|
||||
const resp = await this.request.post(`${this.url}/api/users`, {
|
||||
data: { username }
|
||||
})
|
||||
@@ -421,6 +448,7 @@ export const testComfySnapToGridGridSize = 50
|
||||
export const comfyPageFixture = base.extend<{
|
||||
comfyPage: ComfyPage
|
||||
comfyMouse: ComfyMouse
|
||||
curveWidget: (nodeTitleOrId: string | number) => CurveWidgetHelper
|
||||
}>({
|
||||
comfyPage: async ({ page, request }, use, testInfo) => {
|
||||
const comfyPage = new ComfyPage(page, request)
|
||||
@@ -433,25 +461,17 @@ export const comfyPageFixture = base.extend<{
|
||||
try {
|
||||
await comfyPage.setupSettings({
|
||||
'Comfy.UseNewMenu': 'Top',
|
||||
// Hide canvas menu/info/selection toolbox by default.
|
||||
'Comfy.Graph.CanvasInfo': false,
|
||||
'Comfy.Graph.CanvasMenu': false,
|
||||
'Comfy.Canvas.SelectionToolbox': false,
|
||||
// Hide all badges by default.
|
||||
'Comfy.NodeBadge.NodeIdBadgeMode': NodeBadgeMode.None,
|
||||
'Comfy.NodeBadge.NodeSourceBadgeMode': NodeBadgeMode.None,
|
||||
// Disable tooltips by default to avoid flakiness.
|
||||
'Comfy.EnableTooltips': false,
|
||||
'Comfy.userId': userId,
|
||||
// Set tutorial completed to true to avoid loading the tutorial workflow.
|
||||
'Comfy.TutorialCompleted': true,
|
||||
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize,
|
||||
'Comfy.VueNodes.AutoScaleLayout': false,
|
||||
// Disable toast warning about version compatibility, as they may or
|
||||
// may not appear - depending on upstream ComfyUI dependencies
|
||||
'Comfy.VersionCompatibility.DisableWarnings': true,
|
||||
// Disable errors tab to prevent missing model detection from
|
||||
// rendering error indicators on nodes during unrelated tests.
|
||||
'Comfy.RightSidePanel.ShowErrorsTab': false
|
||||
})
|
||||
} catch (e) {
|
||||
@@ -471,6 +491,15 @@ export const comfyPageFixture = base.extend<{
|
||||
comfyMouse: async ({ comfyPage }, use) => {
|
||||
const comfyMouse = new ComfyMouse(comfyPage)
|
||||
await use(comfyMouse)
|
||||
},
|
||||
curveWidget: async ({ comfyPage, page }, use) => {
|
||||
await use((nodeTitleOrId: string | number) => {
|
||||
const node = comfyPage.vueNodes.getNodeByTitle(nodeTitleOrId as string)
|
||||
const svg = node.locator('svg').filter({
|
||||
has: page.locator('path[data-testid="curve-path"]')
|
||||
})
|
||||
return new CurveWidgetHelper(page, svg)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -100,6 +100,59 @@ export class NodeLibrarySidebarTab extends SidebarTab {
|
||||
}
|
||||
}
|
||||
|
||||
export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'node-library')
|
||||
}
|
||||
|
||||
get searchInput() {
|
||||
return this.page.getByPlaceholder('Search...')
|
||||
}
|
||||
|
||||
get sidebarContent() {
|
||||
return this.page.locator('.sidebar-content-container')
|
||||
}
|
||||
|
||||
getTab(name: string) {
|
||||
return this.sidebarContent.getByRole('tab', { name, exact: true })
|
||||
}
|
||||
|
||||
get allTab() {
|
||||
return this.getTab('All')
|
||||
}
|
||||
|
||||
get blueprintsTab() {
|
||||
return this.getTab('Blueprints')
|
||||
}
|
||||
|
||||
get sortButton() {
|
||||
return this.sidebarContent.getByRole('button', { name: 'Sort' })
|
||||
}
|
||||
|
||||
getFolder(folderName: string) {
|
||||
return this.sidebarContent
|
||||
.getByRole('treeitem', { name: folderName })
|
||||
.first()
|
||||
}
|
||||
|
||||
getNode(nodeName: string) {
|
||||
return this.sidebarContent.getByRole('treeitem', { name: nodeName }).first()
|
||||
}
|
||||
|
||||
async expandFolder(folderName: string) {
|
||||
const folder = this.getFolder(folderName)
|
||||
const isExpanded = await folder.getAttribute('aria-expanded')
|
||||
if (isExpanded !== 'true') {
|
||||
await folder.click()
|
||||
}
|
||||
}
|
||||
|
||||
override async open() {
|
||||
await super.open()
|
||||
await this.searchInput.waitFor({ state: 'visible' })
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkflowsSidebarTab extends SidebarTab {
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'workflows')
|
||||
@@ -170,6 +223,59 @@ export class WorkflowsSidebarTab extends SidebarTab {
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelLibrarySidebarTab extends SidebarTab {
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'model-library')
|
||||
}
|
||||
|
||||
get searchInput() {
|
||||
return this.page.getByPlaceholder('Search Models...')
|
||||
}
|
||||
|
||||
get modelTree() {
|
||||
return this.page.locator('.model-lib-tree-explorer')
|
||||
}
|
||||
|
||||
get refreshButton() {
|
||||
return this.page.getByRole('button', { name: 'Refresh' })
|
||||
}
|
||||
|
||||
get loadAllFoldersButton() {
|
||||
return this.page.getByRole('button', { name: 'Load All Folders' })
|
||||
}
|
||||
|
||||
get folderNodes() {
|
||||
return this.modelTree.locator('.p-tree-node:not(.p-tree-node-leaf)')
|
||||
}
|
||||
|
||||
get leafNodes() {
|
||||
return this.modelTree.locator('.p-tree-node-leaf')
|
||||
}
|
||||
|
||||
get modelPreview() {
|
||||
return this.page.locator('.model-lib-model-preview')
|
||||
}
|
||||
|
||||
override async open() {
|
||||
await super.open()
|
||||
await this.modelTree.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
getFolderByLabel(label: string) {
|
||||
return this.modelTree
|
||||
.locator('.p-tree-node:not(.p-tree-node-leaf)')
|
||||
.filter({ hasText: label })
|
||||
.first()
|
||||
}
|
||||
|
||||
getLeafByLabel(label: string) {
|
||||
return this.modelTree
|
||||
.locator('.p-tree-node-leaf')
|
||||
.filter({ hasText: label })
|
||||
.first()
|
||||
}
|
||||
}
|
||||
|
||||
export class AssetsSidebarTab extends SidebarTab {
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'assets')
|
||||
|
||||
@@ -50,15 +50,30 @@ export class Topbar {
|
||||
return classes ? !classes.includes('invisible') : false
|
||||
}
|
||||
|
||||
get newWorkflowButton(): Locator {
|
||||
return this.page.locator('.new-blank-workflow-button')
|
||||
}
|
||||
|
||||
getWorkflowTab(tabName: string): Locator {
|
||||
return this.page
|
||||
.locator(`.workflow-tabs .workflow-label:has-text("${tabName}")`)
|
||||
.locator('..')
|
||||
}
|
||||
|
||||
getTab(index: number): Locator {
|
||||
return this.page.locator('.workflow-tabs .p-togglebutton').nth(index)
|
||||
}
|
||||
|
||||
getActiveTab(): Locator {
|
||||
return this.page.locator(
|
||||
'.workflow-tabs .p-togglebutton.p-togglebutton-checked'
|
||||
)
|
||||
}
|
||||
|
||||
async closeWorkflowTab(tabName: string) {
|
||||
const tab = this.getWorkflowTab(tabName)
|
||||
await tab.getByRole('button', { name: 'Close' }).click({ force: true })
|
||||
await tab.hover()
|
||||
await tab.locator('.close-button').click({ force: true })
|
||||
}
|
||||
|
||||
getSaveDialog(): Locator {
|
||||
|
||||
74
browser_tests/fixtures/helpers/CurveWidgetHelper.ts
Normal file
74
browser_tests/fixtures/helpers/CurveWidgetHelper.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class CurveWidgetHelper {
|
||||
constructor(
|
||||
public readonly page: Page,
|
||||
public readonly svgLocator: Locator
|
||||
) {}
|
||||
|
||||
async clickAt(curveX: number, curveY: number): Promise<void> {
|
||||
const box = await this.svgLocator.boundingBox()
|
||||
if (!box) throw new Error('SVG not found')
|
||||
const viewBoxExtent = 1.08
|
||||
const padFraction = 0.04 / viewBoxExtent
|
||||
const usableSize = box.width / viewBoxExtent
|
||||
const screenX = box.x + box.width * padFraction + curveX * usableSize
|
||||
const screenY = box.y + box.height * padFraction + (1 - curveY) * usableSize
|
||||
await this.page.mouse.click(screenX, screenY)
|
||||
}
|
||||
|
||||
async dragPoint(
|
||||
pointIndex: number,
|
||||
toCurveX: number,
|
||||
toCurveY: number
|
||||
): Promise<void> {
|
||||
const circle = this.svgLocator.locator('circle').nth(pointIndex)
|
||||
const circleBox = await circle.boundingBox()
|
||||
if (!circleBox) throw new Error('Circle not found')
|
||||
const fromX = circleBox.x + circleBox.width / 2
|
||||
const fromY = circleBox.y + circleBox.height / 2
|
||||
|
||||
const svgBox = await this.svgLocator.boundingBox()
|
||||
if (!svgBox) throw new Error('SVG not found')
|
||||
const viewBoxExtent = 1.08
|
||||
const padFraction = 0.04 / viewBoxExtent
|
||||
const usableSize = svgBox.width / viewBoxExtent
|
||||
const toScreenX =
|
||||
svgBox.x + svgBox.width * padFraction + toCurveX * usableSize
|
||||
const toScreenY =
|
||||
svgBox.y + svgBox.height * padFraction + (1 - toCurveY) * usableSize
|
||||
|
||||
await this.page.mouse.move(fromX, fromY)
|
||||
await this.page.mouse.down()
|
||||
const steps = 10
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
await this.page.mouse.move(
|
||||
fromX + ((toScreenX - fromX) * i) / steps,
|
||||
fromY + ((toScreenY - fromY) * i) / steps
|
||||
)
|
||||
}
|
||||
await this.page.mouse.up()
|
||||
}
|
||||
|
||||
async rightClickPoint(pointIndex: number): Promise<void> {
|
||||
const circle = this.svgLocator.locator('circle').nth(pointIndex)
|
||||
await circle.dispatchEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 2
|
||||
})
|
||||
}
|
||||
|
||||
async getCurveData(): Promise<
|
||||
{ points: [number, number][]; interpolation: string } | undefined
|
||||
> {
|
||||
return this.page.evaluate(() => {
|
||||
const node = window.app!.graph.nodes.find((n) =>
|
||||
n.widgets?.some((w) => w.type === 'CURVE')
|
||||
)
|
||||
return node?.widgets?.find((w) => w.type === 'CURVE')?.value as
|
||||
| { points: [number, number][]; interpolation: string }
|
||||
| undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
134
browser_tests/fixtures/helpers/ModelLibraryHelper.ts
Normal file
134
browser_tests/fixtures/helpers/ModelLibraryHelper.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
ModelFile,
|
||||
ModelFolderInfo
|
||||
} from '../../../src/platform/assets/schemas/assetSchema'
|
||||
|
||||
const modelFoldersRoutePattern = /\/api\/experiment\/models$/
|
||||
const modelFilesRoutePattern = /\/api\/experiment\/models\/([^?]+)/
|
||||
const viewMetadataRoutePattern = /\/api\/view_metadata\/([^?]+)/
|
||||
|
||||
export interface MockModelMetadata {
|
||||
'modelspec.title'?: string
|
||||
'modelspec.author'?: string
|
||||
'modelspec.architecture'?: string
|
||||
'modelspec.description'?: string
|
||||
'modelspec.resolution'?: string
|
||||
'modelspec.tags'?: string
|
||||
}
|
||||
|
||||
export function createMockModelFolders(names: string[]): ModelFolderInfo[] {
|
||||
return names.map((name) => ({ name, folders: [] }))
|
||||
}
|
||||
|
||||
export function createMockModelFiles(
|
||||
filenames: string[],
|
||||
pathIndex = 0
|
||||
): ModelFile[] {
|
||||
return filenames.map((name) => ({ name, pathIndex }))
|
||||
}
|
||||
|
||||
export class ModelLibraryHelper {
|
||||
private foldersRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private filesRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private metadataRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private folders: ModelFolderInfo[] = []
|
||||
private filesByFolder: Record<string, ModelFile[]> = {}
|
||||
private metadataByModel: Record<string, MockModelMetadata> = {}
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockModelFolders(folders: ModelFolderInfo[]): Promise<void> {
|
||||
this.folders = [...folders]
|
||||
|
||||
if (this.foldersRouteHandler) return
|
||||
|
||||
this.foldersRouteHandler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(this.folders)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(modelFoldersRoutePattern, this.foldersRouteHandler)
|
||||
}
|
||||
|
||||
async mockModelFiles(folder: string, files: ModelFile[]): Promise<void> {
|
||||
this.filesByFolder[folder] = [...files]
|
||||
|
||||
if (this.filesRouteHandler) return
|
||||
|
||||
this.filesRouteHandler = async (route: Route) => {
|
||||
const match = route.request().url().match(modelFilesRoutePattern)
|
||||
const folderName = match?.[1] ? decodeURIComponent(match[1]) : undefined
|
||||
const files = folderName ? (this.filesByFolder[folderName] ?? []) : []
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(files)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(modelFilesRoutePattern, this.filesRouteHandler)
|
||||
}
|
||||
|
||||
async mockMetadata(
|
||||
entries: Record<string, MockModelMetadata>
|
||||
): Promise<void> {
|
||||
Object.assign(this.metadataByModel, entries)
|
||||
|
||||
if (this.metadataRouteHandler) return
|
||||
|
||||
this.metadataRouteHandler = async (route: Route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const filename = url.searchParams.get('filename') ?? ''
|
||||
const metadata = this.metadataByModel[filename]
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(metadata ?? {})
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(viewMetadataRoutePattern, this.metadataRouteHandler)
|
||||
}
|
||||
|
||||
async mockFoldersWithFiles(config: Record<string, string[]>): Promise<void> {
|
||||
const folderNames = Object.keys(config)
|
||||
await this.mockModelFolders(createMockModelFolders(folderNames))
|
||||
for (const [folder, files] of Object.entries(config)) {
|
||||
await this.mockModelFiles(folder, createMockModelFiles(files))
|
||||
}
|
||||
}
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
this.folders = []
|
||||
this.filesByFolder = {}
|
||||
this.metadataByModel = {}
|
||||
|
||||
if (this.foldersRouteHandler) {
|
||||
await this.page.unroute(
|
||||
modelFoldersRoutePattern,
|
||||
this.foldersRouteHandler
|
||||
)
|
||||
this.foldersRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.filesRouteHandler) {
|
||||
await this.page.unroute(modelFilesRoutePattern, this.filesRouteHandler)
|
||||
this.filesRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.metadataRouteHandler) {
|
||||
await this.page.unroute(
|
||||
viewMetadataRoutePattern,
|
||||
this.metadataRouteHandler
|
||||
)
|
||||
this.metadataRouteHandler = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,10 @@ export const TestIds = {
|
||||
widgetActionsMenuButton: 'widget-actions-menu-button'
|
||||
},
|
||||
node: {
|
||||
titleInput: 'node-title-input'
|
||||
titleInput: 'node-title-input',
|
||||
pinIndicator: 'node-pin-indicator',
|
||||
innerWrapper: 'node-inner-wrapper',
|
||||
mainImage: 'main-image'
|
||||
},
|
||||
selectionToolbox: {
|
||||
colorPickerButton: 'color-picker-button',
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import { TestIds } from '../selectors'
|
||||
|
||||
/** DOM-centric helper for a single Vue-rendered node on the canvas. */
|
||||
export class VueNodeFixture {
|
||||
constructor(private readonly locator: Locator) {}
|
||||
@@ -20,6 +22,10 @@ export class VueNodeFixture {
|
||||
return this.locator.locator('[data-testid^="node-body-"]')
|
||||
}
|
||||
|
||||
get pinIndicator(): Locator {
|
||||
return this.locator.getByTestId(TestIds.node.pinIndicator)
|
||||
}
|
||||
|
||||
get collapseButton(): Locator {
|
||||
return this.locator.locator('[data-testid="node-collapse-button"]')
|
||||
}
|
||||
|
||||
@@ -624,21 +624,30 @@ test.describe('Assets sidebar - pagination', () => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Initially loads a batch of assets with has_more pagination', async ({
|
||||
test('initial load fetches first batch with offset 0', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Create a large set of jobs to trigger pagination
|
||||
const manyJobs = createMockJobs(30)
|
||||
const manyJobs = createMockJobs(250)
|
||||
await comfyPage.assets.mockOutputHistory(manyJobs)
|
||||
await comfyPage.setup()
|
||||
|
||||
// Capture the first history fetch (terminal statuses only).
|
||||
// Queue polling also hits /jobs but with status=in_progress,pending.
|
||||
const firstRequest = comfyPage.page.waitForRequest((req) => {
|
||||
if (!/\/api\/jobs\?/.test(req.url())) return false
|
||||
const url = new URL(req.url())
|
||||
const status = url.searchParams.get('status') ?? ''
|
||||
return status.includes('completed')
|
||||
})
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Should load at least the first batch
|
||||
const count = await tab.assetCards.count()
|
||||
expect(count).toBeGreaterThanOrEqual(1)
|
||||
const req = await firstRequest
|
||||
const url = new URL(req.url())
|
||||
expect(url.searchParams.get('offset')).toBe('0')
|
||||
expect(Number(url.searchParams.get('limit'))).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
244
browser_tests/tests/sidebar/modelLibrary.spec.ts
Normal file
244
browser_tests/tests/sidebar/modelLibrary.spec.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
const MOCK_FOLDERS: Record<string, string[]> = {
|
||||
checkpoints: [
|
||||
'sd_xl_base_1.0.safetensors',
|
||||
'dreamshaper_8.safetensors',
|
||||
'realisticVision_v51.safetensors'
|
||||
],
|
||||
loras: ['detail_tweaker_xl.safetensors', 'add_brightness.safetensors'],
|
||||
vae: ['sdxl_vae.safetensors']
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 1. Tab open/close
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Model library sidebar - tab', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
})
|
||||
|
||||
test('Opens model library tab and shows tree', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.modelTree).toBeVisible()
|
||||
await expect(tab.searchInput).toBeVisible()
|
||||
})
|
||||
|
||||
test('Shows refresh and load all folders buttons', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.refreshButton).toBeVisible()
|
||||
await expect(tab.loadAllFoldersButton).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 2. Folder display
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Model library sidebar - folders', () => {
|
||||
// Mocks are set up before setup(), so app.ts's loadModelFolders()
|
||||
// call during initialization hits the mock and populates the store.
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
})
|
||||
|
||||
test('Displays model folders after opening tab', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.getFolderByLabel('checkpoints')).toBeVisible()
|
||||
await expect(tab.getFolderByLabel('loras')).toBeVisible()
|
||||
await expect(tab.getFolderByLabel('vae')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Expanding a folder loads and shows models', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
// Click the folder to expand it
|
||||
await tab.getFolderByLabel('checkpoints').click()
|
||||
|
||||
// Models should appear as leaf nodes
|
||||
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible()
|
||||
await expect(tab.getLeafByLabel('realisticVision_v51')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Expanding a different folder shows its models', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await tab.getFolderByLabel('loras').click()
|
||||
|
||||
await expect(tab.getLeafByLabel('detail_tweaker_xl')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(tab.getLeafByLabel('add_brightness')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 3. Search
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Model library sidebar - search', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
})
|
||||
|
||||
test('Search filters models by filename', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await tab.searchInput.fill('dreamshaper')
|
||||
|
||||
// Wait for debounce (300ms) + load + render
|
||||
await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Other models should not be visible
|
||||
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Clearing search restores folder view', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await tab.searchInput.fill('dreamshaper')
|
||||
await expect(tab.getLeafByLabel('dreamshaper_8')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Clear the search
|
||||
await tab.searchInput.fill('')
|
||||
|
||||
// Folders should be visible again (collapsed)
|
||||
await expect(tab.getFolderByLabel('checkpoints')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(tab.getFolderByLabel('loras')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Search with no matches shows empty tree', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await tab.searchInput.fill('nonexistent_model_xyz')
|
||||
|
||||
// Wait for debounce, then verify no leaf nodes
|
||||
await expect
|
||||
.poll(async () => await tab.leafNodes.count(), { timeout: 5000 })
|
||||
.toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 4. Refresh and load all
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Model library sidebar - refresh', () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
})
|
||||
|
||||
test('Refresh button reloads folder list', async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles({
|
||||
checkpoints: ['model_a.safetensors']
|
||||
})
|
||||
await comfyPage.setup()
|
||||
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.getFolderByLabel('checkpoints')).toBeVisible()
|
||||
|
||||
// Update mock to include a new folder
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles({
|
||||
checkpoints: ['model_a.safetensors'],
|
||||
loras: ['lora_b.safetensors']
|
||||
})
|
||||
|
||||
// Wait for the refresh request to complete
|
||||
const refreshRequest = comfyPage.page.waitForRequest(
|
||||
(req) => req.url().endsWith('/experiment/models'),
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
await tab.refreshButton.click()
|
||||
await refreshRequest
|
||||
|
||||
await expect(tab.getFolderByLabel('loras')).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Load all folders button triggers loading all model data', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
|
||||
await comfyPage.setup()
|
||||
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
// Wait for a per-folder model files request triggered by load all
|
||||
const folderRequest = comfyPage.page.waitForRequest(
|
||||
(req) =>
|
||||
/\/api\/experiment\/models\/[^/]+$/.test(req.url()) &&
|
||||
req.method() === 'GET',
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
|
||||
await tab.loadAllFoldersButton.click()
|
||||
await folderRequest
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 5. Empty state
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Model library sidebar - empty state', () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
})
|
||||
|
||||
test('Shows empty tree when no model folders exist', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles({})
|
||||
await comfyPage.setup()
|
||||
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.modelTree).toBeVisible()
|
||||
expect(await tab.folderNodes.count()).toBe(0)
|
||||
expect(await tab.leafNodes.count()).toBe(0)
|
||||
})
|
||||
})
|
||||
126
browser_tests/tests/sidebar/nodeLibraryV2.spec.ts
Normal file
126
browser_tests/tests/sidebar/nodeLibraryV2.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Node library sidebar V2', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', true)
|
||||
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
await tab.open()
|
||||
})
|
||||
|
||||
test('Can switch between tabs', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
|
||||
await expect(tab.allTab).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
await tab.blueprintsTab.click()
|
||||
await expect(tab.blueprintsTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.allTab).toHaveAttribute('aria-selected', 'false')
|
||||
|
||||
await tab.allTab.click()
|
||||
await expect(tab.allTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.blueprintsTab).toHaveAttribute('aria-selected', 'false')
|
||||
})
|
||||
|
||||
test('All tab displays node tree with folders', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
|
||||
await expect(tab.allTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.getFolder('sampling')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Can expand folder and see nodes in All tab', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
|
||||
await tab.expandFolder('sampling')
|
||||
await expect(tab.getNode('KSampler (Advanced)')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Search filters nodes in All tab', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
|
||||
await expect(tab.getNode('KSampler (Advanced)')).not.toBeVisible()
|
||||
|
||||
await tab.searchInput.fill('KSampler')
|
||||
await expect(tab.getNode('KSampler (Advanced)')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(tab.getNode('CLIPLoader')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Drag node to canvas adds it', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
|
||||
await tab.expandFolder('sampling')
|
||||
await expect(tab.getNode('KSampler (Advanced)')).toBeVisible()
|
||||
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
const canvasBoundingBox = await comfyPage.page
|
||||
.locator('#graph-canvas')
|
||||
.boundingBox()
|
||||
expect(canvasBoundingBox).not.toBeNull()
|
||||
const targetPosition = {
|
||||
x: canvasBoundingBox!.x + canvasBoundingBox!.width / 2,
|
||||
y: canvasBoundingBox!.y + canvasBoundingBox!.height / 2
|
||||
}
|
||||
|
||||
const nodeLocator = tab.getNode('KSampler (Advanced)')
|
||||
await nodeLocator.dragTo(comfyPage.page.locator('#graph-canvas'), {
|
||||
targetPosition
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 5000 })
|
||||
.toBe(initialCount + 1)
|
||||
})
|
||||
|
||||
test('Right-click node shows context menu with bookmark option', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
|
||||
await tab.expandFolder('sampling')
|
||||
const node = tab.getNode('KSampler (Advanced)')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
await node.click({ button: 'right' })
|
||||
|
||||
const contextMenu = comfyPage.page.getByRole('menuitem', {
|
||||
name: /Bookmark Node/
|
||||
})
|
||||
await expect(contextMenu).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('Search clear restores folder view', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
|
||||
await expect(tab.getFolder('sampling')).toBeVisible()
|
||||
|
||||
await tab.searchInput.fill('KSampler')
|
||||
await expect(tab.getNode('KSampler (Advanced)')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
await tab.searchInput.clear()
|
||||
await tab.searchInput.press('Enter')
|
||||
|
||||
await expect(tab.getFolder('sampling')).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Sort dropdown shows sorting options', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTabV2
|
||||
|
||||
await tab.sortButton.click()
|
||||
|
||||
// Reka UI DropdownMenuRadioItem renders with role="menuitemradio"
|
||||
const options = comfyPage.page.getByRole('menuitemradio')
|
||||
await expect(options.first()).toBeVisible({ timeout: 3000 })
|
||||
await expect
|
||||
.poll(() => options.count(), { timeout: 3000 })
|
||||
.toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
154
browser_tests/tests/topbar/workflowTabs.spec.ts
Normal file
154
browser_tests/tests/topbar/workflowTabs.spec.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Workflow tabs', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Topbar'
|
||||
)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('Default workflow tab is visible on load', async ({ comfyPage }) => {
|
||||
const tabNames = await comfyPage.menu.topbar.getTabNames()
|
||||
expect(tabNames.length).toBe(1)
|
||||
expect(tabNames[0]).toContain('Unsaved Workflow')
|
||||
})
|
||||
|
||||
test('Creating a new workflow adds a tab', async ({ comfyPage }) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
expect(await topbar.getTabNames()).toHaveLength(1)
|
||||
|
||||
await topbar.newWorkflowButton.click()
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
|
||||
const tabNames = await topbar.getTabNames()
|
||||
expect(tabNames[1]).toContain('Unsaved Workflow (2)')
|
||||
})
|
||||
|
||||
test('Switching tabs changes active workflow', async ({ comfyPage }) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
await topbar.newWorkflowButton.click()
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
|
||||
const activeNameBefore = await topbar.getActiveTabName()
|
||||
expect(activeNameBefore).toContain('Unsaved Workflow (2)')
|
||||
|
||||
await topbar.getTab(0).click()
|
||||
await expect
|
||||
.poll(() => topbar.getActiveTabName())
|
||||
.toContain('Unsaved Workflow')
|
||||
|
||||
const activeAfter = await topbar.getActiveTabName()
|
||||
expect(activeAfter).not.toContain('(2)')
|
||||
})
|
||||
|
||||
test('Closing a tab removes it', async ({ comfyPage }) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
await topbar.newWorkflowButton.click()
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
|
||||
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(1)
|
||||
|
||||
const remaining = await topbar.getTabNames()
|
||||
expect(remaining[0]).toContain('Unsaved Workflow')
|
||||
})
|
||||
|
||||
test('Right-clicking a tab shows context menu', async ({ comfyPage }) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
await topbar.getTab(0).click({ button: 'right' })
|
||||
|
||||
// Reka UI ContextMenuContent gets data-state="open" when active
|
||||
const contextMenu = comfyPage.page.locator(
|
||||
'[role="menu"][data-state="open"]'
|
||||
)
|
||||
await expect(contextMenu).toBeVisible({ timeout: 5000 })
|
||||
|
||||
await expect(
|
||||
contextMenu.getByRole('menuitem', { name: /Close Tab/i }).first()
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
contextMenu.getByRole('menuitem', { name: /Save/i }).first()
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Context menu Close Tab action removes the tab', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
await topbar.newWorkflowButton.click()
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
|
||||
await topbar.getTab(1).click({ button: 'right' })
|
||||
const contextMenu = comfyPage.page.locator(
|
||||
'[role="menu"][data-state="open"]'
|
||||
)
|
||||
await expect(contextMenu).toBeVisible({ timeout: 5000 })
|
||||
|
||||
await contextMenu
|
||||
.getByRole('menuitem', { name: /Close Tab/i })
|
||||
.first()
|
||||
.click()
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('Closing the last tab creates a new default workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(1)
|
||||
|
||||
await topbar.closeWorkflowTab('Unsaved Workflow')
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(1)
|
||||
|
||||
const tabNames = await topbar.getTabNames()
|
||||
expect(tabNames[0]).toContain('Unsaved Workflow')
|
||||
})
|
||||
|
||||
test('Modified workflow shows unsaved indicator', async ({ comfyPage }) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
// Modify the graph via litegraph API to trigger unsaved state
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app?.graph
|
||||
const node = window.LiteGraph?.createNode('Note')
|
||||
if (graph && node) graph.add(node)
|
||||
})
|
||||
|
||||
// WorkflowTab renders "•" when the workflow has unsaved changes
|
||||
const activeTab = topbar.getActiveTab()
|
||||
await expect(activeTab.locator('text=•')).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Multiple tabs can be created, switched, and closed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
// Create 2 additional tabs (3 total)
|
||||
await topbar.newWorkflowButton.click()
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
await topbar.newWorkflowButton.click()
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(3)
|
||||
|
||||
// Switch to first tab
|
||||
await topbar.getTab(0).click()
|
||||
await expect
|
||||
.poll(() => topbar.getActiveTabName())
|
||||
.toContain('Unsaved Workflow')
|
||||
|
||||
// Close the middle tab
|
||||
await topbar.closeWorkflowTab('Unsaved Workflow (2)')
|
||||
await expect.poll(() => topbar.getTabNames()).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -5,9 +5,9 @@ import {
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../../../../fixtures/ComfyPage'
|
||||
import { TestIds } from '../../../../fixtures/selectors'
|
||||
|
||||
const BYPASS_CLASS = /before:bg-bypass\/60/
|
||||
const PIN_INDICATOR = '[data-testid="node-pin-indicator"]'
|
||||
|
||||
async function clickExactMenuItem(comfyPage: ComfyPage, name: string) {
|
||||
await comfyPage.page.getByRole('menuitem', { name, exact: true }).click()
|
||||
@@ -15,12 +15,10 @@ async function clickExactMenuItem(comfyPage: ComfyPage, name: string) {
|
||||
}
|
||||
|
||||
async function openContextMenu(comfyPage: ComfyPage, nodeTitle: string) {
|
||||
const header = comfyPage.vueNodes
|
||||
.getNodeByTitle(nodeTitle)
|
||||
.locator('.lg-node-header')
|
||||
await header.click()
|
||||
await header.click({ button: 'right' })
|
||||
const menu = comfyPage.page.locator('.p-contextmenu')
|
||||
const fixture = await comfyPage.vueNodes.getFixtureByTitle(nodeTitle)
|
||||
await fixture.header.click()
|
||||
await fixture.header.click({ button: 'right' })
|
||||
const menu = comfyPage.contextMenu.primeVueMenu
|
||||
await menu.waitFor({ state: 'visible' })
|
||||
return menu
|
||||
}
|
||||
@@ -35,17 +33,13 @@ async function openMultiNodeContextMenu(
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
for (const title of titles) {
|
||||
const header = comfyPage.vueNodes
|
||||
.getNodeByTitle(title)
|
||||
.locator('.lg-node-header')
|
||||
await header.click({ modifiers: ['ControlOrMeta'] })
|
||||
const fixture = await comfyPage.vueNodes.getFixtureByTitle(title)
|
||||
await fixture.header.click({ modifiers: ['ControlOrMeta'] })
|
||||
}
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const firstHeader = comfyPage.vueNodes
|
||||
.getNodeByTitle(titles[0])
|
||||
.locator('.lg-node-header')
|
||||
const box = await firstHeader.boundingBox()
|
||||
const firstFixture = await comfyPage.vueNodes.getFixtureByTitle(titles[0])
|
||||
const box = await firstFixture.header.boundingBox()
|
||||
if (!box) throw new Error(`Header for "${titles[0]}" not found`)
|
||||
await comfyPage.page.mouse.click(
|
||||
box.x + box.width / 2,
|
||||
@@ -53,16 +47,15 @@ async function openMultiNodeContextMenu(
|
||||
{ button: 'right' }
|
||||
)
|
||||
|
||||
const menu = comfyPage.page.locator('.p-contextmenu')
|
||||
const menu = comfyPage.contextMenu.primeVueMenu
|
||||
await menu.waitFor({ state: 'visible' })
|
||||
return menu
|
||||
}
|
||||
|
||||
function getNodeWrapper(comfyPage: ComfyPage, nodeTitle: string): Locator {
|
||||
return comfyPage.page
|
||||
.locator('[data-node-id]')
|
||||
.filter({ hasText: nodeTitle })
|
||||
.getByTestId('node-inner-wrapper')
|
||||
return comfyPage.vueNodes
|
||||
.getNodeByTitle(nodeTitle)
|
||||
.getByTestId(TestIds.node.innerWrapper)
|
||||
}
|
||||
|
||||
async function getNodeRef(comfyPage: ComfyPage, nodeTitle: string) {
|
||||
@@ -82,9 +75,7 @@ test.describe('Vue Node Context Menu', () => {
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
await clickExactMenuItem(comfyPage, 'Rename')
|
||||
|
||||
const titleInput = comfyPage.page.locator(
|
||||
'.node-title-editor input[type="text"]'
|
||||
)
|
||||
const titleInput = comfyPage.page.getByTestId(TestIds.node.titleInput)
|
||||
await titleInput.waitFor({ state: 'visible' })
|
||||
await titleInput.fill('My Renamed Sampler')
|
||||
await titleInput.press('Enter')
|
||||
@@ -135,16 +126,12 @@ test.describe('Vue Node Context Menu', () => {
|
||||
await openContextMenu(comfyPage, nodeTitle)
|
||||
await clickExactMenuItem(comfyPage, 'Pin')
|
||||
|
||||
const pinIndicator = comfyPage.vueNodes
|
||||
.getNodeByTitle(nodeTitle)
|
||||
.locator(PIN_INDICATOR)
|
||||
await expect(pinIndicator).toBeVisible()
|
||||
const fixture = await comfyPage.vueNodes.getFixtureByTitle(nodeTitle)
|
||||
await expect(fixture.pinIndicator).toBeVisible()
|
||||
expect(await nodeRef.isPinned()).toBe(true)
|
||||
|
||||
// Verify drag blocked
|
||||
const header = comfyPage.vueNodes
|
||||
.getNodeByTitle(nodeTitle)
|
||||
.locator('.lg-node-header')
|
||||
const header = fixture.header
|
||||
const posBeforeDrag = await header.boundingBox()
|
||||
if (!posBeforeDrag) throw new Error('Header not found')
|
||||
await comfyPage.canvasOps.dragAndDrop(
|
||||
@@ -158,7 +145,7 @@ test.describe('Vue Node Context Menu', () => {
|
||||
await openContextMenu(comfyPage, nodeTitle)
|
||||
await clickExactMenuItem(comfyPage, 'Unpin')
|
||||
|
||||
await expect(pinIndicator).not.toBeVisible()
|
||||
await expect(fixture.pinIndicator).not.toBeVisible()
|
||||
expect(await nodeRef.isPinned()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -244,7 +231,9 @@ test.describe('Vue Node Context Menu', () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
// Capture the original image src from the node's preview
|
||||
const imagePreview = comfyPage.page.locator('.image-preview img')
|
||||
const imagePreview = comfyPage.vueNodes
|
||||
.getNodeByTitle('Load Image')
|
||||
.getByTestId(TestIds.node.mainImage)
|
||||
const originalSrc = await imagePreview.getAttribute('src')
|
||||
|
||||
// Write a test image into the browser clipboard
|
||||
@@ -347,8 +336,7 @@ test.describe('Vue Node Context Menu', () => {
|
||||
await comfyPage.nodeOps.fillPromptDialog('TestBlueprint')
|
||||
|
||||
// Open node library sidebar and search for the blueprint
|
||||
await comfyPage.page.getByRole('button', { name: 'Node Library' }).click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.menu.nodeLibraryTab.tabButton.click()
|
||||
const searchBox = comfyPage.page.getByRole('combobox', {
|
||||
name: 'Search'
|
||||
})
|
||||
@@ -414,20 +402,16 @@ test.describe('Vue Node Context Menu', () => {
|
||||
await clickExactMenuItem(comfyPage, 'Pin')
|
||||
|
||||
for (const title of nodeTitles) {
|
||||
const pinIndicator = comfyPage.vueNodes
|
||||
.getNodeByTitle(title)
|
||||
.locator(PIN_INDICATOR)
|
||||
await expect(pinIndicator).toBeVisible()
|
||||
const fixture = await comfyPage.vueNodes.getFixtureByTitle(title)
|
||||
await expect(fixture.pinIndicator).toBeVisible()
|
||||
}
|
||||
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Unpin')
|
||||
|
||||
for (const title of nodeTitles) {
|
||||
const pinIndicator = comfyPage.vueNodes
|
||||
.getNodeByTitle(title)
|
||||
.locator(PIN_INDICATOR)
|
||||
await expect(pinIndicator).not.toBeVisible()
|
||||
const fixture = await comfyPage.vueNodes.getFixtureByTitle(title)
|
||||
await expect(fixture.pinIndicator).not.toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
288
browser_tests/tests/vueNodes/widgets/curve/curveWidget.spec.ts
Normal file
288
browser_tests/tests/vueNodes/widgets/curve/curveWidget.spec.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
import type { TestGraphAccess } from '../../../../types/globals'
|
||||
|
||||
test.describe('Curve Widget', { tag: ['@widget', '@smoke'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.workflow.loadWorkflow('vueNodes/widgets/curve_widget')
|
||||
await comfyPage.page.waitForFunction(() => {
|
||||
const g = window.graph as unknown as TestGraphAccess
|
||||
return g?._nodes_by_id?.['1'] !== undefined
|
||||
})
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const g = window.app!.graph as unknown as TestGraphAccess
|
||||
const node = g._nodes_by_id['1']
|
||||
if (!node.widgets?.some((w) => w.type === 'curve')) {
|
||||
node.addWidget(
|
||||
'curve',
|
||||
'tone_curve',
|
||||
{
|
||||
points: [
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
],
|
||||
interpolation: 'monotone_cubic'
|
||||
},
|
||||
() => {}
|
||||
)
|
||||
}
|
||||
})
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await expect(
|
||||
comfyPage.vueNodes
|
||||
.getNodeByTitle('Curve')
|
||||
.locator('svg')
|
||||
.filter({
|
||||
has: comfyPage.page.locator('path[data-testid="curve-path"]')
|
||||
})
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('Rendering', () => {
|
||||
test('renders with default diagonal', async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('Curve')
|
||||
const svg = node.locator('svg').filter({
|
||||
has: comfyPage.page.locator('path[data-testid="curve-path"]')
|
||||
})
|
||||
await expect(svg).toBeVisible()
|
||||
await expect(svg.locator('[data-testid="curve-path"]')).toHaveAttribute(
|
||||
'd',
|
||||
/\S+/
|
||||
)
|
||||
await expect(svg.locator('circle')).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('interpolation selector shows Smooth by default', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('Curve')
|
||||
await expect(node.getByRole('combobox')).toContainText('Smooth')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Adding control points', () => {
|
||||
test('click adds a new control point', async ({ curveWidget }) => {
|
||||
const helper = curveWidget('Curve')
|
||||
await expect(helper.svgLocator.locator('circle')).toHaveCount(2)
|
||||
await helper.clickAt(0.5, 0.8)
|
||||
await expect(helper.svgLocator.locator('circle')).toHaveCount(3)
|
||||
})
|
||||
|
||||
test('multiple clicks add multiple points', async ({ curveWidget }) => {
|
||||
const helper = curveWidget('Curve')
|
||||
await helper.clickAt(0.25, 0.3)
|
||||
await helper.clickAt(0.5, 0.6)
|
||||
await helper.clickAt(0.75, 0.4)
|
||||
await expect(helper.svgLocator.locator('circle')).toHaveCount(5)
|
||||
})
|
||||
|
||||
test('Ctrl+click does not add points', async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('Curve')
|
||||
const svg = node.locator('svg').filter({
|
||||
has: comfyPage.page.locator('path[data-testid="curve-path"]')
|
||||
})
|
||||
const box = await svg.boundingBox()
|
||||
const viewBoxExtent = 1.08
|
||||
const pad = 0.04 / viewBoxExtent
|
||||
const usable = box!.width / viewBoxExtent
|
||||
const x = box!.x + box!.width * pad + 0.5 * usable
|
||||
const y = box!.y + box!.height * pad + 0.5 * usable
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.mouse.click(x, y)
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
await expect(svg.locator('circle')).toHaveCount(2)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Dragging control points', () => {
|
||||
test('dragging a point updates curve', async ({ curveWidget }) => {
|
||||
const helper = curveWidget('Curve')
|
||||
const path = helper.svgLocator.locator('[data-testid="curve-path"]')
|
||||
await helper.clickAt(0.5, 0.5)
|
||||
const d1 = await path.getAttribute('d')
|
||||
await helper.dragPoint(1, 0.5, 0.8)
|
||||
await expect.poll(() => path.getAttribute('d')).not.toBe(d1)
|
||||
})
|
||||
|
||||
test('no points when disabled @widget @smoke', async ({
|
||||
comfyPage,
|
||||
curveWidget
|
||||
}) => {
|
||||
const helper = curveWidget('Curve')
|
||||
await comfyPage.page.evaluate((title) => {
|
||||
interface TestNode {
|
||||
widgets: Array<{
|
||||
options?: {
|
||||
disabled?: boolean
|
||||
}
|
||||
}>
|
||||
}
|
||||
interface TestGraph {
|
||||
findNodesByTitle: (t: string) => TestNode[]
|
||||
}
|
||||
|
||||
const graph = window.graph as unknown as TestGraph
|
||||
const node = graph.findNodesByTitle(title)[0]
|
||||
if (node) {
|
||||
const widget = node.widgets[0]
|
||||
if (widget) {
|
||||
if (!widget.options) widget.options = {}
|
||||
widget.options.disabled = true
|
||||
}
|
||||
}
|
||||
}, 'Curve')
|
||||
await expect(helper.svgLocator.locator('circle')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('drag clamps to [0, 1]', async ({ curveWidget }) => {
|
||||
const helper = curveWidget('Curve')
|
||||
await helper.dragPoint(0, -0.5, 1.5)
|
||||
const data = await helper.getCurveData()
|
||||
expect(data?.points[0][0]).toBeGreaterThanOrEqual(-0.001)
|
||||
expect(data?.points[0][0]).toBeLessThanOrEqual(1.001)
|
||||
expect(data?.points[0][1]).toBeGreaterThanOrEqual(-0.001)
|
||||
expect(data?.points[0][1]).toBeLessThanOrEqual(1.001)
|
||||
})
|
||||
|
||||
test('drag maintains x-order', async ({ curveWidget }) => {
|
||||
const helper = curveWidget('Curve')
|
||||
await helper.clickAt(0.5, 0.5)
|
||||
await helper.dragPoint(1, 0.1, 0.5)
|
||||
await expect(helper.svgLocator.locator('circle')).toHaveCount(3)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Deleting control points', () => {
|
||||
test('right-click deletes point', async ({ curveWidget }) => {
|
||||
const helper = curveWidget('Curve')
|
||||
await helper.clickAt(0.5, 0.5)
|
||||
await expect(helper.svgLocator.locator('circle')).toHaveCount(3)
|
||||
await helper.rightClickPoint(1)
|
||||
await expect(helper.svgLocator.locator('circle')).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('Ctrl+click deletes point', async ({ comfyPage, curveWidget }) => {
|
||||
const helper = curveWidget('Curve')
|
||||
await helper.clickAt(0.5, 0.5)
|
||||
const circle = helper.svgLocator.locator('circle').nth(1)
|
||||
const cbox = await circle.boundingBox()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.mouse.click(
|
||||
cbox!.x + cbox!.width / 2,
|
||||
cbox!.y + cbox!.height / 2
|
||||
)
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
await expect(helper.svgLocator.locator('circle')).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('minimum 2 points limit', async ({ curveWidget }) => {
|
||||
const helper = curveWidget('Curve')
|
||||
await helper.rightClickPoint(0)
|
||||
await expect(helper.svgLocator.locator('circle')).toHaveCount(2)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Interpolation', () => {
|
||||
test('Smooth to Linear changes path', async ({
|
||||
comfyPage,
|
||||
curveWidget
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('Curve')
|
||||
const helper = curveWidget('Curve')
|
||||
const path = helper.svgLocator.locator('[data-testid="curve-path"]')
|
||||
await helper.clickAt(0.5, 0.8)
|
||||
const d1 = await path.getAttribute('d')
|
||||
await node.getByRole('combobox').click()
|
||||
await comfyPage.page.getByRole('option', { name: 'Linear' }).click()
|
||||
await expect.poll(() => path.getAttribute('d')).not.toBe(d1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Histogram', () => {
|
||||
test('absent before execution', async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('Curve')
|
||||
await expect(
|
||||
node.locator('[data-testid="histogram-path"]')
|
||||
).not.toBeAttached()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Disabled state', () => {
|
||||
test('no points when disabled', async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph.nodes.find((n) => n.title === 'Curve')!
|
||||
node.widgets![0].disabled = true
|
||||
})
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('Curve')
|
||||
await expect(node.locator('circle')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Screenshots', { tag: '@screenshot' }, () => {
|
||||
test('default curve matches baseline', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('curve-default.png', {
|
||||
maxDiffPixelRatio: 0.01
|
||||
})
|
||||
})
|
||||
|
||||
test('linear curve matches baseline', async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('Curve')
|
||||
await node.getByRole('combobox').click()
|
||||
await comfyPage.page.getByRole('option', { name: 'Linear' }).click()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('curve-linear.png', {
|
||||
maxDiffPixelRatio: 0.01
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Persistence', { tag: '@workflow' }, () => {
|
||||
test('data persists after save/reload', async ({
|
||||
comfyPage,
|
||||
curveWidget
|
||||
}) => {
|
||||
await curveWidget('Curve').clickAt(0.5, 0.8)
|
||||
await comfyPage.workflow.loadWorkflow('vueNodes/widgets/curve_widget')
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['1']
|
||||
node.addWidget(
|
||||
'curve',
|
||||
'tone_curve',
|
||||
{
|
||||
points: [
|
||||
[0, 0],
|
||||
[0.5, 0.8],
|
||||
[1, 1]
|
||||
],
|
||||
interpolation: 'monotone_cubic'
|
||||
},
|
||||
() => {}
|
||||
)
|
||||
})
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeByTitle('Curve').locator('circle')
|
||||
).toHaveCount(3)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Edge cases', () => {
|
||||
test('20 points', async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph.nodes.find((n) => n.title === 'Curve')!
|
||||
const w = node.widgets!.find((w) => w.type === 'curve')!
|
||||
;(w.value as { points: [number, number][] }).points = Array.from(
|
||||
{ length: 20 },
|
||||
(_, i) => [i / 19, i / 19]
|
||||
)
|
||||
})
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeByTitle('Curve').locator('circle')
|
||||
).toHaveCount(20)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@
|
||||
<EditableText
|
||||
:is-editing="showInput"
|
||||
:model-value="editedTitle"
|
||||
:input-attrs="{ 'data-testid': 'node-title-input' }"
|
||||
@edit="onEdit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user