Compare commits

..

5 Commits

Author SHA1 Message Date
jaeone94
f7b48c193c test: extract waitForDraftPersisted helper and relax timeout
Move draft localStorage polling into WorkflowHelper for reuse.
Use default 5s timeout instead of 3s per review feedback.
2026-04-03 05:40:46 +09:00
jaeone94
38d6a3124e test: use dispatchEvent in packAllInteriorNodes to bypass DOM widget overlay
canvas.click() gets intercepted by DOM widget textarea after the
position fix places nodes at correct locations. dispatchEvent bypasses
the pointer event interception check.
2026-04-03 05:09:24 +09:00
jaeone94
1c429d2d57 test: fix draft reload in subgraph position E2E test
Use page.reload() with explicit draft persistence polling instead of
comfyPage.setup(), which triggered a full navigation that bypassed
the draft auto-load flow.
2026-04-03 04:15:39 +09:00
jaeone94
fa0239a9ca test: add E2E test for subgraph node position preservation after draft reload
Verifies that entering a subgraph, reloading the page (draft auto-loads),
and auto-entering the subgraph via hash navigation does not corrupt
internal node positions.
2026-04-03 03:31:45 +09:00
jaeone94
63a56a8ad1 fix: prevent subgraph node position corruption during graph transitions
ResizeObserver was converting DOM positions back to canvas coordinates
using clientPosToCanvasPos, which depends on the current canvas
scale/offset. During graph transitions (e.g. entering a subgraph from
a draft-loaded workflow), the canvas viewport was stale (still had the
parent graph's zoom level), causing all subgraph node positions to be
permanently corrupted. The corruption accumulated across page refreshes
as drafts saved the corrupted positions.

Use the layout store's existing position (initialized from LiteGraph)
instead of reverse-converting DOM screen coordinates. Only fall back to
getBoundingClientRect conversion for nodes not yet in the layout store.

Fixes subgraph node positions drifting apart or compressing together
when entering a subgraph after draft workflow reload.
2026-04-03 03:24:37 +09:00
18 changed files with 186 additions and 878 deletions

View File

@@ -69,9 +69,6 @@
/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

View File

@@ -19,9 +19,7 @@ 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'
@@ -33,7 +31,6 @@ 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'
@@ -58,9 +55,7 @@ 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
@@ -78,21 +73,11 @@ 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
@@ -214,7 +199,6 @@ 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[] = []
@@ -262,7 +246,6 @@ 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() {

View File

@@ -100,59 +100,6 @@ 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')
@@ -223,59 +170,6 @@ 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')

View File

@@ -50,30 +50,15 @@ 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.hover()
await tab.locator('.close-button').click({ force: true })
await tab.getByRole('button', { name: 'Close' }).click({ force: true })
}
getSaveDialog(): Locator {

View File

@@ -1,134 +0,0 @@
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
}
}
}

View File

@@ -484,7 +484,16 @@ export class SubgraphHelper {
await this.comfyPage.vueNodes.enterSubgraph(hostNodeId)
await this.comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await this.comfyPage.nextFrame()
await this.comfyPage.canvas.click()
await this.comfyPage.canvas.dispatchEvent('pointerdown', {
bubbles: true,
cancelable: true,
button: 0
})
await this.comfyPage.canvas.dispatchEvent('pointerup', {
bubbles: true,
cancelable: true,
button: 0
})
await this.comfyPage.canvas.press('Control+a')
await this.comfyPage.nextFrame()
await this.page.evaluate(() => {
@@ -493,7 +502,16 @@ export class SubgraphHelper {
})
await this.comfyPage.nextFrame()
await this.exitViaBreadcrumb()
await this.comfyPage.canvas.click()
await this.comfyPage.canvas.dispatchEvent('pointerdown', {
bubbles: true,
cancelable: true,
button: 0
})
await this.comfyPage.canvas.dispatchEvent('pointerup', {
bubbles: true,
cancelable: true,
button: 0
})
await this.comfyPage.nextFrame()
}

View File

@@ -58,6 +58,16 @@ export class WorkflowHelper {
await this.comfyPage.nextFrame()
}
async waitForDraftPersisted({ timeout = 5000 } = {}) {
await this.comfyPage.page.waitForFunction(
() =>
Object.keys(localStorage).some((k) =>
k.startsWith('Comfy.Workflow.Draft.v2:')
),
{ timeout }
)
}
async loadWorkflow(workflowName: string) {
await this.comfyPage.workflowUploadInput.setInputFiles(
assetPath(`${workflowName}.json`)

View File

@@ -80,10 +80,7 @@ export const TestIds = {
widgetActionsMenuButton: 'widget-actions-menu-button'
},
node: {
titleInput: 'node-title-input',
pinIndicator: 'node-pin-indicator',
innerWrapper: 'node-inner-wrapper',
mainImage: 'main-image'
titleInput: 'node-title-input'
},
selectionToolbox: {
colorPickerButton: 'color-picker-button',

View File

@@ -1,7 +1,5 @@
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) {}
@@ -22,10 +20,6 @@ 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"]')
}

View File

@@ -624,30 +624,21 @@ test.describe('Assets sidebar - pagination', () => {
await comfyPage.assets.clearMocks()
})
test('initial load fetches first batch with offset 0', async ({
test('Initially loads a batch of assets with has_more pagination', async ({
comfyPage
}) => {
const manyJobs = createMockJobs(250)
// Create a large set of jobs to trigger pagination
const manyJobs = createMockJobs(30)
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()
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)
// Should load at least the first batch
const count = await tab.assetCards.count()
expect(count).toBeGreaterThanOrEqual(1)
})
})

View File

@@ -1,244 +0,0 @@
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)
})
})

