mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-31 09:45:46 +00:00
Compare commits
1 Commits
test/image
...
docs/weekl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd65ee0bfd |
@@ -119,7 +119,7 @@ When writing new tests, follow these patterns:
|
||||
|
||||
```typescript
|
||||
// Import the test fixture
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Feature Name', () => {
|
||||
// Set up test environment if needed
|
||||
@@ -148,12 +148,6 @@ Always check for existing helpers and fixtures before implementing new ones:
|
||||
|
||||
Most common testing needs are already addressed by these helpers, which will make your tests more consistent and reliable.
|
||||
|
||||
### Import Conventions
|
||||
|
||||
- Prefer `@e2e/*` for imports within `browser_tests/`
|
||||
- Continue using `@/*` for imports from `src/`
|
||||
- Avoid introducing new deep relative imports within `browser_tests/` when the alias is available
|
||||
|
||||
### Key Testing Patterns
|
||||
|
||||
1. **Focus elements explicitly**:
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "ImageCropV2",
|
||||
"pos": [50, 50],
|
||||
"size": [400, 500],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageCropV2"
|
||||
},
|
||||
"widgets_values": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 512,
|
||||
"height": 512
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
{
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 50],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [1]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["example.png", "image"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "ImageCropV2",
|
||||
"pos": [450, 50],
|
||||
"size": [400, 500],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageCropV2"
|
||||
},
|
||||
"widgets_values": [
|
||||
{
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
"width": 100,
|
||||
"height": 100
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "PreviewImage",
|
||||
"pos": [900, 50],
|
||||
"size": [315, 270],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewImage"
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 0, 2, 0, "IMAGE"],
|
||||
[2, 2, 0, 3, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -2,42 +2,42 @@ import type { APIRequestContext, Locator, Page } from '@playwright/test'
|
||||
import { test as base } from '@playwright/test'
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
|
||||
import { TestIds } from './selectors'
|
||||
import { sleep } from './utils/timing'
|
||||
import { comfyExpect } from './utils/customMatchers'
|
||||
import { NodeBadgeMode } from '../../src/types/nodeSource'
|
||||
import { ComfyActionbar } from '@e2e/helpers/actionbar'
|
||||
import { ComfyTemplates } from '@e2e/helpers/templates'
|
||||
import { ComfyMouse } from '@e2e/fixtures/ComfyMouse'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { comfyExpect } from '@e2e/fixtures/utils/customMatchers'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
import { sleep } from '@e2e/fixtures/utils/timing'
|
||||
import { VueNodeHelpers } from '@e2e/fixtures/VueNodeHelpers'
|
||||
import { BottomPanel } from '@e2e/fixtures/components/BottomPanel'
|
||||
import { ComfyNodeSearchBox } from '@e2e/fixtures/components/ComfyNodeSearchBox'
|
||||
import { ComfyNodeSearchBoxV2 } from '@e2e/fixtures/components/ComfyNodeSearchBoxV2'
|
||||
import { ContextMenu } from '@e2e/fixtures/components/ContextMenu'
|
||||
import { QueuePanel } from '@e2e/fixtures/components/QueuePanel'
|
||||
import { SettingDialog } from '@e2e/fixtures/components/SettingDialog'
|
||||
import { ComfyActionbar } from '../helpers/actionbar'
|
||||
import { ComfyTemplates } from '../helpers/templates'
|
||||
import { ComfyMouse } from './ComfyMouse'
|
||||
import { VueNodeHelpers } from './VueNodeHelpers'
|
||||
import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox'
|
||||
import { ComfyNodeSearchBoxV2 } from './components/ComfyNodeSearchBoxV2'
|
||||
import { ContextMenu } from './components/ContextMenu'
|
||||
import { SettingDialog } from './components/SettingDialog'
|
||||
import { BottomPanel } from './components/BottomPanel'
|
||||
import { QueuePanel } from './components/QueuePanel'
|
||||
import {
|
||||
AssetsSidebarTab,
|
||||
NodeLibrarySidebarTab,
|
||||
WorkflowsSidebarTab
|
||||
} from '@e2e/fixtures/components/SidebarTab'
|
||||
import { Topbar } from '@e2e/fixtures/components/Topbar'
|
||||
import { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
|
||||
import { AssetsHelper } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
import { CanvasHelper } from '@e2e/fixtures/helpers/CanvasHelper'
|
||||
import { ClipboardHelper } from '@e2e/fixtures/helpers/ClipboardHelper'
|
||||
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 { NodeOperationsHelper } from '@e2e/fixtures/helpers/NodeOperationsHelper'
|
||||
import { PerformanceHelper } from '@e2e/fixtures/helpers/PerformanceHelper'
|
||||
import { QueueHelper } from '@e2e/fixtures/helpers/QueueHelper'
|
||||
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'
|
||||
} from './components/SidebarTab'
|
||||
import { Topbar } from './components/Topbar'
|
||||
import { AssetsHelper } from './helpers/AssetsHelper'
|
||||
import { CanvasHelper } from './helpers/CanvasHelper'
|
||||
import { PerformanceHelper } from './helpers/PerformanceHelper'
|
||||
import { QueueHelper } from './helpers/QueueHelper'
|
||||
import { ClipboardHelper } from './helpers/ClipboardHelper'
|
||||
import { CommandHelper } from './helpers/CommandHelper'
|
||||
import { DragDropHelper } from './helpers/DragDropHelper'
|
||||
import { FeatureFlagHelper } from './helpers/FeatureFlagHelper'
|
||||
import { KeyboardHelper } from './helpers/KeyboardHelper'
|
||||
import { NodeOperationsHelper } from './helpers/NodeOperationsHelper'
|
||||
import { SettingsHelper } from './helpers/SettingsHelper'
|
||||
import { AppModeHelper } from './helpers/AppModeHelper'
|
||||
import { SubgraphHelper } from './helpers/SubgraphHelper'
|
||||
import { ToastHelper } from './helpers/ToastHelper'
|
||||
import { WorkflowHelper } from './helpers/WorkflowHelper'
|
||||
import { assetPath } from './utils/paths'
|
||||
import type { WorkspaceStore } from '../types/globals'
|
||||
|
||||
dotenvConfig()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { WorkspaceStore } from '../../types/globals'
|
||||
import { TestIds } from '../selectors'
|
||||
@@ -175,8 +174,6 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
super(page, 'assets')
|
||||
}
|
||||
|
||||
// --- Tab navigation ---
|
||||
|
||||
get generatedTab() {
|
||||
return this.page.getByRole('tab', { name: 'Generated' })
|
||||
}
|
||||
@@ -185,8 +182,6 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
return this.page.getByRole('tab', { name: 'Imported' })
|
||||
}
|
||||
|
||||
// --- Empty state ---
|
||||
|
||||
get emptyStateMessage() {
|
||||
return this.page.getByText(
|
||||
'Upload files or generate content to see them here'
|
||||
@@ -197,169 +192,8 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
return this.page.getByText(title)
|
||||
}
|
||||
|
||||
// --- Search & filter ---
|
||||
|
||||
get searchInput() {
|
||||
return this.page.getByPlaceholder('Search Assets...')
|
||||
}
|
||||
|
||||
get settingsButton() {
|
||||
return this.page.getByRole('button', { name: 'View settings' })
|
||||
}
|
||||
|
||||
// --- View mode ---
|
||||
|
||||
get listViewOption() {
|
||||
return this.page.getByText('List view')
|
||||
}
|
||||
|
||||
get gridViewOption() {
|
||||
return this.page.getByText('Grid view')
|
||||
}
|
||||
|
||||
// --- Sort options (cloud-only, shown inside settings popover) ---
|
||||
|
||||
get sortNewestFirst() {
|
||||
return this.page.getByText('Newest first')
|
||||
}
|
||||
|
||||
get sortOldestFirst() {
|
||||
return this.page.getByText('Oldest first')
|
||||
}
|
||||
|
||||
// --- Asset cards ---
|
||||
|
||||
get assetCards() {
|
||||
return this.page.locator('[role="button"][data-selected]')
|
||||
}
|
||||
|
||||
getAssetCardByName(name: string) {
|
||||
return this.page.locator('[role="button"][data-selected]', {
|
||||
hasText: name
|
||||
})
|
||||
}
|
||||
|
||||
get selectedCards() {
|
||||
return this.page.locator('[data-selected="true"]')
|
||||
}
|
||||
|
||||
// --- List view items ---
|
||||
|
||||
get listViewItems() {
|
||||
return this.page.locator(
|
||||
'.sidebar-content-container [role="button"][tabindex="0"]'
|
||||
)
|
||||
}
|
||||
|
||||
// --- Selection footer ---
|
||||
|
||||
get selectionFooter() {
|
||||
return this.page
|
||||
.locator('.sidebar-content-container')
|
||||
.locator('..')
|
||||
.locator('[class*="h-18"]')
|
||||
}
|
||||
|
||||
get selectionCountButton() {
|
||||
return this.page.getByText(/Assets Selected: \d+/)
|
||||
}
|
||||
|
||||
get deselectAllButton() {
|
||||
return this.page.getByText('Deselect all')
|
||||
}
|
||||
|
||||
get deleteSelectedButton() {
|
||||
return this.page
|
||||
.getByTestId('assets-delete-selected')
|
||||
.or(this.page.locator('button:has(.icon-\\[lucide--trash-2\\])').last())
|
||||
.first()
|
||||
}
|
||||
|
||||
get downloadSelectedButton() {
|
||||
return this.page
|
||||
.getByTestId('assets-download-selected')
|
||||
.or(this.page.locator('button:has(.icon-\\[lucide--download\\])').last())
|
||||
.first()
|
||||
}
|
||||
|
||||
// --- Context menu ---
|
||||
|
||||
contextMenuItem(label: string) {
|
||||
return this.page.locator('.p-contextmenu').getByText(label)
|
||||
}
|
||||
|
||||
// --- Folder view ---
|
||||
|
||||
get backToAssetsButton() {
|
||||
return this.page.getByText('Back to all assets')
|
||||
}
|
||||
|
||||
// --- Loading ---
|
||||
|
||||
get skeletonLoaders() {
|
||||
return this.page.locator('.sidebar-content-container .animate-pulse')
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
override async open() {
|
||||
// Remove any toast notifications that may overlay the sidebar button
|
||||
await this.dismissToasts()
|
||||
await super.open()
|
||||
await this.generatedTab.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
/** Dismiss all visible toast notifications by clicking their close buttons. */
|
||||
async dismissToasts() {
|
||||
const closeButtons = this.page.locator('.p-toast-close-button')
|
||||
for (const btn of await closeButtons.all()) {
|
||||
await btn.click({ force: true }).catch(() => {})
|
||||
}
|
||||
// Wait for all toast elements to fully animate out and detach from DOM
|
||||
await expect(this.page.locator('.p-toast-message'))
|
||||
.toHaveCount(0, { timeout: 5000 })
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
async switchToImported() {
|
||||
await this.dismissToasts()
|
||||
await this.importedTab.click()
|
||||
await expect(this.importedTab).toHaveAttribute('aria-selected', 'true', {
|
||||
timeout: 3000
|
||||
})
|
||||
}
|
||||
|
||||
async switchToGenerated() {
|
||||
await this.dismissToasts()
|
||||
await this.generatedTab.click()
|
||||
await expect(this.generatedTab).toHaveAttribute('aria-selected', 'true', {
|
||||
timeout: 3000
|
||||
})
|
||||
}
|
||||
|
||||
async openSettingsMenu() {
|
||||
await this.dismissToasts()
|
||||
await this.settingsButton.click()
|
||||
// Wait for popover content to render
|
||||
await this.listViewOption
|
||||
.or(this.gridViewOption)
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
}
|
||||
|
||||
async rightClickAsset(name: string) {
|
||||
const card = this.getAssetCardByName(name)
|
||||
await card.click({ button: 'right' })
|
||||
await this.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
}
|
||||
|
||||
async waitForAssets(count?: number) {
|
||||
if (count !== undefined) {
|
||||
await expect(this.assetCards).toHaveCount(count, { timeout: 5000 })
|
||||
} else {
|
||||
await this.assetCards.first().waitFor({ state: 'visible', timeout: 5000 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,63 +5,6 @@ import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/j
|
||||
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
|
||||
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
|
||||
/** Factory to create a mock completed job with preview output. */
|
||||
export function createMockJob(
|
||||
overrides: Partial<RawJobListItem> & { id: string }
|
||||
): RawJobListItem {
|
||||
const now = Date.now() / 1000
|
||||
return {
|
||||
status: 'completed',
|
||||
create_time: now,
|
||||
execution_start_time: now,
|
||||
execution_end_time: now + 5,
|
||||
preview_output: {
|
||||
filename: `output_${overrides.id}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1,
|
||||
priority: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/** Create multiple mock jobs with sequential IDs and staggered timestamps. */
|
||||
export function createMockJobs(
|
||||
count: number,
|
||||
baseOverrides?: Partial<RawJobListItem>
|
||||
): RawJobListItem[] {
|
||||
const now = Date.now() / 1000
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
createMockJob({
|
||||
id: `job-${String(i + 1).padStart(3, '0')}`,
|
||||
create_time: now - i * 60,
|
||||
execution_start_time: now - i * 60,
|
||||
execution_end_time: now - i * 60 + 5 + i,
|
||||
preview_output: {
|
||||
filename: `image_${String(i + 1).padStart(3, '0')}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
...baseOverrides
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Create mock imported file names with various media types. */
|
||||
export function createMockImportedFiles(count: number): string[] {
|
||||
const extensions = ['png', 'jpg', 'mp4', 'wav', 'glb', 'txt']
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, i) =>
|
||||
`imported_${String(i + 1).padStart(3, '0')}.${extensions[i % extensions.length]}`
|
||||
)
|
||||
}
|
||||
|
||||
function parseLimit(url: URL, total: number): number {
|
||||
const value = Number(url.searchParams.get('limit'))
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { Response } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import type { StatusWsMessage } from '../../src/schemas/apiSchema'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
import { comfyPageFixture } from '../fixtures/ComfyPage'
|
||||
import { webSocketFixture } from '../fixtures/ws'
|
||||
import type { WorkspaceStore } from '../types/globals'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
type CropValue = { x: number; y: number; width: number; height: number } | null
|
||||
|
||||
async function getCropValue(comfyPage: ComfyPage): Promise<CropValue> {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const n = window.app!.graph.getNodeById(2)
|
||||
const w = n?.widgets?.find((w) => w.type === 'imagecrop')
|
||||
const v = w?.value as Record<string, number> | undefined
|
||||
return v ? { x: v.x, y: v.y, width: v.width, height: v.height } : null
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Image Crop', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
})
|
||||
|
||||
test.describe('without source image', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/image_crop_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test(
|
||||
'Shows empty state when no input image is connected',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
await expect
|
||||
.soft(node.locator('.icon-\\[lucide--image\\]'))
|
||||
.toBeVisible()
|
||||
await expect.soft(node).toContainText('No input image connected')
|
||||
await expect.soft(node.locator('.cursor-move')).toHaveCount(0)
|
||||
await expect.soft(node.locator('img')).toHaveCount(0)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Renders controls in default state',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
await expect(node.getByText('Ratio')).toBeVisible()
|
||||
await expect(
|
||||
node.locator('button:has(.icon-\\[lucide--lock-open\\])')
|
||||
).toBeVisible()
|
||||
await expect(node.locator('input')).toHaveCount(4)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('with source image after execution', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/image_crop_with_source')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.runButton.click()
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeLocator('2').locator('img')
|
||||
).toBeVisible({ timeout: 30_000 })
|
||||
})
|
||||
|
||||
test(
|
||||
'Displays source image with crop overlay after execution',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const img = node.locator('img')
|
||||
|
||||
await expect
|
||||
.poll(() => img.evaluate((el: HTMLImageElement) => el.naturalWidth))
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
await expect(node.locator('.cursor-move')).toBeVisible()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.evaluate(() =>
|
||||
Promise.all(
|
||||
document
|
||||
.getAnimations()
|
||||
.filter((a) => a.playState === 'running')
|
||||
.map((a) => a.finished.catch(() => undefined))
|
||||
)
|
||||
)
|
||||
await expect(node).toHaveScreenshot('image-crop-with-source.png', {
|
||||
maxDiffPixelRatio: 0.02
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Drag crop box updates crop position',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const cropBox = node.locator('.cursor-move')
|
||||
const box = await cropBox.boundingBox()
|
||||
expect(box, 'Crop box not found').not.toBeNull()
|
||||
|
||||
const valueBefore = await getCropValue(comfyPage)
|
||||
expect(
|
||||
valueBefore,
|
||||
'Widget value missing — check fixture setup'
|
||||
).not.toBeNull()
|
||||
|
||||
const startX = box!.x + box!.width / 2
|
||||
const startY = box!.y + box!.height / 2
|
||||
|
||||
const pointerOpts = { bubbles: true, cancelable: true, pointerId: 1 }
|
||||
await cropBox.dispatchEvent('pointerdown', {
|
||||
...pointerOpts,
|
||||
clientX: startX,
|
||||
clientY: startY
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await cropBox.dispatchEvent('pointermove', {
|
||||
...pointerOpts,
|
||||
clientX: startX + 15,
|
||||
clientY: startY + 10
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await cropBox.dispatchEvent('pointermove', {
|
||||
...pointerOpts,
|
||||
clientX: startX + 30,
|
||||
clientY: startY + 20
|
||||
})
|
||||
await cropBox.dispatchEvent('pointerup', {
|
||||
...pointerOpts,
|
||||
clientX: startX + 30,
|
||||
clientY: startY + 20
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(async () => {
|
||||
const valueAfter = await getCropValue(comfyPage)
|
||||
expect(valueAfter?.x).toBeGreaterThan(valueBefore!.x)
|
||||
expect(valueAfter?.y).toBeGreaterThan(valueBefore!.y)
|
||||
expect(valueAfter?.width).toBe(valueBefore!.width)
|
||||
expect(valueAfter?.height).toBe(valueBefore!.height)
|
||||
}).toPass()
|
||||
|
||||
await expect(node).toHaveScreenshot('image-crop-after-drag.png')
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB |
@@ -1,72 +1,8 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import {
|
||||
createMockJob,
|
||||
createMockJobs
|
||||
} from '../../fixtures/helpers/AssetsHelper'
|
||||
import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SAMPLE_JOBS: RawJobListItem[] = [
|
||||
createMockJob({
|
||||
id: 'job-alpha',
|
||||
create_time: 1000,
|
||||
execution_start_time: 1000,
|
||||
execution_end_time: 1010,
|
||||
preview_output: {
|
||||
filename: 'landscape.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-beta',
|
||||
create_time: 2000,
|
||||
execution_start_time: 2000,
|
||||
execution_end_time: 2003,
|
||||
preview_output: {
|
||||
filename: 'portrait.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '2',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-gamma',
|
||||
create_time: 3000,
|
||||
execution_start_time: 3000,
|
||||
execution_end_time: 3020,
|
||||
preview_output: {
|
||||
filename: 'abstract_art.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '3',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 2
|
||||
})
|
||||
]
|
||||
|
||||
const SAMPLE_IMPORTED_FILES = [
|
||||
'reference_photo.png',
|
||||
'background.jpg',
|
||||
'audio_clip.wav'
|
||||
]
|
||||
|
||||
// ==========================================================================
|
||||
// 1. Empty states
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - empty states', () => {
|
||||
test.describe('Assets sidebar', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockEmptyState()
|
||||
await comfyPage.setup()
|
||||
@@ -76,587 +12,19 @@ test.describe('Assets sidebar - empty states', () => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Shows empty-state copy for generated tab', async ({ comfyPage }) => {
|
||||
test('Shows empty-state copy for generated and imported tabs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.emptyStateTitle('No generated files found')).toBeVisible()
|
||||
await expect(tab.emptyStateMessage).toBeVisible()
|
||||
})
|
||||
|
||||
test('Shows empty-state copy for imported tab', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.switchToImported()
|
||||
await tab.importedTab.click()
|
||||
|
||||
await expect(tab.emptyStateTitle('No imported files found')).toBeVisible()
|
||||
await expect(tab.emptyStateMessage).toBeVisible()
|
||||
})
|
||||
|
||||
test('No asset cards are rendered when empty', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 2. Tab navigation
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - tab navigation', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles(SAMPLE_IMPORTED_FILES)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Generated tab is active by default', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.importedTab).toHaveAttribute('aria-selected', 'false')
|
||||
})
|
||||
|
||||
test('Can switch between Generated and Imported tabs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Switch to Imported
|
||||
await tab.switchToImported()
|
||||
await expect(tab.importedTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'false')
|
||||
|
||||
// Switch back to Generated
|
||||
await tab.switchToGenerated()
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
test('Search is cleared when switching tabs', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Type search in Generated tab
|
||||
await tab.searchInput.fill('landscape')
|
||||
await expect(tab.searchInput).toHaveValue('landscape')
|
||||
|
||||
// Switch to Imported tab
|
||||
await tab.switchToImported()
|
||||
await expect(tab.searchInput).toHaveValue('')
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 3. Asset display - grid view
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - grid view display', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles(SAMPLE_IMPORTED_FILES)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Displays generated assets as cards in grid view', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.waitForAssets()
|
||||
const count = await tab.assetCards.count()
|
||||
expect(count).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('Displays imported files when switching to Imported tab', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.switchToImported()
|
||||
|
||||
// Wait for imported assets to render
|
||||
await expect(tab.assetCards.first()).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Imported tab should show the mocked files
|
||||
const count = await tab.assetCards.count()
|
||||
expect(count).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 4. View mode toggle (grid <-> list)
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - view mode toggle', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Can switch to list view via settings menu', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Open settings menu and select list view
|
||||
await tab.openSettingsMenu()
|
||||
await tab.listViewOption.click()
|
||||
|
||||
// List view items should now be visible
|
||||
await expect(tab.listViewItems.first()).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Can switch back to grid view', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Switch to list view
|
||||
await tab.openSettingsMenu()
|
||||
await tab.listViewOption.click()
|
||||
await expect(tab.listViewItems.first()).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Switch back to grid view (settings popover is still open)
|
||||
await tab.gridViewOption.click()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Grid cards (with data-selected attribute) should be visible again
|
||||
await expect(tab.assetCards.first()).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 5. Search functionality
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - search', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Search input is visible', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.searchInput).toBeVisible()
|
||||
})
|
||||
|
||||
test('Filtering assets by search query reduces displayed count', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Search for a specific filename that matches only one asset
|
||||
await tab.searchInput.fill('landscape')
|
||||
|
||||
// Wait for filter to reduce the count
|
||||
await expect(async () => {
|
||||
const filteredCount = await tab.assetCards.count()
|
||||
expect(filteredCount).toBeLessThan(initialCount)
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Clearing search restores all assets', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Filter then clear
|
||||
await tab.searchInput.fill('landscape')
|
||||
await expect(async () => {
|
||||
expect(await tab.assetCards.count()).toBeLessThan(initialCount)
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
await tab.searchInput.fill('')
|
||||
await expect(tab.assetCards).toHaveCount(initialCount, { timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Search with no matches shows empty state', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.searchInput.fill('nonexistent_file_xyz')
|
||||
await expect(tab.assetCards).toHaveCount(0, { timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 6. Asset selection
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - selection', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Clicking an asset card selects it', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Click first asset card
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Should have data-selected="true"
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Ctrl+click adds to selection', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const cards = tab.assetCards
|
||||
const cardCount = await cards.count()
|
||||
expect(cardCount).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Click first card
|
||||
await cards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Ctrl+click second card
|
||||
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('Selection shows footer with count and actions', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Footer should show selection count
|
||||
await expect(tab.selectionCountButton).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('Deselect all clears selection', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Hover over the selection count button to reveal "Deselect all"
|
||||
await tab.selectionCountButton.hover()
|
||||
await expect(tab.deselectAllButton).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Click "Deselect all"
|
||||
await tab.deselectAllButton.click()
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Selection is cleared when switching tabs', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Switch to Imported tab
|
||||
await tab.switchToImported()
|
||||
|
||||
// Switch back - selection should be cleared
|
||||
await tab.switchToGenerated()
|
||||
await tab.waitForAssets()
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 7. Context menu
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - context menu', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Right-clicking an asset shows context menu', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Right-click first asset
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
|
||||
// Context menu should appear with standard items
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(contextMenu).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('Context menu contains Download action for output asset', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
|
||||
await expect(tab.contextMenuItem('Download')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Context menu contains Inspect action for image assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
|
||||
await expect(tab.contextMenuItem('Inspect asset')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Context menu contains Delete action for output assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
|
||||
await expect(tab.contextMenuItem('Delete')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Context menu contains Copy job ID for output assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
|
||||
await expect(tab.contextMenuItem('Copy job ID')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Context menu contains workflow actions for output assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(contextMenu).toBeVisible({ timeout: 3000 })
|
||||
|
||||
await expect(
|
||||
tab.contextMenuItem('Open as workflow in new tab')
|
||||
).toBeVisible()
|
||||
await expect(tab.contextMenuItem('Export workflow')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Bulk context menu shows when multiple assets selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const cards = tab.assetCards
|
||||
const cardCount = await cards.count()
|
||||
expect(cardCount).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Dismiss any toasts that appeared after asset loading
|
||||
await tab.dismissToasts()
|
||||
|
||||
// Multi-select: click first, then Ctrl/Cmd+click second
|
||||
await cards.first().click()
|
||||
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
|
||||
|
||||
// Verify multi-selection took effect and footer is stable before right-clicking
|
||||
await expect(tab.selectedCards).toHaveCount(2, { timeout: 3000 })
|
||||
await expect(tab.selectionFooter).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Right-click on a selected card (retry to let grid layout settle)
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(async () => {
|
||||
await cards.first().click({ button: 'right' })
|
||||
await expect(contextMenu).toBeVisible()
|
||||
}).toPass({ intervals: [300], timeout: 5000 })
|
||||
|
||||
// Bulk menu should show bulk download action
|
||||
await expect(tab.contextMenuItem('Download all')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 8. Bulk actions (footer)
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - bulk actions', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Footer shows download button when assets selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Download button in footer should be visible
|
||||
await expect(tab.downloadSelectedButton).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('Footer shows delete button when output assets selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Delete button in footer should be visible
|
||||
await expect(tab.deleteSelectedButton).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('Selection count displays correct number', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select two assets
|
||||
const cards = tab.assetCards
|
||||
const cardCount = await cards.count()
|
||||
expect(cardCount).toBeGreaterThanOrEqual(2)
|
||||
|
||||
await cards.first().click()
|
||||
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
|
||||
|
||||
// Selection count should show the count
|
||||
await expect(tab.selectionCountButton).toBeVisible({ timeout: 3000 })
|
||||
const text = await tab.selectionCountButton.textContent()
|
||||
expect(text).toMatch(/Assets Selected: \d+/)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 9. Pagination
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - pagination', () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Initially loads a batch of assets with has_more pagination', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Create a large set of jobs to trigger pagination
|
||||
const manyJobs = createMockJobs(30)
|
||||
await comfyPage.assets.mockOutputHistory(manyJobs)
|
||||
await comfyPage.setup()
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 10. Settings menu visibility
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - settings menu', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Settings menu shows view mode options', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.openSettingsMenu()
|
||||
|
||||
await expect(tab.listViewOption).toBeVisible()
|
||||
await expect(tab.gridViewOption).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,528 +0,0 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../../../../fixtures/ComfyPage'
|
||||
|
||||
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()
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
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')
|
||||
await menu.waitFor({ state: 'visible' })
|
||||
return menu
|
||||
}
|
||||
|
||||
async function openMultiNodeContextMenu(
|
||||
comfyPage: ComfyPage,
|
||||
titles: string[]
|
||||
) {
|
||||
// deselectAll via evaluate — clearSelection() clicks at a fixed position
|
||||
// which can hit nodes or the toolbar overlay
|
||||
await comfyPage.page.evaluate(() => window.app!.canvas.deselectAll())
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
for (const title of titles) {
|
||||
const header = comfyPage.vueNodes
|
||||
.getNodeByTitle(title)
|
||||
.locator('.lg-node-header')
|
||||
await header.click({ modifiers: ['ControlOrMeta'] })
|
||||
}
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
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,
|
||||
box.y + box.height / 2,
|
||||
{ button: 'right' }
|
||||
)
|
||||
|
||||
const menu = comfyPage.page.locator('.p-contextmenu')
|
||||
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')
|
||||
}
|
||||
|
||||
async function getNodeRef(comfyPage: ComfyPage, nodeTitle: string) {
|
||||
const refs = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
|
||||
return refs[0]
|
||||
}
|
||||
|
||||
test.describe('Vue Node Context Menu', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test.describe('Single Node Actions', () => {
|
||||
test('should rename node via context menu', async ({ comfyPage }) => {
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
await clickExactMenuItem(comfyPage, 'Rename')
|
||||
|
||||
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')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const renamedNode =
|
||||
comfyPage.vueNodes.getNodeByTitle('My Renamed Sampler')
|
||||
await expect(renamedNode).toBeVisible()
|
||||
})
|
||||
|
||||
test('should copy and paste node via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await openContextMenu(comfyPage, 'Load Checkpoint')
|
||||
await clickExactMenuItem(comfyPage, 'Copy')
|
||||
|
||||
// Internal clipboard paste (menu Copy uses canvas clipboard, not OS)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.canvas.pasteFromClipboard({ connectInputs: false })
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
|
||||
initialCount + 1
|
||||
)
|
||||
})
|
||||
|
||||
test('should duplicate node via context menu', async ({ comfyPage }) => {
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await openContextMenu(comfyPage, 'Load Checkpoint')
|
||||
await clickExactMenuItem(comfyPage, 'Duplicate')
|
||||
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
|
||||
initialCount + 1
|
||||
)
|
||||
})
|
||||
|
||||
test('should pin and unpin node via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodeTitle = 'Load Checkpoint'
|
||||
const nodeRef = await getNodeRef(comfyPage, nodeTitle)
|
||||
|
||||
// Pin via context menu
|
||||
await openContextMenu(comfyPage, nodeTitle)
|
||||
await clickExactMenuItem(comfyPage, 'Pin')
|
||||
|
||||
const pinIndicator = comfyPage.vueNodes
|
||||
.getNodeByTitle(nodeTitle)
|
||||
.locator(PIN_INDICATOR)
|
||||
await expect(pinIndicator).toBeVisible()
|
||||
expect(await nodeRef.isPinned()).toBe(true)
|
||||
|
||||
// Verify drag blocked
|
||||
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(
|
||||
{ x: posBeforeDrag.x + 10, y: posBeforeDrag.y + 10 },
|
||||
{ x: posBeforeDrag.x + 256, y: posBeforeDrag.y + 256 }
|
||||
)
|
||||
const posAfterDrag = await header.boundingBox()
|
||||
expect(posAfterDrag).toEqual(posBeforeDrag)
|
||||
|
||||
// Unpin via context menu
|
||||
await openContextMenu(comfyPage, nodeTitle)
|
||||
await clickExactMenuItem(comfyPage, 'Unpin')
|
||||
|
||||
await expect(pinIndicator).not.toBeVisible()
|
||||
expect(await nodeRef.isPinned()).toBe(false)
|
||||
})
|
||||
|
||||
test('should bypass node and remove bypass via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodeTitle = 'Load Checkpoint'
|
||||
const nodeRef = await getNodeRef(comfyPage, nodeTitle)
|
||||
|
||||
await openContextMenu(comfyPage, nodeTitle)
|
||||
await clickExactMenuItem(comfyPage, 'Bypass')
|
||||
|
||||
expect(await nodeRef.isBypassed()).toBe(true)
|
||||
await expect(getNodeWrapper(comfyPage, nodeTitle)).toHaveClass(
|
||||
BYPASS_CLASS
|
||||
)
|
||||
|
||||
await openContextMenu(comfyPage, nodeTitle)
|
||||
await clickExactMenuItem(comfyPage, 'Remove Bypass')
|
||||
|
||||
expect(await nodeRef.isBypassed()).toBe(false)
|
||||
await expect(getNodeWrapper(comfyPage, nodeTitle)).not.toHaveClass(
|
||||
BYPASS_CLASS
|
||||
)
|
||||
})
|
||||
|
||||
test('should minimize and expand node via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const fixture = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await expect(fixture.body).toBeVisible()
|
||||
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
await clickExactMenuItem(comfyPage, 'Minimize Node')
|
||||
await expect(fixture.body).not.toBeVisible()
|
||||
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
await clickExactMenuItem(comfyPage, 'Expand Node')
|
||||
await expect(fixture.body).toBeVisible()
|
||||
})
|
||||
|
||||
test('should convert node to subgraph via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
|
||||
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Image Node Actions', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page
|
||||
.context()
|
||||
.grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.vueNodes.waitForNodes(1)
|
||||
})
|
||||
|
||||
test('should copy image to clipboard via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openContextMenu(comfyPage, 'Load Image')
|
||||
await clickExactMenuItem(comfyPage, 'Copy Image')
|
||||
|
||||
// Verify the clipboard contains an image
|
||||
const hasImage = await comfyPage.page.evaluate(async () => {
|
||||
const items = await navigator.clipboard.read()
|
||||
return items.some((item) =>
|
||||
item.types.some((t) => t.startsWith('image/'))
|
||||
)
|
||||
})
|
||||
expect(hasImage).toBe(true)
|
||||
})
|
||||
|
||||
test('should paste image to LoadImage node via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Capture the original image src from the node's preview
|
||||
const imagePreview = comfyPage.page.locator('.image-preview img')
|
||||
const originalSrc = await imagePreview.getAttribute('src')
|
||||
|
||||
// Write a test image into the browser clipboard
|
||||
await comfyPage.page.evaluate(async () => {
|
||||
const resp = await fetch('/api/view?filename=example.png&type=input')
|
||||
const blob = await resp.blob()
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ [blob.type]: blob })
|
||||
])
|
||||
})
|
||||
|
||||
// Right-click and select Paste Image
|
||||
await openContextMenu(comfyPage, 'Load Image')
|
||||
await clickExactMenuItem(comfyPage, 'Paste Image')
|
||||
|
||||
// Verify the image preview src changed
|
||||
await expect(imagePreview).not.toHaveAttribute('src', originalSrc!)
|
||||
})
|
||||
|
||||
test('should open image in new tab via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openContextMenu(comfyPage, 'Load Image')
|
||||
|
||||
const popupPromise = comfyPage.page.waitForEvent('popup')
|
||||
await clickExactMenuItem(comfyPage, 'Open Image')
|
||||
const popup = await popupPromise
|
||||
|
||||
expect(popup.url()).toContain('/api/view')
|
||||
expect(popup.url()).toContain('filename=')
|
||||
await popup.close()
|
||||
})
|
||||
|
||||
test('should download image via Save Image context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openContextMenu(comfyPage, 'Load Image')
|
||||
|
||||
const downloadPromise = comfyPage.page.waitForEvent('download')
|
||||
await clickExactMenuItem(comfyPage, 'Save Image')
|
||||
const download = await downloadPromise
|
||||
|
||||
expect(download.suggestedFilename()).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Subgraph Actions', () => {
|
||||
test('should convert to subgraph and unpack back', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Convert KSampler to subgraph
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
|
||||
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
).not.toBeVisible()
|
||||
|
||||
// Unpack the subgraph
|
||||
await openContextMenu(comfyPage, 'New Subgraph')
|
||||
await clickExactMenuItem(comfyPage, 'Unpack Subgraph')
|
||||
|
||||
await expect(comfyPage.vueNodes.getNodeByTitle('KSampler')).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should open properties panel via Edit Subgraph Widgets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Convert to subgraph first
|
||||
await openContextMenu(comfyPage, 'Empty Latent Image')
|
||||
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Right-click subgraph and edit widgets
|
||||
await openContextMenu(comfyPage, 'New Subgraph')
|
||||
await clickExactMenuItem(comfyPage, 'Edit Subgraph Widgets')
|
||||
|
||||
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should add subgraph to library and find in node library', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Convert to subgraph first
|
||||
await openContextMenu(comfyPage, 'KSampler')
|
||||
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Add to library
|
||||
await openContextMenu(comfyPage, 'New Subgraph')
|
||||
await clickExactMenuItem(comfyPage, 'Add Subgraph to Library')
|
||||
|
||||
// Fill the blueprint name
|
||||
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' })
|
||||
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()
|
||||
const searchBox = comfyPage.page.getByRole('combobox', {
|
||||
name: 'Search'
|
||||
})
|
||||
await searchBox.waitFor({ state: 'visible' })
|
||||
await searchBox.fill('TestBlueprint')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.page.getByText('TestBlueprint')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Multi-Node Actions', () => {
|
||||
const nodeTitles = ['Load Checkpoint', 'KSampler']
|
||||
|
||||
test('should batch rename selected nodes via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Rename')
|
||||
|
||||
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' })
|
||||
await comfyPage.nodeOps.fillPromptDialog('MyNode')
|
||||
|
||||
await expect(comfyPage.vueNodes.getNodeByTitle('MyNode 1')).toBeVisible()
|
||||
await expect(comfyPage.vueNodes.getNodeByTitle('MyNode 2')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should copy and paste selected nodes via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Copy')
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.canvas.pasteFromClipboard({ connectInputs: false })
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
|
||||
initialCount + nodeTitles.length
|
||||
)
|
||||
})
|
||||
|
||||
test('should duplicate selected nodes via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Duplicate')
|
||||
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
|
||||
initialCount + nodeTitles.length
|
||||
)
|
||||
})
|
||||
|
||||
test('should pin and unpin selected nodes via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Pin')
|
||||
|
||||
for (const title of nodeTitles) {
|
||||
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 pinIndicator = comfyPage.vueNodes
|
||||
.getNodeByTitle(title)
|
||||
.locator(PIN_INDICATOR)
|
||||
await expect(pinIndicator).not.toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('should bypass and remove bypass on selected nodes via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Bypass')
|
||||
|
||||
for (const title of nodeTitles) {
|
||||
const nodeRef = await getNodeRef(comfyPage, title)
|
||||
expect(await nodeRef.isBypassed()).toBe(true)
|
||||
await expect(getNodeWrapper(comfyPage, title)).toHaveClass(BYPASS_CLASS)
|
||||
}
|
||||
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Remove Bypass')
|
||||
|
||||
for (const title of nodeTitles) {
|
||||
const nodeRef = await getNodeRef(comfyPage, title)
|
||||
expect(await nodeRef.isBypassed()).toBe(false)
|
||||
await expect(getNodeWrapper(comfyPage, title)).not.toHaveClass(
|
||||
BYPASS_CLASS
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('should minimize and expand selected nodes via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const fixture1 =
|
||||
await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
|
||||
const fixture2 = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
|
||||
await expect(fixture1.body).toBeVisible()
|
||||
await expect(fixture2.body).toBeVisible()
|
||||
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Minimize Node')
|
||||
|
||||
await expect(fixture1.body).not.toBeVisible()
|
||||
await expect(fixture2.body).not.toBeVisible()
|
||||
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Expand Node')
|
||||
|
||||
await expect(fixture1.body).toBeVisible()
|
||||
await expect(fixture2.body).toBeVisible()
|
||||
})
|
||||
|
||||
test('should frame selected nodes via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initialGroupCount = await comfyPage.page.evaluate(
|
||||
() => window.app!.graph.groups.length
|
||||
)
|
||||
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Frame Nodes')
|
||||
|
||||
const newGroupCount = await comfyPage.page.evaluate(
|
||||
() => window.app!.graph.groups.length
|
||||
)
|
||||
expect(newGroupCount).toBe(initialGroupCount + 1)
|
||||
})
|
||||
|
||||
test('should convert to group node via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Convert to Group Node')
|
||||
|
||||
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' })
|
||||
await comfyPage.nodeOps.fillPromptDialog('TestGroupNode')
|
||||
|
||||
const groupNodes = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'workflow>TestGroupNode'
|
||||
)
|
||||
expect(groupNodes.length).toBe(1)
|
||||
})
|
||||
|
||||
test('should convert selected nodes to subgraph via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
|
||||
await openMultiNodeContextMenu(comfyPage, nodeTitles)
|
||||
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
|
||||
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
|
||||
initialCount - nodeTitles.length + 1
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -323,174 +323,6 @@ test.describe('Workflow Persistence', () => {
|
||||
expect(linkCountAfter).toBe(linkCountBefore)
|
||||
})
|
||||
|
||||
test('Closing an inactive tab with save preserves its own content', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'PR #10745 — saveWorkflow called checkState on inactive tab, serializing the active graph instead'
|
||||
})
|
||||
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Topbar'
|
||||
)
|
||||
|
||||
const suffix = Date.now().toString(36)
|
||||
const nameA = `test-A-${suffix}`
|
||||
const nameB = `test-B-${suffix}`
|
||||
|
||||
// Save the default workflow as A
|
||||
await comfyPage.menu.topbar.saveWorkflow(nameA)
|
||||
const nodeCountA = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
// Create B: duplicate and save
|
||||
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.menu.topbar.saveWorkflow(nameB)
|
||||
|
||||
// Add a Note node in B to mark it as modified
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.graph.add(window.LiteGraph!.createNode('Note', undefined, {}))
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nodeCountB = await comfyPage.nodeOps.getNodeCount()
|
||||
expect(nodeCountB).toBe(nodeCountA + 1)
|
||||
|
||||
// Trigger checkState so isModified is set
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const em = window.app!.extensionManager as unknown as Record<
|
||||
string,
|
||||
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
|
||||
>
|
||||
em.workflow?.activeWorkflow?.changeTracker?.checkState()
|
||||
})
|
||||
|
||||
// Switch to A via topbar tab (making B inactive)
|
||||
await comfyPage.menu.topbar.getWorkflowTab(nameA).click()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBe(nodeCountA)
|
||||
|
||||
// Close inactive B tab via middle-click — triggers "Save before closing?"
|
||||
await comfyPage.menu.topbar.getWorkflowTab(nameB).click({
|
||||
button: 'middle'
|
||||
})
|
||||
|
||||
// Click "Save" in the dirty close dialog
|
||||
const saveButton = comfyPage.page.getByRole('button', { name: 'Save' })
|
||||
await saveButton.waitFor({ state: 'visible' })
|
||||
await saveButton.click()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify we're still on A with A's content
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBe(nodeCountA)
|
||||
|
||||
// Re-open B from sidebar saved list
|
||||
const workflowsTab = comfyPage.menu.workflowsTab
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(nameB).dblclick()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
|
||||
// B should have the extra Note node we added, not A's node count
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 })
|
||||
.toBe(nodeCountB)
|
||||
})
|
||||
|
||||
test('Closing an inactive unsaved tab with save preserves its own content', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'PR #10745 — saveWorkflowAs called checkState on inactive temp tab, serializing the active graph'
|
||||
})
|
||||
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Topbar'
|
||||
)
|
||||
|
||||
const suffix = Date.now().toString(36)
|
||||
const nameA = `test-A-${suffix}`
|
||||
const nameB = `test-B-${suffix}`
|
||||
|
||||
// Save the default workflow as A
|
||||
await comfyPage.menu.topbar.saveWorkflow(nameA)
|
||||
const nodeCountA = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
// Create B as an unsaved workflow with a Note node
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.graph.add(window.LiteGraph!.createNode('Note', undefined, {}))
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Trigger checkState so isModified is set
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const em = window.app!.extensionManager as unknown as Record<
|
||||
string,
|
||||
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
|
||||
>
|
||||
em.workflow?.activeWorkflow?.changeTracker?.checkState()
|
||||
})
|
||||
|
||||
const nodeCountB = await comfyPage.nodeOps.getNodeCount()
|
||||
expect(nodeCountB).toBe(1)
|
||||
expect(nodeCountA).not.toBe(nodeCountB)
|
||||
|
||||
// Switch to A via topbar tab (making unsaved B inactive)
|
||||
await comfyPage.menu.topbar.getWorkflowTab(nameA).click()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBe(nodeCountA)
|
||||
|
||||
// Close inactive unsaved B tab — triggers "Save before closing?"
|
||||
await comfyPage.menu.topbar
|
||||
.getWorkflowTab('Unsaved Workflow')
|
||||
.click({ button: 'middle' })
|
||||
|
||||
// Click "Save" in the dirty close dialog (scoped to dialog)
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await saveButton.waitFor({ state: 'visible' })
|
||||
await saveButton.click()
|
||||
|
||||
// Fill in the filename dialog
|
||||
const saveDialog = comfyPage.menu.topbar.getSaveDialog()
|
||||
await saveDialog.waitFor({ state: 'visible' })
|
||||
await saveDialog.fill(nameB)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify we're still on A with A's content
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBe(nodeCountA)
|
||||
|
||||
// Re-open B from sidebar saved list
|
||||
const workflowsTab = comfyPage.menu.workflowsTab
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(nameB).dblclick()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
|
||||
// B should have 1 node (the Note), not A's node count
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 })
|
||||
.toBe(nodeCountB)
|
||||
})
|
||||
|
||||
test('Splitter panel sizes persist correctly in localStorage', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -363,7 +363,7 @@ Test your feature flags with different combinations:
|
||||
### Example Test
|
||||
|
||||
```typescript
|
||||
// Example from a colocated unit test
|
||||
// In tests-ui/tests/api.featureFlags.test.ts
|
||||
it('should handle preview metadata based on feature flag', () => {
|
||||
// Mock server supports feature
|
||||
api.serverFeatureFlags = { supports_preview_metadata: true }
|
||||
|
||||
@@ -25,7 +25,7 @@ ComfyUI's extension system follows these key principles:
|
||||
|
||||
## Core Extensions List
|
||||
|
||||
The following table lists ALL core extensions in the system as of 2025-01-30:
|
||||
The following table lists the main core extensions in the system. Additional extensions may exist for specialized features like cloud integration, badges, and advanced UI features. See `src/extensions/core/` for the complete list.
|
||||
|
||||
### Main Extensions
|
||||
|
||||
|
||||
@@ -2,22 +2,70 @@
|
||||
|
||||
This guide covers patterns and examples for testing Vue components in the ComfyUI Frontend codebase.
|
||||
|
||||
## Testing Libraries
|
||||
|
||||
This project supports two component testing approaches:
|
||||
|
||||
- **@testing-library/vue** (Recommended) - User-centric, behavior-focused testing with semantic queries
|
||||
- **@vue/test-utils** - Lower-level component API testing
|
||||
|
||||
Both are acceptable, but Testing Library is preferred for new tests as it encourages better testing practices.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Basic Component Testing](#basic-component-testing)
|
||||
2. [PrimeVue Components Testing](#primevue-components-testing)
|
||||
3. [Tooltip Directives](#tooltip-directives)
|
||||
4. [Component Events Testing](#component-events-testing)
|
||||
5. [User Interaction Testing](#user-interaction-testing)
|
||||
6. [Asynchronous Component Testing](#asynchronous-component-testing)
|
||||
7. [Working with Vue Reactivity](#working-with-vue-reactivity)
|
||||
1. [Basic Component Testing with Testing Library](#basic-component-testing-with-testing-library)
|
||||
2. [Basic Component Testing with Vue Test Utils](#basic-component-testing-with-vue-test-utils)
|
||||
3. [PrimeVue Components Testing](#primevue-components-testing)
|
||||
4. [Tooltip Directives](#tooltip-directives)
|
||||
5. [Component Events Testing](#component-events-testing)
|
||||
6. [User Interaction Testing](#user-interaction-testing)
|
||||
7. [Asynchronous Component Testing](#asynchronous-component-testing)
|
||||
8. [Working with Vue Reactivity](#working-with-vue-reactivity)
|
||||
|
||||
## Basic Component Testing
|
||||
## Basic Component Testing with Testing Library
|
||||
|
||||
Basic approach to testing a component's rendering and structure:
|
||||
User-centric approach using @testing-library/vue (recommended):
|
||||
|
||||
```typescript
|
||||
// Example from: src/components/sidebar/SidebarIcon.spec.ts
|
||||
// Example from: src/components/sidebar/SidebarIcon.test.ts
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
describe('SidebarIcon', () => {
|
||||
const exampleProps = {
|
||||
icon: 'pi pi-cog',
|
||||
selected: false,
|
||||
tooltip: 'Settings'
|
||||
}
|
||||
|
||||
it('renders as a button with accessible label', () => {
|
||||
render(SidebarIcon, { props: exampleProps })
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Settings' })
|
||||
expect(button).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles click interactions', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClick = vi.fn()
|
||||
|
||||
render(SidebarIcon, {
|
||||
props: { ...exampleProps, onClick }
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
expect(onClick).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Basic Component Testing with Vue Test Utils
|
||||
|
||||
Lower-level API testing approach:
|
||||
|
||||
```typescript
|
||||
// Alternative approach using @vue/test-utils
|
||||
import { mount } from '@vue/test-utils'
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ This guide covers patterns and examples for testing Pinia stores in the ComfyUI
|
||||
Basic setup for testing Pinia stores:
|
||||
|
||||
```typescript
|
||||
// Example from a colocated store unit test
|
||||
// Example from: tests-ui/tests/store/workflowStore.test.ts
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -51,7 +51,7 @@ describe('useWorkflowStore', () => {
|
||||
Testing store state changes:
|
||||
|
||||
```typescript
|
||||
// Example from a colocated store unit test
|
||||
// Example from: tests-ui/tests/store/workflowStore.test.ts
|
||||
it('should create a temporary workflow with a unique path', () => {
|
||||
const workflow = store.createTemporary()
|
||||
expect(workflow.path).toBe('workflows/Unsaved Workflow.json')
|
||||
@@ -72,7 +72,7 @@ it('should create a temporary workflow not clashing with persisted workflows', a
|
||||
Testing store actions:
|
||||
|
||||
```typescript
|
||||
// Example from a colocated store unit test
|
||||
// Example from: tests-ui/tests/store/workflowStore.test.ts
|
||||
describe('openWorkflow', () => {
|
||||
it('should load and open a temporary workflow', async () => {
|
||||
// Create a test workflow
|
||||
@@ -115,7 +115,7 @@ describe('openWorkflow', () => {
|
||||
Testing store getters:
|
||||
|
||||
```typescript
|
||||
// Example from a colocated store unit test
|
||||
// Example from: tests-ui/tests/store/modelStore.test.ts
|
||||
describe('getters', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
@@ -162,7 +162,7 @@ describe('getters', () => {
|
||||
Mocking API and other dependencies:
|
||||
|
||||
```typescript
|
||||
// Example from a colocated store unit test
|
||||
// Example from: tests-ui/tests/store/workflowStore.test.ts
|
||||
// Add mock for api at the top of the file
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
@@ -205,7 +205,7 @@ describe('syncWorkflows', () => {
|
||||
Testing store watchers and reactive behavior:
|
||||
|
||||
```typescript
|
||||
// Example from a colocated store unit test
|
||||
// Example from: tests-ui/tests/store/workflowStore.test.ts
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
describe('Subgraphs', () => {
|
||||
@@ -253,7 +253,7 @@ describe('Subgraphs', () => {
|
||||
Testing store integration with other parts of the application:
|
||||
|
||||
```typescript
|
||||
// Example from a colocated store unit test
|
||||
// Example from: tests-ui/tests/store/workflowStore.test.ts
|
||||
describe('renameWorkflow', () => {
|
||||
it('should rename workflow and update bookmarks', async () => {
|
||||
const workflow = store.createTemporary('dir/test.json')
|
||||
|
||||
@@ -18,7 +18,7 @@ This guide covers patterns and examples for unit testing utilities, composables,
|
||||
Testing Vue composables requires handling reactivity correctly:
|
||||
|
||||
```typescript
|
||||
// Example from a colocated composable unit test
|
||||
// Example from: tests-ui/tests/composables/useServerLogs.test.ts
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { useServerLogs } from '@/composables/useServerLogs'
|
||||
@@ -59,7 +59,7 @@ describe('useServerLogs', () => {
|
||||
Testing LiteGraph-related functionality:
|
||||
|
||||
```typescript
|
||||
// Example from a colocated LiteGraph unit test
|
||||
// Example from: tests-ui/tests/litegraph.test.ts
|
||||
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('LGraph', () => {
|
||||
Testing with ComfyUI workflow files:
|
||||
|
||||
```typescript
|
||||
// Example from a colocated workflow unit test
|
||||
// Example from: tests-ui/tests/comfyWorkflow.test.ts
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { defaultGraph } from '@/scripts/defaultGraph'
|
||||
@@ -125,7 +125,7 @@ describe('workflow validation', () => {
|
||||
Mocking the ComfyUI API object:
|
||||
|
||||
```typescript
|
||||
// Example from a colocated composable unit test
|
||||
// Example from: tests-ui/tests/composables/useServerLogs.test.ts
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
@@ -147,9 +147,9 @@ it('should subscribe to logs API', () => {
|
||||
})
|
||||
```
|
||||
|
||||
## Mocking Lodash Functions
|
||||
## Mocking Utility Functions
|
||||
|
||||
Mocking utility functions like debounce:
|
||||
Mocking utility functions like debounce from es-toolkit:
|
||||
|
||||
```typescript
|
||||
// Mock debounce to execute immediately
|
||||
@@ -183,7 +183,7 @@ describe('Function using debounce', () => {
|
||||
When you need to test real debounce/throttle behavior:
|
||||
|
||||
```typescript
|
||||
// Example from a colocated composable unit test
|
||||
// Example from: tests-ui/tests/composables/useWorkflowAutoSave.test.ts
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('debounced function', () => {
|
||||
@@ -223,7 +223,7 @@ describe('debounced function', () => {
|
||||
Creating mock node definitions for testing:
|
||||
|
||||
```typescript
|
||||
// Example from a colocated schema unit test
|
||||
// Example from: tests-ui/tests/apiTypes.test.ts
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
type ComfyNodeDef,
|
||||
|
||||
@@ -230,6 +230,15 @@ export default defineConfig([
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['tests-ui/**/*'],
|
||||
rules: {
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{ disallowTypeAnnotations: false }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.spec.ts'],
|
||||
ignores: ['browser_tests/tests/**/*.spec.ts'],
|
||||
|
||||
@@ -143,16 +143,11 @@
|
||||
<Button
|
||||
v-if="shouldShowDeleteButton"
|
||||
size="icon"
|
||||
data-testid="assets-delete-selected"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
data-testid="assets-download-selected"
|
||||
@click="handleDownloadSelected"
|
||||
>
|
||||
<Button size="icon" @click="handleDownloadSelected">
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
@@ -161,17 +156,12 @@
|
||||
<Button
|
||||
v-if="shouldShowDeleteButton"
|
||||
variant="secondary"
|
||||
data-testid="assets-delete-selected"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
<span>{{ $t('mediaAsset.selection.deleteSelected') }}</span>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
data-testid="assets-download-selected"
|
||||
@click="handleDownloadSelected"
|
||||
>
|
||||
<Button variant="secondary" @click="handleDownloadSelected">
|
||||
<span>{{ $t('mediaAsset.selection.downloadSelected') }}</span>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
|
||||
@@ -4,14 +4,18 @@ Our project supports multiple languages using `vue-i18n`. This allows users arou
|
||||
|
||||
## Supported Languages
|
||||
|
||||
- ar (العربية)
|
||||
- en (English)
|
||||
- zh (中文)
|
||||
- ru (Русский)
|
||||
- es (Español)
|
||||
- fa (فارسی)
|
||||
- fr (Français)
|
||||
- ja (日本語)
|
||||
- ko (한국어)
|
||||
- fr (Français)
|
||||
- es (Español)
|
||||
- pt-BR (Português Brasileiro)
|
||||
- ru (Русский)
|
||||
- tr (Türkçe)
|
||||
- zh (中文)
|
||||
- zh-TW (繁體中文)
|
||||
|
||||
## How to Add a New Language
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ export const useWorkflowService = () => {
|
||||
}
|
||||
|
||||
if (isSelfOverwrite) {
|
||||
if (workflowStore.isActive(workflow)) workflow.changeTracker?.checkState()
|
||||
workflow.changeTracker?.checkState()
|
||||
await saveWorkflow(workflow)
|
||||
} else {
|
||||
let target: ComfyWorkflow
|
||||
@@ -156,7 +156,7 @@ export const useWorkflowService = () => {
|
||||
app.rootGraph.extra.linearMode = isApp
|
||||
target.initialMode = isApp ? 'app' : 'graph'
|
||||
}
|
||||
if (workflowStore.isActive(target)) target.changeTracker?.checkState()
|
||||
target.changeTracker?.checkState()
|
||||
|
||||
await workflowStore.saveWorkflow(target)
|
||||
}
|
||||
@@ -173,7 +173,7 @@ export const useWorkflowService = () => {
|
||||
if (workflow.isTemporary) {
|
||||
await saveWorkflowAs(workflow)
|
||||
} else {
|
||||
if (workflowStore.isActive(workflow)) workflow.changeTracker?.checkState()
|
||||
workflow.changeTracker?.checkState()
|
||||
|
||||
const isApp = workflow.initialMode === 'app'
|
||||
const expectedPath =
|
||||
|
||||
@@ -21,13 +21,13 @@
|
||||
"verbatimModuleSyntax": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@e2e/*": ["./browser_tests/*"],
|
||||
"@/utils/formatUtil": [
|
||||
"./packages/shared-frontend-utils/src/formatUtil.ts"
|
||||
],
|
||||
"@/utils/networkUtil": [
|
||||
"./packages/shared-frontend-utils/src/networkUtil.ts"
|
||||
]
|
||||
],
|
||||
"@tests-ui/*": ["./tests-ui/*"]
|
||||
},
|
||||
"typeRoots": ["src/types", "node_modules/@types", "./node_modules"],
|
||||
"types": [
|
||||
@@ -49,6 +49,8 @@
|
||||
"src/types/**/*.d.ts",
|
||||
"playwright.config.ts",
|
||||
"playwright.i18n.config.ts",
|
||||
|
||||
"tests-ui/**/*",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts"
|
||||
// "vitest.setup.ts",
|
||||
|
||||
@@ -161,6 +161,7 @@ export default defineConfig({
|
||||
ignored: [
|
||||
'./browser_tests/**',
|
||||
'./node_modules/**',
|
||||
'./tests-ui/**',
|
||||
'.eslintcache',
|
||||
'.oxlintrc.json',
|
||||
'*.config.{ts,mts}',
|
||||
|
||||
Reference in New Issue
Block a user