Compare commits

...

6 Commits

Author SHA1 Message Date
Dante
17d2870ef4 test(modelLibrary): add E2E tests for model library sidebar tab (#10789)
## Summary
- Add `ModelLibraryHelper` mock helper for `/experiment/models` and
`/view_metadata` endpoints
- Add `ModelLibrarySidebarTab` page object fixture with search, folder,
and leaf locators
- Add 11 E2E test scenarios covering tab open/close, folder display,
folder expansion, search with debounce, refresh, load all folders, and
empty state

## Test plan
- [ ] CI passes all Playwright shards
- [ ] `pnpm typecheck:browser` passes
- [ ] `pnpm lint` passes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10789-test-modelLibrary-add-E2E-tests-for-model-library-sidebar-tab-3356d73d365081b49a7ed752512164da)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 08:04:46 +09:00
Dante
7a68943839 test(assets): strengthen pagination E2E assertions (#10773)
## Summary

The existing pagination smoke test only asserts `count >= 1`, which
passes even if the sidebar eagerly loads all items or ignores page
boundaries entirely.

### What changed

**Before:**
- Created 30 mock jobs (less than BATCH_SIZE of 200) — all loaded in one
request, `has_more: false`
- Asserted `count >= 1` — redundant with the grid-render smoke test

**After — two targeted assertions:**

1. **Initial batch < total**: Mock 250 jobs (> BATCH_SIZE 200). First
`/api/jobs?limit=200&offset=0` returns 200 items with `has_more: true`.
Assert `initialCount < 250`.

2. **Scroll triggers second fetch**: Scroll `VirtualGrid` container to
bottom → `approach-end` event → `handleApproachEnd()` →
`assetsStore.loadMoreHistory()` → `/api/jobs?limit=200&offset=200`
fetches remaining 50. Assert `finalCount > initialCount` via
`expect.poll()`.

### Types
Mock data uses `RawJobListItem` from
`src/platform/remote/comfyui/jobs/jobTypes.ts` (Zod-inferred). This is
the correct source-of-truth per `docs/guidance/playwright.md` —
`/api/jobs` is a Python backend endpoint not covered by
`@comfyorg/ingest-types`.

## Test plan
- [ ] CI E2E tests pass
- [ ] `initial batch is smaller than total job count` validates
pagination boundary
- [ ] `scrolling to the end loads additional items` triggers actual
second API call

Fixes #10649

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 08:00:50 +09:00
Dante
8912f4159a test: add E2E tests for workflow tab operations (#10796)
## Summary

Add Playwright E2E tests for workflow tab interactions in the topbar.

## Changes

- **What**: New test file
`browser_tests/tests/topbar/workflowTabs.spec.ts` with 5 tests covering
default tab visibility, tab creation, switching, closing, and context
menu. Added `newWorkflowButton`, `getTab()`, and `getActiveTab()`
locators to `Topbar.ts` fixture.

## Review Focus

Tests are focused on tab UI interactions only (sidebar workflow
operations are already covered in `workflows.spec.ts`). Context menu
assertion uses Reka UI's `data-reka-context-menu-content` attribute.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10796-test-add-E2E-tests-for-workflow-tab-operations-3356d73d36508170a657ef816e23b71c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 07:07:31 +09:00
Dante
794b986954 test: add E2E tests for Node Library V2 sidebar (#10798)
## Summary

- Adds Playwright E2E tests for the Node Library V2 sidebar tab
(`Comfy.NodeLibrary.NewDesign: true`)
- Adds `NodeLibrarySidebarTabV2` fixture class with V2-specific locators
(search input, tab buttons, node cards)
- Exposes `menu.nodeLibraryTabV2` on `ComfyPage` for test access
- Tests cover: tab visibility, default tab selection, tab switching,
folder expansion, search filtering, and sort button presence

## Test plan

- [ ] Run `pnpm test:browser:local -- --grep "Node library sidebar V2"`
against a running ComfyUI server with the V2 node library
- [ ] Verify tests pass in CI

Fixes #9079

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10798-test-add-E2E-tests-for-Node-Library-V2-sidebar-3356d73d36508185a11feaf95e32225b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 07:04:56 +09:00
Terry Jia
a7b3515692 chore: add @jtydhr88 as code owner for GLSL renderer (#10742)
## Summary
add myself as glsl owner

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10742-chore-add-jtydhr88-as-code-owner-for-GLSL-renderer-3336d73d3650816f84deebf3161aee7a)
by [Unito](https://www.unito.io)
2026-04-02 13:09:58 -07:00
Johnpaul Chiwetelu
26f3f11a3e test: replace raw CSS selectors with TestIds in context menu spec (#10760)
## Summary
- Replace raw CSS selectors (`.lg-node-header`, `.p-contextmenu`,
`.node-title-editor input`, `.image-preview img`) with centralized
`TestIds` constants and existing fixtures in the context menu E2E spec
- Add `data-testid="title-editor-input"` to TitleEditor overlay for
stable selector targeting
- Use `NodeLibrarySidebarTab` fixture for node library sidebar
interaction

## Changes
- `browser_tests/fixtures/selectors.ts`: add `pinIndicator`,
`innerWrapper`, `titleEditorInput`, `mainImage` to `TestIds.node`
- `browser_tests/fixtures/utils/vueNodeFixtures.ts`: add `pinIndicator`
getter
- `src/components/graph/TitleEditor.vue`: add `data-testid` via
`input-attrs`
- `browser_tests/.../contextMenu.spec.ts`: replace all raw selectors
with TestIds/fixtures

## Test plan
- [x] All 23 context menu E2E tests pass locally
- [x] Typecheck passes
- [x] Lint passes

Fixes #10750
Fixes #10749

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10760-test-replace-raw-CSS-selectors-with-TestIds-in-context-menu-spec-3336d73d3650818790c0e32e0b6f1e98)
by [Unito](https://www.unito.io)
2026-04-02 21:04:50 +01:00
13 changed files with 852 additions and 50 deletions

View File

@@ -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

View File

@@ -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,6 +33,7 @@ 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'
@@ -55,7 +58,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 +78,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 +214,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,6 +262,7 @@ 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,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')

View File

@@ -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 {

View 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
}
}
}

View File

@@ -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',

View File

@@ -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"]')
}

View File

@@ -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)
})
})

View 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)
})
})

View 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)
})
})

View 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)
})
})

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,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()
}
})

View File

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