View File

@@ -1,126 +0,0 @@
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)
})
})

View File

@@ -0,0 +1,78 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.describe(
'Subgraph node positions after draft reload',
{ tag: ['@subgraph'] },
() => {
test('Node positions are preserved after draft reload with subgraph auto-entry', async ({
comfyPage
}) => {
test.setTimeout(30000)
// Enable workflow persistence explicitly
await comfyPage.settings.setSetting('Comfy.Workflow.Persist', true)
// Load a workflow containing a subgraph
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
// Enter the subgraph programmatically (fixture node is too small for UI click)
await comfyPage.page.evaluate(() => {
const sg = [...window.app!.rootGraph.subgraphs.values()][0]
if (sg) window.app!.canvas.setGraph(sg)
})
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
const positionsBefore = await comfyPage.page.evaluate(() => {
const sg = [...window.app!.rootGraph.subgraphs.values()][0]
return sg.nodes.map((n) => ({
id: n.id,
x: n.pos[0],
y: n.pos[1]
}))
})
expect(positionsBefore.length).toBeGreaterThan(0)
// Wait for the debounced draft persistence to flush to localStorage
await comfyPage.workflow.waitForDraftPersisted()
// Reload the page (draft auto-loads with hash preserved)
await comfyPage.page.reload({ waitUntil: 'networkidle' })
await comfyPage.page.waitForFunction(
() => window.app && window.app.extensionManager
)
await comfyPage.page.waitForSelector('.p-blockui-mask', {
state: 'hidden'
})
await comfyPage.nextFrame()
// Wait for subgraph auto-entry via hash navigation
await expect
.poll(() => comfyPage.subgraph.isInSubgraph(), { timeout: 10000 })
.toBe(true)
// Verify all internal node positions are preserved
const positionsAfter = await comfyPage.page.evaluate(() => {
const sg = [...window.app!.rootGraph.subgraphs.values()][0]
return sg.nodes.map((n) => ({
id: n.id,
x: n.pos[0],
y: n.pos[1]
}))
})
for (const before of positionsBefore) {
const after = positionsAfter.find((n) => n.id === before.id)
expect(
after,
`Node ${before.id} should exist after reload`
).toBeDefined()
expect(after!.x).toBeCloseTo(before.x, 0)
expect(after!.y).toBeCloseTo(before.y, 0)
}
})
}
)

View File

@@ -1,154 +0,0 @@
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)
})
})

View File

