mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-01 01:59:08 +00:00
Compare commits
5 Commits
dev/remote
...
test/image
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42f5762fe1 | ||
|
|
86a3938d11 | ||
|
|
e11a1776ed | ||
|
|
161522b138 | ||
|
|
61144ea1d5 |
@@ -119,7 +119,7 @@ When writing new tests, follow these patterns:
|
||||
|
||||
```typescript
|
||||
// Import the test fixture
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Feature Name', () => {
|
||||
// Set up test environment if needed
|
||||
@@ -148,6 +148,12 @@ 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**:
|
||||
|
||||
@@ -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 '../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 { 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 {
|
||||
AssetsSidebarTab,
|
||||
NodeLibrarySidebarTab,
|
||||
WorkflowsSidebarTab
|
||||
} 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'
|
||||
} 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'
|
||||
import type { WorkspaceStore } from '../types/globals'
|
||||
|
||||
dotenvConfig()
|
||||
|
||||
@@ -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 '../fixtures/ComfyPage'
|
||||
import { webSocketFixture } from '../fixtures/ws'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
import type { WorkspaceStore } from '../types/globals'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Image Compare', () => {
|
||||
test.describe('Image Compare', { tag: '@widget' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('widgets/image_compare_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.resetView()
|
||||
})
|
||||
|
||||
function createTestImageDataUrl(label: string, color: string): string {
|
||||
const svg =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">` +
|
||||
@@ -37,6 +44,23 @@ test.describe('Image Compare', () => {
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async function moveToPercentage(
|
||||
page: Page,
|
||||
containerLocator: Locator,
|
||||
percentage: number
|
||||
) {
|
||||
const box = await containerLocator.boundingBox()
|
||||
if (!box) throw new Error('Container not found')
|
||||
await page.mouse.move(
|
||||
box.x + box.width * (percentage / 100),
|
||||
box.y + box.height / 2
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test(
|
||||
'Shows empty state when no images are set',
|
||||
{ tag: '@smoke' },
|
||||
@@ -50,6 +74,10 @@ test.describe('Image Compare', () => {
|
||||
}
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slider defaults
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test(
|
||||
'Slider defaults to 50% with both images set',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
@@ -78,4 +106,401 @@ test.describe('Image Compare', () => {
|
||||
await expect(node).toHaveScreenshot('image-compare-default-50.png')
|
||||
}
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slider interaction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test(
|
||||
'Mouse hover moves slider position',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const beforeUrl = createTestImageDataUrl('Before', '#c00')
|
||||
const afterUrl = createTestImageDataUrl('After', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [beforeUrl],
|
||||
afterImages: [afterUrl]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const handle = node.locator('[role="presentation"]')
|
||||
const beforeImg = node.locator('img[alt="Before image"]')
|
||||
const afterImg = node.locator('img[alt="After image"]')
|
||||
await expect(afterImg).toBeVisible()
|
||||
|
||||
// Left edge: sliderPosition ≈ 5 → clip-path inset right ≈ 95%
|
||||
await moveToPercentage(comfyPage.page, afterImg, 5)
|
||||
await expect(beforeImg).toHaveCSS('clip-path', /9\d%/)
|
||||
await expect
|
||||
.poll(() =>
|
||||
handle.evaluate((el) => parseFloat((el as HTMLElement).style.left))
|
||||
)
|
||||
.toBeLessThan(10)
|
||||
|
||||
// Right edge: sliderPosition ≈ 95 → clip-path inset right ≈ 5%
|
||||
await moveToPercentage(comfyPage.page, afterImg, 95)
|
||||
await expect(beforeImg).toHaveCSS('clip-path', /inset\(0 [0-9]+%/)
|
||||
await expect
|
||||
.poll(() =>
|
||||
handle.evaluate((el) => parseFloat((el as HTMLElement).style.left))
|
||||
)
|
||||
.toBeGreaterThan(90)
|
||||
}
|
||||
)
|
||||
|
||||
test('Slider preserves last position when mouse leaves widget', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const beforeUrl = createTestImageDataUrl('Before', '#c00')
|
||||
const afterUrl = createTestImageDataUrl('After', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [beforeUrl],
|
||||
afterImages: [afterUrl]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const handle = node.locator('[role="presentation"]')
|
||||
const afterImg = node.locator('img[alt="After image"]')
|
||||
await expect(afterImg).toBeVisible()
|
||||
|
||||
await moveToPercentage(comfyPage.page, afterImg, 30)
|
||||
// Wait for Vue to commit the slider update
|
||||
await expect
|
||||
.poll(() =>
|
||||
handle.evaluate((el) => parseFloat((el as HTMLElement).style.left))
|
||||
)
|
||||
.toBeCloseTo(30, 0)
|
||||
const positionWhileInside = parseFloat(
|
||||
await handle.evaluate((el) => (el as HTMLElement).style.left)
|
||||
)
|
||||
|
||||
await comfyPage.page.mouse.move(0, 0)
|
||||
|
||||
// Position must not reset to default 50%
|
||||
const positionAfterLeave = parseFloat(
|
||||
await handle.evaluate((el) => (el as HTMLElement).style.left)
|
||||
)
|
||||
expect(positionAfterLeave).toBeCloseTo(positionWhileInside, 0)
|
||||
})
|
||||
|
||||
test('Slider clamps to 0% at left edge of container', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const beforeUrl = createTestImageDataUrl('Before', '#c00')
|
||||
const afterUrl = createTestImageDataUrl('After', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [beforeUrl],
|
||||
afterImages: [afterUrl]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const handle = node.locator('[role="presentation"]')
|
||||
const afterImg = node.locator('img[alt="After image"]')
|
||||
await expect(afterImg).toBeVisible()
|
||||
|
||||
const box = await afterImg.boundingBox()
|
||||
if (!box) throw new Error('Container not found')
|
||||
|
||||
// Move to the leftmost pixel (elementX = 0 → sliderPosition = 0)
|
||||
await comfyPage.page.mouse.move(box.x, box.y + box.height / 2)
|
||||
await expect
|
||||
.poll(() => handle.evaluate((el) => (el as HTMLElement).style.left))
|
||||
.toBe('0%')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single image modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('Only before image shows without slider when afterImages is empty', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const url = createTestImageDataUrl('Before', '#c00')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [url],
|
||||
afterImages: []
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node.locator('img')).toHaveCount(1)
|
||||
await expect(node.locator('[role="presentation"]')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Only after image shows without slider when beforeImages is empty', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const url = createTestImageDataUrl('After', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [],
|
||||
afterImages: [url]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node.locator('img')).toHaveCount(1)
|
||||
await expect(node.locator('[role="presentation"]')).not.toBeVisible()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test(
|
||||
'Batch navigation appears when before side has multiple images',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const url1 = createTestImageDataUrl('A1', '#c00')
|
||||
const url2 = createTestImageDataUrl('A2', '#0c0')
|
||||
const url3 = createTestImageDataUrl('A3', '#00c')
|
||||
const afterUrl = createTestImageDataUrl('B1', '#888')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [url1, url2, url3],
|
||||
afterImages: [afterUrl]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeBatch = node.locator('[data-testid="before-batch"]')
|
||||
|
||||
await expect(node.locator('[data-testid="batch-nav"]')).toBeVisible()
|
||||
await expect(
|
||||
beforeBatch.locator('[data-testid="batch-counter"]')
|
||||
).toHaveText('1 / 3')
|
||||
// after-batch renders only when afterBatchCount > 1
|
||||
await expect(
|
||||
node.locator('[data-testid="after-batch"]')
|
||||
).not.toBeVisible()
|
||||
await expect(
|
||||
beforeBatch.locator('[data-testid="batch-prev"]')
|
||||
).toBeDisabled()
|
||||
}
|
||||
)
|
||||
|
||||
test('Batch navigation is hidden when both sides have single images', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const url = createTestImageDataUrl('Image', '#c00')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [url],
|
||||
afterImages: [url]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node.locator('[data-testid="batch-nav"]')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test(
|
||||
'Navigate forward through before images',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const url1 = createTestImageDataUrl('A1', '#c00')
|
||||
const url2 = createTestImageDataUrl('A2', '#0c0')
|
||||
const url3 = createTestImageDataUrl('A3', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [url1, url2, url3],
|
||||
afterImages: [createTestImageDataUrl('B1', '#888')]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeBatch = node.locator('[data-testid="before-batch"]')
|
||||
const counter = beforeBatch.locator('[data-testid="batch-counter"]')
|
||||
const nextBtn = beforeBatch.locator('[data-testid="batch-next"]')
|
||||
const prevBtn = beforeBatch.locator('[data-testid="batch-prev"]')
|
||||
|
||||
await nextBtn.click()
|
||||
await expect(counter).toHaveText('2 / 3')
|
||||
await expect(node.locator('img[alt="Before image"]')).toHaveAttribute(
|
||||
'src',
|
||||
url2
|
||||
)
|
||||
await expect(prevBtn).toBeEnabled()
|
||||
|
||||
await nextBtn.click()
|
||||
await expect(counter).toHaveText('3 / 3')
|
||||
await expect(nextBtn).toBeDisabled()
|
||||
}
|
||||
)
|
||||
|
||||
test('Navigate backward through before images', async ({ comfyPage }) => {
|
||||
const url1 = createTestImageDataUrl('A1', '#c00')
|
||||
const url2 = createTestImageDataUrl('A2', '#0c0')
|
||||
const url3 = createTestImageDataUrl('A3', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [url1, url2, url3],
|
||||
afterImages: [createTestImageDataUrl('B1', '#888')]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeBatch = node.locator('[data-testid="before-batch"]')
|
||||
const counter = beforeBatch.locator('[data-testid="batch-counter"]')
|
||||
const nextBtn = beforeBatch.locator('[data-testid="batch-next"]')
|
||||
const prevBtn = beforeBatch.locator('[data-testid="batch-prev"]')
|
||||
|
||||
await nextBtn.click()
|
||||
await nextBtn.click()
|
||||
await expect(counter).toHaveText('3 / 3')
|
||||
|
||||
await prevBtn.click()
|
||||
await expect(counter).toHaveText('2 / 3')
|
||||
await expect(prevBtn).toBeEnabled()
|
||||
await expect(nextBtn).toBeEnabled()
|
||||
})
|
||||
|
||||
test('Before and after batch navigation are independent', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const url1 = createTestImageDataUrl('A1', '#c00')
|
||||
const url2 = createTestImageDataUrl('A2', '#0c0')
|
||||
const url3 = createTestImageDataUrl('A3', '#00c')
|
||||
const urlA = createTestImageDataUrl('B1', '#880')
|
||||
const urlB = createTestImageDataUrl('B2', '#008')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [url1, url2, url3],
|
||||
afterImages: [urlA, urlB]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeBatch = node.locator('[data-testid="before-batch"]')
|
||||
const afterBatch = node.locator('[data-testid="after-batch"]')
|
||||
|
||||
await beforeBatch.locator('[data-testid="batch-next"]').click()
|
||||
await afterBatch.locator('[data-testid="batch-next"]').click()
|
||||
|
||||
await expect(
|
||||
beforeBatch.locator('[data-testid="batch-counter"]')
|
||||
).toHaveText('2 / 3')
|
||||
await expect(
|
||||
afterBatch.locator('[data-testid="batch-counter"]')
|
||||
).toHaveText('2 / 2')
|
||||
await expect(node.locator('img[alt="Before image"]')).toHaveAttribute(
|
||||
'src',
|
||||
url2
|
||||
)
|
||||
await expect(node.locator('img[alt="After image"]')).toHaveAttribute(
|
||||
'src',
|
||||
urlB
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Visual regression screenshots
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test(
|
||||
'Screenshot at 25% slider position',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const beforeUrl = createTestImageDataUrl('Before', '#c00')
|
||||
const afterUrl = createTestImageDataUrl('After', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [beforeUrl],
|
||||
afterImages: [afterUrl]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const afterImg = node.locator('img[alt="After image"]')
|
||||
await expect(afterImg).toBeVisible()
|
||||
await moveToPercentage(comfyPage.page, afterImg, 25)
|
||||
await expect(node.locator('img[alt="Before image"]')).toHaveCSS(
|
||||
'clip-path',
|
||||
/7\d%/
|
||||
)
|
||||
|
||||
await expect(node).toHaveScreenshot('image-compare-slider-25.png')
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Screenshot at 75% slider position',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const beforeUrl = createTestImageDataUrl('Before', '#c00')
|
||||
const afterUrl = createTestImageDataUrl('After', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [beforeUrl],
|
||||
afterImages: [afterUrl]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const afterImg = node.locator('img[alt="After image"]')
|
||||
await expect(afterImg).toBeVisible()
|
||||
await moveToPercentage(comfyPage.page, afterImg, 75)
|
||||
await expect(node.locator('img[alt="Before image"]')).toHaveCSS(
|
||||
'clip-path',
|
||||
/2\d%/
|
||||
)
|
||||
|
||||
await expect(node).toHaveScreenshot('image-compare-slider-75.png')
|
||||
}
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('Widget remains stable with broken image URLs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const errors: string[] = []
|
||||
comfyPage.page.on('pageerror', (err) => errors.push(err.message))
|
||||
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: ['http://invalid.local/broken.png'],
|
||||
afterImages: ['http://invalid.local/broken2.png']
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node.locator('img')).toHaveCount(2)
|
||||
await expect(node.locator('[role="presentation"]')).toBeVisible()
|
||||
expect(errors).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('Rapid value updates show latest images and reset batch index', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const redUrl = createTestImageDataUrl('Red', '#c00')
|
||||
const green1Url = createTestImageDataUrl('G1', '#0c0')
|
||||
const green2Url = createTestImageDataUrl('G2', '#090')
|
||||
const blueUrl = createTestImageDataUrl('Blue', '#00c')
|
||||
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [redUrl],
|
||||
afterImages: [blueUrl]
|
||||
})
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [green1Url, green2Url],
|
||||
afterImages: [blueUrl]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node.locator('img[alt="Before image"]')).toHaveAttribute(
|
||||
'src',
|
||||
green1Url
|
||||
)
|
||||
await expect(
|
||||
node
|
||||
.locator('[data-testid="before-batch"]')
|
||||
.locator('[data-testid="batch-counter"]')
|
||||
).toHaveText('1 / 2')
|
||||
})
|
||||
|
||||
test('Large batch sizes show correct counter', async ({ comfyPage }) => {
|
||||
const images = Array.from({ length: 20 }, (_, i) =>
|
||||
createTestImageDataUrl(String(i + 1), '#c00')
|
||||
)
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: images,
|
||||
afterImages: images
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(
|
||||
node
|
||||
.locator('[data-testid="before-batch"]')
|
||||
.locator('[data-testid="batch-counter"]')
|
||||
).toHaveText('1 / 20')
|
||||
await expect(
|
||||
node
|
||||
.locator('[data-testid="after-batch"]')
|
||||
.locator('[data-testid="batch-counter"]')
|
||||
).toHaveText('1 / 20')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
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,6 +323,174 @@ 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
|
||||
// In tests-ui/tests/api.featureFlags.test.ts
|
||||
// Example from a colocated unit test
|
||||
it('should handle preview metadata based on feature flag', () => {
|
||||
// Mock server supports feature
|
||||
api.serverFeatureFlags = { supports_preview_metadata: true }
|
||||
|
||||
@@ -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: tests-ui/tests/store/workflowStore.test.ts
|
||||
// Example from a colocated store unit test
|
||||
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: tests-ui/tests/store/workflowStore.test.ts
|
||||
// Example from a colocated store unit test
|
||||
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: tests-ui/tests/store/workflowStore.test.ts
|
||||
// Example from a colocated store unit test
|
||||
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: tests-ui/tests/store/modelStore.test.ts
|
||||
// Example from a colocated store unit test
|
||||
describe('getters', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
@@ -162,7 +162,7 @@ describe('getters', () => {
|
||||
Mocking API and other dependencies:
|
||||
|
||||
```typescript
|
||||
// Example from: tests-ui/tests/store/workflowStore.test.ts
|
||||
// Example from a colocated store unit test
|
||||
// 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: tests-ui/tests/store/workflowStore.test.ts
|
||||
// Example from a colocated store unit test
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
describe('Subgraphs', () => {
|
||||
@@ -253,7 +253,7 @@ describe('Subgraphs', () => {
|
||||
Testing store integration with other parts of the application:
|
||||
|
||||
```typescript
|
||||
// Example from: tests-ui/tests/store/workflowStore.test.ts
|
||||
// Example from a colocated store unit test
|
||||
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: tests-ui/tests/composables/useServerLogs.test.ts
|
||||
// Example from a colocated composable unit test
|
||||
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: tests-ui/tests/litegraph.test.ts
|
||||
// Example from a colocated LiteGraph unit test
|
||||
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: tests-ui/tests/comfyWorkflow.test.ts
|
||||
// Example from a colocated workflow unit test
|
||||
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: tests-ui/tests/composables/useServerLogs.test.ts
|
||||
// Example from a colocated composable unit test
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
@@ -183,7 +183,7 @@ describe('Function using debounce', () => {
|
||||
When you need to test real debounce/throttle behavior:
|
||||
|
||||
```typescript
|
||||
// Example from: tests-ui/tests/composables/useWorkflowAutoSave.test.ts
|
||||
// Example from a colocated composable unit test
|
||||
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: tests-ui/tests/apiTypes.test.ts
|
||||
// Example from a colocated schema unit test
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
type ComfyNodeDef,
|
||||
|
||||
@@ -230,15 +230,6 @@ export default defineConfig([
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['tests-ui/**/*'],
|
||||
rules: {
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{ disallowTypeAnnotations: false }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.spec.ts'],
|
||||
ignores: ['browser_tests/tests/**/*.spec.ts'],
|
||||
|
||||
@@ -139,7 +139,7 @@ export const useWorkflowService = () => {
|
||||
}
|
||||
|
||||
if (isSelfOverwrite) {
|
||||
workflow.changeTracker?.checkState()
|
||||
if (workflowStore.isActive(workflow)) 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'
|
||||
}
|
||||
target.changeTracker?.checkState()
|
||||
if (workflowStore.isActive(target)) target.changeTracker?.checkState()
|
||||
|
||||
await workflowStore.saveWorkflow(target)
|
||||
}
|
||||
@@ -173,7 +173,7 @@ export const useWorkflowService = () => {
|
||||
if (workflow.isTemporary) {
|
||||
await saveWorkflowAs(workflow)
|
||||
} else {
|
||||
workflow.changeTracker?.checkState()
|
||||
if (workflowStore.isActive(workflow)) workflow.changeTracker?.checkState()
|
||||
|
||||
const isApp = workflow.initialMode === 'app'
|
||||
const expectedPath =
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, provide, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { FilterOption } from '@/platform/assets/types/filterTypes'
|
||||
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import FormDropdown from './form/dropdown/FormDropdown.vue'
|
||||
import type { FormDropdownItem, LayoutMode } from './form/dropdown/types'
|
||||
import { AssetKindKey } from './form/dropdown/types'
|
||||
import {
|
||||
buildSearchText,
|
||||
extractFilterValues,
|
||||
getByPath,
|
||||
mapToDropdownItem
|
||||
} from '../utils/resolveItemSchema'
|
||||
import { fetchRemoteRoute } from '../utils/resolveRemoteRoute'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: string
|
||||
widget: SimplifiedWidget<string | undefined>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const comboSpec = computed(() => {
|
||||
if (props.widget.spec && isComboInputSpec(props.widget.spec)) {
|
||||
return props.widget.spec
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
const remoteConfig = computed(() => comboSpec.value?.remote!)
|
||||
const itemSchema = computed(() => remoteConfig.value?.item_schema!)
|
||||
|
||||
const rawItems = ref<unknown[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchItems() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchRemoteRoute(remoteConfig.value.route, {
|
||||
params: remoteConfig.value.query_params,
|
||||
timeout: remoteConfig.value.timeout ?? 30000
|
||||
})
|
||||
const data = remoteConfig.value.response_key
|
||||
? res.data[remoteConfig.value.response_key]
|
||||
: res.data
|
||||
rawItems.value = Array.isArray(data) ? data : []
|
||||
} catch (err) {
|
||||
console.error('RichComboWidget: fetch error', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void fetchItems()
|
||||
})
|
||||
|
||||
const assetKind = computed(() => {
|
||||
const pt = itemSchema.value.preview_type ?? 'image'
|
||||
return pt as 'image' | 'video' | 'audio'
|
||||
})
|
||||
|
||||
provide(AssetKindKey, assetKind)
|
||||
|
||||
const items = computed<FormDropdownItem[]>(() =>
|
||||
rawItems.value.map((raw) => mapToDropdownItem(raw, itemSchema.value))
|
||||
)
|
||||
|
||||
const searchIndex = computed(() => {
|
||||
const schema = itemSchema.value
|
||||
const fields = schema.search_fields ?? [schema.label_field]
|
||||
const index = new Map<string, string>()
|
||||
for (const raw of rawItems.value) {
|
||||
const id = String(getByPath(raw, schema.value_field) ?? '')
|
||||
index.set(id, buildSearchText(raw, fields))
|
||||
}
|
||||
return index
|
||||
})
|
||||
|
||||
const filterOptions = computed<FilterOption[]>(() => {
|
||||
const schema = itemSchema.value
|
||||
if (!schema.filter_field) return []
|
||||
const values = extractFilterValues(rawItems.value, schema.filter_field)
|
||||
return [
|
||||
{ name: 'All', value: 'all' },
|
||||
...values.map((v) => ({ name: v, value: v }))
|
||||
]
|
||||
})
|
||||
|
||||
const filterSelected = ref('all')
|
||||
const layoutMode = ref<LayoutMode>('list')
|
||||
const selectedSet = ref<Set<string>>(new Set())
|
||||
|
||||
const filteredItems = computed<FormDropdownItem[]>(() => {
|
||||
const schema = itemSchema.value
|
||||
if (filterSelected.value === 'all' || !schema.filter_field) {
|
||||
return items.value
|
||||
}
|
||||
const filterField = schema.filter_field
|
||||
return rawItems.value
|
||||
.filter(
|
||||
(raw) =>
|
||||
String(getByPath(raw, filterField) ?? '') === filterSelected.value
|
||||
)
|
||||
.map((raw) => mapToDropdownItem(raw, schema))
|
||||
})
|
||||
|
||||
async function searcher(
|
||||
query: string,
|
||||
searchItems: FormDropdownItem[],
|
||||
_onCleanup: (cleanupFn: () => void) => void
|
||||
): Promise<FormDropdownItem[]> {
|
||||
if (!query.trim()) return searchItems
|
||||
const q = query.toLowerCase()
|
||||
return searchItems.filter((item) => {
|
||||
const text = searchIndex.value.get(item.id) ?? item.name.toLowerCase()
|
||||
return text.includes(q)
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => props.modelValue, items],
|
||||
([val]) => {
|
||||
selectedSet.value.clear()
|
||||
if (val) {
|
||||
const item = items.value.find((i) => i.id === val)
|
||||
if (item) selectedSet.value.add(item.id)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function handleRefresh() {
|
||||
void fetchItems()
|
||||
}
|
||||
|
||||
function handleSelection(selected: Set<string>) {
|
||||
const id = selected.values().next().value
|
||||
if (id) {
|
||||
emit('update:modelValue', id)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full items-center gap-1">
|
||||
<FormDropdown
|
||||
v-model:selected="selectedSet"
|
||||
v-model:filter-selected="filterSelected"
|
||||
v-model:layout-mode="layoutMode"
|
||||
:items="filteredItems"
|
||||
:placeholder="loading ? 'Loading...' : t('widgets.uploadSelect.placeholder')"
|
||||
:multiple="false"
|
||||
:filter-options="[]"
|
||||
:show-sort="false"
|
||||
:show-layout-switcher="false"
|
||||
:searcher="searcher"
|
||||
class="flex-1"
|
||||
@update:selected="handleSelection"
|
||||
/>
|
||||
<button
|
||||
v-if="remoteConfig?.refresh_button !== false"
|
||||
class="flex size-7 shrink-0 items-center justify-center rounded text-secondary hover:bg-component-node-widget-background-hovered"
|
||||
title="Refresh"
|
||||
@pointerdown.stop
|
||||
@click.stop="handleRefresh"
|
||||
>
|
||||
<i
|
||||
:class="[
|
||||
'icon-[lucide--refresh-cw] size-3.5',
|
||||
loading && 'animate-spin'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,11 +1,6 @@
|
||||
<template>
|
||||
<RichComboWidget
|
||||
v-if="hasItemSchema"
|
||||
v-model="modelValue"
|
||||
:widget
|
||||
/>
|
||||
<WidgetSelectDropdown
|
||||
v-else-if="isDropdownUIWidget"
|
||||
v-if="isDropdownUIWidget"
|
||||
v-model="modelValue"
|
||||
:widget
|
||||
:node-type="widget.nodeType ?? nodeType"
|
||||
@@ -29,7 +24,6 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import RichComboWidget from '@/renderer/extensions/vueNodes/widgets/components/RichComboWidget.vue'
|
||||
import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue'
|
||||
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
|
||||
import WidgetWithControl from '@/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue'
|
||||
@@ -59,10 +53,6 @@ const comboSpec = computed<ComboInputSpec | undefined>(() => {
|
||||
return undefined
|
||||
})
|
||||
|
||||
const hasItemSchema = computed(
|
||||
() => !!comboSpec.value?.remote?.item_schema
|
||||
)
|
||||
|
||||
const specDescriptor = computed<{
|
||||
kind: AssetKind
|
||||
allowUpload: boolean
|
||||
|
||||
@@ -34,8 +34,6 @@ interface Props {
|
||||
accept?: string
|
||||
filterOptions?: FilterOption[]
|
||||
sortOptions?: SortOption[]
|
||||
showSort?: boolean
|
||||
showLayoutSwitcher?: boolean
|
||||
showOwnershipFilter?: boolean
|
||||
ownershipOptions?: OwnershipFilterOption[]
|
||||
showBaseModelFilter?: boolean
|
||||
@@ -63,8 +61,6 @@ const {
|
||||
accept,
|
||||
filterOptions = [],
|
||||
sortOptions = getDefaultSortOptions(),
|
||||
showSort = true,
|
||||
showLayoutSwitcher = true,
|
||||
showOwnershipFilter,
|
||||
ownershipOptions,
|
||||
showBaseModelFilter,
|
||||
@@ -236,8 +232,6 @@ function handleSelection(item: FormDropdownItem, index: number) {
|
||||
v-model:base-model-selected="baseModelSelected"
|
||||
:filter-options
|
||||
:sort-options
|
||||
:show-sort
|
||||
:show-layout-switcher="showLayoutSwitcher"
|
||||
:show-ownership-filter
|
||||
:ownership-options
|
||||
:show-base-model-filter
|
||||
|
||||
@@ -20,8 +20,6 @@ interface Props {
|
||||
isSelected: (item: FormDropdownItem, index: number) => boolean
|
||||
filterOptions: FilterOption[]
|
||||
sortOptions: SortOption[]
|
||||
showSort?: boolean
|
||||
showLayoutSwitcher?: boolean
|
||||
showOwnershipFilter?: boolean
|
||||
ownershipOptions?: OwnershipFilterOption[]
|
||||
showBaseModelFilter?: boolean
|
||||
@@ -33,8 +31,6 @@ const {
|
||||
isSelected,
|
||||
filterOptions,
|
||||
sortOptions,
|
||||
showSort = true,
|
||||
showLayoutSwitcher = true,
|
||||
showOwnershipFilter,
|
||||
ownershipOptions,
|
||||
showBaseModelFilter,
|
||||
@@ -116,8 +112,6 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
|
||||
v-model:ownership-selected="ownershipSelected"
|
||||
v-model:base-model-selected="baseModelSelected"
|
||||
:sort-options
|
||||
:show-sort
|
||||
:show-layout-switcher="showLayoutSwitcher"
|
||||
:show-ownership-filter
|
||||
:ownership-options
|
||||
:show-base-model-filter
|
||||
@@ -151,7 +145,6 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
|
||||
:preview-url="item.preview_url ?? ''"
|
||||
:name="item.name"
|
||||
:label="item.label"
|
||||
:description="item.description"
|
||||
:layout="layoutMode"
|
||||
@click="emit('item-click', item, index)"
|
||||
/>
|
||||
|
||||
@@ -18,13 +18,8 @@ import type { LayoutMode, SortOption } from './types'
|
||||
const { t } = useI18n()
|
||||
const overlayProps = useTransformCompatOverlayProps()
|
||||
|
||||
const {
|
||||
showSort = true,
|
||||
showLayoutSwitcher = true
|
||||
} = defineProps<{
|
||||
defineProps<{
|
||||
sortOptions: SortOption[]
|
||||
showSort?: boolean
|
||||
showLayoutSwitcher?: boolean
|
||||
showOwnershipFilter?: boolean
|
||||
ownershipOptions?: OwnershipFilterOption[]
|
||||
showBaseModelFilter?: boolean
|
||||
@@ -119,7 +114,6 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="showSort"
|
||||
ref="sortTriggerRef"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
@@ -138,7 +132,6 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
<i class="icon-[lucide--arrow-up-down] size-4" />
|
||||
</Button>
|
||||
<Popover
|
||||
v-if="showSort"
|
||||
ref="sortPopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
@@ -316,7 +309,6 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
</Popover>
|
||||
|
||||
<div
|
||||
v-if="showLayoutSwitcher"
|
||||
:class="
|
||||
cn(
|
||||
actionButtonStyle,
|
||||
|
||||
@@ -12,7 +12,6 @@ interface Props {
|
||||
previewUrl: string
|
||||
name: string
|
||||
label?: string
|
||||
description?: string
|
||||
layout?: LayoutMode
|
||||
}
|
||||
|
||||
@@ -28,31 +27,11 @@ const actualDimensions = ref<string | null>(null)
|
||||
const assetKind = inject(AssetKindKey)
|
||||
|
||||
const isVideo = computed(() => assetKind?.value === 'video')
|
||||
const isAudio = computed(() => assetKind?.value === 'audio')
|
||||
|
||||
const audioRef = ref<HTMLAudioElement | null>(null)
|
||||
const isPlayingAudio = ref(false)
|
||||
|
||||
function handleClick() {
|
||||
emit('click', props.index)
|
||||
}
|
||||
|
||||
function toggleAudioPreview(event: Event) {
|
||||
event.stopPropagation()
|
||||
if (!audioRef.value) return
|
||||
if (isPlayingAudio.value) {
|
||||
audioRef.value.pause()
|
||||
isPlayingAudio.value = false
|
||||
} else {
|
||||
void audioRef.value.play()
|
||||
isPlayingAudio.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function handleAudioEnded() {
|
||||
isPlayingAudio.value = false
|
||||
}
|
||||
|
||||
function handleImageLoad(event: Event) {
|
||||
emit('mediaLoad', event)
|
||||
if (!event.target || !(event.target instanceof HTMLImageElement)) return
|
||||
@@ -128,25 +107,6 @@ function handleVideoLoad(event: Event) {
|
||||
muted
|
||||
@loadeddata="handleVideoLoad"
|
||||
/>
|
||||
<div
|
||||
v-else-if="previewUrl && isAudio"
|
||||
class="flex size-full cursor-pointer items-center justify-center bg-gradient-to-tr from-violet-500 via-purple-500 to-fuchsia-400"
|
||||
@click.stop="toggleAudioPreview"
|
||||
>
|
||||
<audio
|
||||
ref="audioRef"
|
||||
:src="previewUrl"
|
||||
preload="none"
|
||||
@ended="handleAudioEnded"
|
||||
/>
|
||||
<i
|
||||
:class="
|
||||
isPlayingAudio
|
||||
? 'icon-[lucide--pause] size-5 text-white'
|
||||
: 'icon-[lucide--play] size-5 text-white'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<img
|
||||
v-else-if="previewUrl"
|
||||
:src="previewUrl"
|
||||
@@ -184,13 +144,6 @@ function handleVideoLoad(event: Event) {
|
||||
>
|
||||
{{ label ?? name }}
|
||||
</span>
|
||||
<!-- Description -->
|
||||
<span
|
||||
v-if="description && layout !== 'grid'"
|
||||
class="text-secondary line-clamp-1 block overflow-hidden text-xs"
|
||||
>
|
||||
{{ description }}
|
||||
</span>
|
||||
<!-- Meta Data -->
|
||||
<span v-if="actualDimensions" class="text-secondary block text-xs">
|
||||
{{ actualDimensions }}
|
||||
|
||||
@@ -12,9 +12,7 @@ export interface FormDropdownItem {
|
||||
name: string
|
||||
/** Original/alternate label (e.g., original filename) */
|
||||
label?: string
|
||||
/** Short description shown below the name in list view */
|
||||
description?: string
|
||||
/** Preview image/video/audio URL */
|
||||
/** Preview image/video URL */
|
||||
preview_url?: string
|
||||
/** Whether the item is immutable (public model) - used for ownership filtering */
|
||||
is_immutable?: boolean
|
||||
|
||||
@@ -214,9 +214,7 @@ const addComboWidget = (
|
||||
}
|
||||
)
|
||||
|
||||
if (inputSpec.remote && !inputSpec.remote.item_schema) {
|
||||
// Skip useRemoteWidget when item_schema is present —
|
||||
// RichComboWidget handles its own data fetching and rendering.
|
||||
if (inputSpec.remote) {
|
||||
if (!isComboWidget(widget)) {
|
||||
throw new Error(`Expected combo widget but received ${widget.type}`)
|
||||
}
|
||||
|
||||
@@ -2,12 +2,10 @@ import axios from 'axios'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { IWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import {
|
||||
getRemoteAuthHeaders,
|
||||
resolveRoute
|
||||
} from '../utils/resolveRemoteRoute'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const MAX_RETRIES = 5
|
||||
const TIMEOUT = 4096
|
||||
@@ -23,6 +21,17 @@ interface CacheEntry<T> {
|
||||
failed?: boolean
|
||||
}
|
||||
|
||||
async function getAuthHeaders() {
|
||||
if (isCloud) {
|
||||
const authStore = useAuthStore()
|
||||
const authHeader = await authStore.getAuthHeader()
|
||||
return {
|
||||
...(authHeader && { headers: authHeader })
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
const dataCache = new Map<string, CacheEntry<unknown>>()
|
||||
|
||||
const createCacheKey = (config: RemoteWidgetConfig): string => {
|
||||
@@ -64,10 +73,9 @@ const fetchData = async (
|
||||
) => {
|
||||
const { route, response_key, query_params, timeout = TIMEOUT } = config
|
||||
|
||||
const url = resolveRoute(route)
|
||||
const authHeaders = await getRemoteAuthHeaders(route)
|
||||
const authHeaders = await getAuthHeaders()
|
||||
|
||||
const res = await axios.get(url, {
|
||||
const res = await axios.get(route, {
|
||||
params: query_params,
|
||||
signal: controller.signal,
|
||||
timeout,
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import type { RemoteItemSchema } from '@/schemas/nodeDefSchema'
|
||||
import type { FormDropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
|
||||
|
||||
/** Traverse an object by dot-path, treating numeric segments as array indices */
|
||||
export function getByPath(obj: unknown, path: string): unknown {
|
||||
return path.split('.').reduce((acc: unknown, key: string) => {
|
||||
if (acc == null) return undefined
|
||||
const idx = Number(key)
|
||||
if (Number.isInteger(idx) && idx >= 0 && Array.isArray(acc)) return acc[idx]
|
||||
return (acc as Record<string, unknown>)[key]
|
||||
}, obj)
|
||||
}
|
||||
|
||||
/** Resolve a label — either dot-path or template with {field.path} placeholders */
|
||||
export function resolveLabel(template: string, item: unknown): string {
|
||||
if (!template.includes('{')) {
|
||||
return String(getByPath(item, template) ?? '')
|
||||
}
|
||||
return template.replace(/\{([^}]+)\}/g, (_, path: string) =>
|
||||
String(getByPath(item, path) ?? '')
|
||||
)
|
||||
}
|
||||
|
||||
/** Map a raw API object to a FormDropdownItem using the item_schema */
|
||||
export function mapToDropdownItem(
|
||||
raw: unknown,
|
||||
schema: RemoteItemSchema
|
||||
): FormDropdownItem {
|
||||
return {
|
||||
id: String(getByPath(raw, schema.value_field) ?? ''),
|
||||
name: resolveLabel(schema.label_field, raw),
|
||||
description: schema.description_field
|
||||
? resolveLabel(schema.description_field, raw)
|
||||
: undefined,
|
||||
preview_url: schema.preview_url_field
|
||||
? String(getByPath(raw, schema.preview_url_field) ?? '')
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract items array from full API response using response_key */
|
||||
export function extractItems(
|
||||
response: unknown,
|
||||
responseKey?: string
|
||||
): unknown[] {
|
||||
const data = responseKey ? getByPath(response, responseKey) : response
|
||||
return Array.isArray(data) ? data : []
|
||||
}
|
||||
|
||||
/** Build search text for an item from the specified search fields */
|
||||
export function buildSearchText(raw: unknown, searchFields: string[]): string {
|
||||
return searchFields
|
||||
.map((field) => String(getByPath(raw, field) ?? ''))
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
/** Extract unique filter values from items */
|
||||
export function extractFilterValues(
|
||||
items: unknown[],
|
||||
filterField: string
|
||||
): string[] {
|
||||
const values = new Set<string>()
|
||||
for (const item of items) {
|
||||
const value = getByPath(item, filterField)
|
||||
if (value != null) values.add(String(value))
|
||||
}
|
||||
return Array.from(values).sort()
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { getComfyApiBaseUrl } from '@/config/comfyApi'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
/**
|
||||
* Check if a route is a comfy-api proxy route.
|
||||
* These routes need the comfy-api base URL prepended and always require auth.
|
||||
*/
|
||||
function isProxyRoute(route: string): boolean {
|
||||
return route.startsWith('/proxy/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a RemoteOptions route to a full URL.
|
||||
* - "/proxy/..." routes → prepend getComfyApiBaseUrl()
|
||||
* - Everything else → use as-is
|
||||
*/
|
||||
export function resolveRoute(route: string): string {
|
||||
if (isProxyRoute(route)) {
|
||||
return getComfyApiBaseUrl() + route
|
||||
}
|
||||
return route
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth headers for a remote request.
|
||||
* - "/proxy/..." routes → ALWAYS inject auth (comfy-api requires it)
|
||||
* - Other routes → only inject auth in cloud mode
|
||||
*/
|
||||
export async function getRemoteAuthHeaders(
|
||||
route: string
|
||||
): Promise<Record<string, any>> {
|
||||
if (isProxyRoute(route)) {
|
||||
const authStore = useAuthStore()
|
||||
const authHeader = await authStore.getAuthHeader()
|
||||
if (authHeader) {
|
||||
return { headers: authHeader }
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: make an authenticated GET request to a remote route.
|
||||
*/
|
||||
export async function fetchRemoteRoute(
|
||||
route: string,
|
||||
options: {
|
||||
params?: Record<string, string>
|
||||
timeout?: number
|
||||
signal?: AbortSignal
|
||||
} = {}
|
||||
) {
|
||||
const url = resolveRoute(route)
|
||||
const authHeaders = await getRemoteAuthHeaders(route)
|
||||
return axios.get(url, { ...options, ...authHeaders })
|
||||
}
|
||||
@@ -5,16 +5,6 @@ import { resultItemType } from '@/schemas/apiSchema'
|
||||
import { CONTROL_OPTIONS } from '@/types/simplifiedWidget'
|
||||
|
||||
const zComboOption = z.union([z.string(), z.number()])
|
||||
const zRemoteItemSchema = z.object({
|
||||
value_field: z.string(),
|
||||
label_field: z.string(),
|
||||
preview_url_field: z.string().optional(),
|
||||
preview_type: z.enum(['image', 'video', 'audio']).default('image'),
|
||||
description_field: z.string().optional(),
|
||||
search_fields: z.array(z.string()).optional(),
|
||||
filter_field: z.string().optional()
|
||||
})
|
||||
|
||||
const zRemoteWidgetConfig = z.object({
|
||||
route: z.string().url().or(z.string().startsWith('/')),
|
||||
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
|
||||
@@ -23,8 +13,7 @@ const zRemoteWidgetConfig = z.object({
|
||||
refresh_button: z.boolean().optional(),
|
||||
control_after_refresh: z.enum(['first', 'last']).optional(),
|
||||
timeout: z.number().gte(0).optional(),
|
||||
max_retries: z.number().gte(0).optional(),
|
||||
item_schema: zRemoteItemSchema.optional()
|
||||
max_retries: z.number().gte(0).optional()
|
||||
})
|
||||
const zMultiSelectOption = z.object({
|
||||
placeholder: z.string().optional(),
|
||||
@@ -365,7 +354,6 @@ export const zMatchTypeOptions = z.object({
|
||||
export type ComfyInputsSpec = z.infer<typeof zComfyInputsSpec>
|
||||
export type ComfyOutputTypesSpec = z.infer<typeof zComfyOutputTypesSpec>
|
||||
export type ComfyNodeDef = z.infer<typeof zComfyNodeDef>
|
||||
export type RemoteItemSchema = z.infer<typeof zRemoteItemSchema>
|
||||
export type RemoteWidgetConfig = z.infer<typeof zRemoteWidgetConfig>
|
||||
|
||||
export type ComboInputOptions = z.infer<typeof zComboInputOptions>
|
||||
|
||||
@@ -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,8 +49,6 @@
|
||||
"src/types/**/*.d.ts",
|
||||
"playwright.config.ts",
|
||||
"playwright.i18n.config.ts",
|
||||
|
||||
"tests-ui/**/*",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts"
|
||||
// "vitest.setup.ts",
|
||||
|
||||
@@ -161,7 +161,6 @@ export default defineConfig({
|
||||
ignored: [
|
||||
'./browser_tests/**',
|
||||
'./node_modules/**',
|
||||
'./tests-ui/**',
|
||||
'.eslintcache',
|
||||
'.oxlintrc.json',
|
||||
'*.config.{ts,mts}',
|
||||
|
||||
Reference in New Issue
Block a user