@@ -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,10 +15,12 @@ async function clickExactMenuItem(comfyPage: ComfyPage, name: string) {
}
async function openContextMenu(comfyPage: ComfyPage, nodeTitle: string) {
const fixture = await comfyPage.vueNodes.getFixtureByTitle(nodeTitle)
await fixture.header.click()
await fixture.header.click({ button: 'right' })
const menu = comfyPage.contextMenu.primeVueMenu
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')
await menu.waitFor({ state: 'visible' })
return menu
}
@@ -33,13 +35,17 @@ async function openMultiNodeContextMenu(
await comfyPage.nextFrame()
for (const title of titles) {
const fixture = await comfyPage.vueNodes.getFixtureByTitle(title)
await fixture.header.click({ modifiers: ['ControlOrMeta'] })
const header = comfyPage.vueNodes
.getNodeByTitle(title)
.locator('.lg-node-header')
await header.click({ modifiers: ['ControlOrMeta'] })
}
await comfyPage.nextFrame()
const firstFixture = await comfyPage.vueNodes.getFixtureByTitle(titles[0])
const box = await firstFixture.header.boundingBox()
const firstHeader = comfyPage.vueNodes
.getNodeByTitle(titles[0])
.locator('.lg-node-header')
const box = await firstHeader.boundingBox()
if (!box) throw new Error(`Header for "${titles[0]}" not found`)
await comfyPage.page.mouse.click(
box.x + box.width / 2,
@@ -47,15 +53,16 @@ async function openMultiNodeContextMenu(
{ button: 'right' }
)
const menu = comfyPage.contextMenu.primeVueMenu
const menu = comfyPage.page.locator('.p-contextmenu')
await menu.waitFor({ state: 'visible' })
return menu
}
function getNodeWrapper(comfyPage: ComfyPage, nodeTitle: string): Locator {
return comfyPage.vueNodes
.getNodeByTitle(nodeTitle)
.getByTestId(TestIds.node.innerWrapper)
return comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: nodeTitle })
.getByTestId('node-inner-wrapper')
}
async function getNodeRef(comfyPage: ComfyPage, nodeTitle: string) {
@@ -75,7 +82,9 @@ test.describe('Vue Node Context Menu', () => {
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Rename')
const titleInput = comfyPage.page.getByTestId(TestIds.node.titleInput)
const titleInput = comfyPage.page.locator(
'.node-title-editor input[type="text"]'
)
await titleInput.waitFor({ state: 'visible' })
await titleInput.fill('My Renamed Sampler')
await titleInput.press('Enter')
@@ -126,12 +135,16 @@ test.describe('Vue Node Context Menu', () => {
await openContextMenu(comfyPage, nodeTitle)
await clickExactMenuItem(comfyPage, 'Pin')
const fixture = await comfyPage.vueNodes.getFixtureByTitle(nodeTitle)
await expect(fixture.pinIndicator).toBeVisible()
const pinIndicator = comfyPage.vueNodes
.getNodeByTitle(nodeTitle)
.locator(PIN_INDICATOR)
await expect(pinIndicator).toBeVisible()
expect(await nodeRef.isPinned()).toBe(true)
// Verify drag blocked
const header = fixture.header
const header = comfyPage.vueNodes
.getNodeByTitle(nodeTitle)
.locator('.lg-node-header')
const posBeforeDrag = await header.boundingBox()
if (!posBeforeDrag) throw new Error('Header not found')
await comfyPage.canvasOps.dragAndDrop(
@@ -145,7 +158,7 @@ test.describe('Vue Node Context Menu', () => {
await openContextMenu(comfyPage, nodeTitle)
await clickExactMenuItem(comfyPage, 'Unpin')
await expect(fixture.pinIndicator).not.toBeVisible()
await expect(pinIndicator).not.toBeVisible()
expect(await nodeRef.isPinned()).toBe(false)
})
@@ -231,9 +244,7 @@ test.describe('Vue Node Context Menu', () => {
comfyPage
}) => {
// Capture the original image src from the node's preview
const imagePreview = comfyPage.vueNodes
.getNodeByTitle('Load Image')
.getByTestId(TestIds.node.mainImage)
const imagePreview = comfyPage.page.locator('.image-preview img')
const originalSrc = await imagePreview.getAttribute('src')
// Write a test image into the browser clipboard
@@ -336,7 +347,8 @@ test.describe('Vue Node Context Menu', () => {
await comfyPage.nodeOps.fillPromptDialog('TestBlueprint')
// Open node library sidebar and search for the blueprint
await comfyPage.menu.nodeLibraryTab.tabButton.click()
await comfyPage.page.getByRole('button', { name: 'Node Library' }).click()
await comfyPage.nextFrame()
const searchBox = comfyPage.page.getByRole('combobox', {
name: 'Search'
})
@@ -402,16 +414,20 @@ test.describe('Vue Node Context Menu', () => {
await clickExactMenuItem(comfyPage, 'Pin')
for (const title of nodeTitles) {
const fixture = await comfyPage.vueNodes.getFixtureByTitle(title)
await expect(fixture.pinIndicator).toBeVisible()
const pinIndicator = comfyPage.vueNodes
.getNodeByTitle(title)
.locator(PIN_INDICATOR)
await expect(pinIndicator).toBeVisible()
}
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Unpin')
for (const title of nodeTitles) {
const fixture = await comfyPage.vueNodes.getFixtureByTitle(title)
await expect(fixture.pinIndicator).not.toBeVisible()
const pinIndicator = comfyPage.vueNodes
.getNodeByTitle(title)
.locator(PIN_INDICATOR)
await expect(pinIndicator).not.toBeVisible()
}
})

View File

@@ -7,7 +7,6 @@
<EditableText
:is-editing="showInput"
:model-value="editedTitle"
:input-attrs="{ 'data-testid': 'node-title-input' }"
@edit="onEdit"
/>
</div>

View File

@@ -181,7 +181,9 @@ describe('useVueNodeResizeTracking', () => {
resizeObserverState.callback?.([entry], createObserverMock())
expect(rectSpy).toHaveBeenCalledTimes(1)
// When layout store already has correct position, getBoundingClientRect
// is not needed — position is read from the store instead.
expect(rectSpy).not.toHaveBeenCalled()
expect(testState.setSource).not.toHaveBeenCalled()
expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled()
expect(testState.syncNodeSlotLayoutsFromDOM).not.toHaveBeenCalled()
@@ -192,13 +194,13 @@ describe('useVueNodeResizeTracking', () => {
resizeObserverState.callback?.([entry], createObserverMock())
expect(rectSpy).toHaveBeenCalledTimes(1)
expect(rectSpy).not.toHaveBeenCalled()
expect(testState.setSource).not.toHaveBeenCalled()
expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled()
expect(testState.syncNodeSlotLayoutsFromDOM).not.toHaveBeenCalled()
})
it('updates bounds on first observation when size matches but position differs', () => {
it('preserves layout store position when size matches but DOM position differs', () => {
const nodeId = 'test-node'
const width = 240
const height = 180
@@ -209,7 +211,6 @@ describe('useVueNodeResizeTracking', () => {
left: 100,
top: 200
})
const titleHeight = LiteGraph.NODE_TITLE_HEIGHT
seedNodeLayout({
nodeId,
@@ -221,20 +222,10 @@ describe('useVueNodeResizeTracking', () => {
resizeObserverState.callback?.([entry], createObserverMock())
expect(rectSpy).toHaveBeenCalledTimes(1)
expect(testState.setSource).toHaveBeenCalledWith(LayoutSource.DOM)
expect(testState.batchUpdateNodeBounds).toHaveBeenCalledWith([
{
nodeId,
bounds: {
x: 100,
y: 200 + titleHeight,
width,
height
}
}
])
expect(testState.syncNodeSlotLayoutsFromDOM).toHaveBeenCalledWith(nodeId)
// Position from DOM should NOT override layout store position
expect(rectSpy).not.toHaveBeenCalled()
expect(testState.setSource).not.toHaveBeenCalled()
expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled()
})
it('updates node bounds + slot layouts when size changes', () => {

View File

@@ -186,13 +186,26 @@ const resizeObserver = new ResizeObserver((entries) => {
continue
}
// Screen-space rect
const rect = element.getBoundingClientRect()
const [cx, cy] = conv.clientPosToCanvasPos([rect.left, rect.top])
const topLeftCanvas = { x: cx, y: cy }
// Use existing position from layout store (source of truth) rather than
// converting screen-space getBoundingClientRect() back to canvas coords.
// The DOM→canvas conversion depends on the current canvas scale/offset,
// which can be stale during graph transitions (e.g. entering a subgraph
// before fitView runs), producing corrupted positions.
const existingPos = nodeLayout?.position
let posX: number
let posY: number
if (existingPos) {
posX = existingPos.x
posY = existingPos.y
} else {
const rect = element.getBoundingClientRect()
const [cx, cy] = conv.clientPosToCanvasPos([rect.left, rect.top])
posX = cx
posY = cy + LiteGraph.NODE_TITLE_HEIGHT
}
const bounds: Bounds = {
x: topLeftCanvas.x,
y: topLeftCanvas.y + LiteGraph.NODE_TITLE_HEIGHT,
x: posX,
y: posY,
width,
height
}