Compare commits

..

1 Commits

Author SHA1 Message Date
Terry Jia
f6925806c1 test: add basic E2E tests for CurveEditor widget 2026-03-29 21:37:00 -04:00
31 changed files with 205 additions and 3209 deletions

View File

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

View File

@@ -4,35 +4,36 @@
"nodes": [
{
"id": 1,
"type": "ImageCropV2",
"type": "CurveEditor",
"pos": [50, 50],
"size": [400, 500],
"size": [400, 400],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"name": "histogram",
"type": "HISTOGRAM",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"name": "curve",
"type": "CURVE",
"links": null
}
],
"properties": {
"Node name for S&R": "ImageCropV2"
"Node name for S&R": "CurveEditor"
},
"widgets_values": [
{
"x": 0,
"y": 0,
"width": 512,
"height": 512
"points": [
[0, 0],
[1, 1]
],
"interpolation": "monotone_cubic"
}
]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,72 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Curve Widget', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('widgets/curve_widget')
await comfyPage.vueNodes.waitForNodes()
})
test(
'Renders default diagonal curve with two control points',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
const svg = node.locator('svg[viewBox="-0.04 -0.04 1.08 1.08"]')
await expect(svg).toBeVisible()
const curvePath = svg.locator('[data-testid="curve-path"]')
await expect(curvePath).toBeVisible()
await expect(curvePath).toHaveAttribute('d', /.+/)
await expect(svg.locator('circle')).toHaveCount(2)
await expect(node).toHaveScreenshot('curve-default-diagonal.png')
}
)
test(
'Interpolation selector shows Smooth by default',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
await expect(node.getByText('Smooth')).toBeVisible()
}
)
test(
'Click on SVG canvas adds a control point',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const svg = node.locator('svg[viewBox="-0.04 -0.04 1.08 1.08"]')
await expect(svg).toBeVisible()
await expect(svg.locator('circle')).toHaveCount(2)
const box = await svg.boundingBox()
if (!box) throw new Error('SVG bounding box not found')
// Click at ~(0.5, 0.8) in curve space.
// ViewBox is -0.04..1.04, so pad fraction = 0.04/1.08.
// Y is inverted: curveY=0.8 → svgY=0.2 → near top of SVG.
const padFrac = 0.04 / 1.08
const scale = 1 / 1.08
const screenX = box.x + box.width * padFrac + 0.5 * box.width * scale
const screenY =
box.y + box.height * padFrac + (1 - 0.8) * box.height * scale
await comfyPage.page.mouse.click(screenX, screenY)
await comfyPage.nextFrame()
await expect(svg.locator('circle')).toHaveCount(3)
await expect(node).toHaveScreenshot('curve-after-add-point.png')
}
)
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -192,15 +192,3 @@ export function curvesToLUT(
return lut
}
export function curveDataToFloatLUT(
curve: CurveData,
size: number = 256
): Float32Array {
const lut = new Float32Array(size)
const interpolate = createInterpolator(curve.points, curve.interpolation)
for (let i = 0; i < size; i++) {
lut[i] = interpolate(i / (size - 1))
}
return lut
}

View File

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

View File

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

View File

@@ -284,7 +284,6 @@ import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useGLSLPreview } from '@/renderer/glsl/useGLSLPreview'
import { usePromotedPreviews } from '@/composables/node/usePromotedPreviews'
import NodeBadges from '@/renderer/extensions/vueNodes/components/NodeBadges.vue'
import { LayoutSource } from '@/renderer/core/layout/types'
@@ -731,8 +730,6 @@ const lgraphNode = computed(() => {
// reaching through lgraphNode for promoted preview resolution.
const { promotedPreviews } = usePromotedPreviews(lgraphNode)
useGLSLPreview(lgraphNode)
const showAdvancedInputsButton = computed(() => {
const node = lgraphNode.value
if (!node) return false

View File

@@ -1,40 +0,0 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { SUBGRAPH_INPUT_ID } from '@/lib/litegraph/src/constants'
export const GLSL_NODE_TYPE = 'GLSLShader'
export const DEBOUNCE_MS = 50
export const DEFAULT_SIZE = 512
const MAX_PREVIEW_DIMENSION = 1024
export function normalizeDimension(value: unknown): number {
const parsed = Number(value)
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_SIZE
return parsed
}
export function clampResolution(w: number, h: number): [number, number] {
const maxDim = Math.max(w, h)
if (maxDim <= MAX_PREVIEW_DIMENSION) return [w, h]
const scale = MAX_PREVIEW_DIMENSION / maxDim
return [Math.round(w * scale), Math.round(h * scale)]
}
export function getImageThroughSubgraphBoundary(
node: LGraphNode,
slot: number,
ownerSubgraphNode: LGraphNode
): HTMLImageElement | undefined {
const graph = node.graph
if (!graph) return undefined
const input = node.inputs[slot]
if (input?.link == null) return undefined
const link = graph._links.get(input.link)
if (!link || link.origin_id !== SUBGRAPH_INPUT_ID) return undefined
const outerUpstream = ownerSubgraphNode.getInputNode(link.origin_slot)
if (!outerUpstream?.imgs?.length) return undefined
return outerUpstream.imgs[0]
}

View File

@@ -1,331 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, reactive, ref, shallowRef } from 'vue'
import { useGLSLPreview } from '@/renderer/glsl/useGLSLPreview'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { GLSLRendererConfig } from '@/renderer/glsl/useGLSLRenderer'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { MaybeRefOrGetter } from 'vue'
const mockRendererFactory = vi.hoisted(() => {
const init = vi.fn(() => true)
const compileFragment = vi.fn(() => ({ success: true, log: '' }))
const setResolution = vi.fn()
const setFloatUniform = vi.fn()
const setIntUniform = vi.fn()
const setBoolUniform = vi.fn()
const bindCurveTexture = vi.fn()
const bindInputImage = vi.fn()
const render = vi.fn()
const toBlob = vi.fn(() => Promise.resolve(new Blob(['test'])))
const dispose = vi.fn()
const lastConfig = { value: undefined as GLSLRendererConfig | undefined }
return {
create: (config?: GLSLRendererConfig) => {
lastConfig.value = config
return {
init,
compileFragment,
setResolution,
setFloatUniform,
setIntUniform,
setBoolUniform,
bindCurveTexture,
bindInputImage,
render,
toBlob,
dispose
}
},
lastConfig,
init,
compileFragment,
setResolution,
setFloatUniform,
setIntUniform,
setBoolUniform,
bindCurveTexture,
bindInputImage,
render,
toBlob,
dispose
}
})
vi.mock('@/renderer/glsl/useGLSLRenderer', () => ({
useGLSLRenderer: (config?: GLSLRendererConfig) =>
mockRendererFactory.create(config)
}))
const mockSetNodePreviewsByNodeId = vi.fn()
const mockNodeOutputs = reactive<Record<string, unknown>>({})
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => ({
setNodePreviewsByNodeId: mockSetNodePreviewsByNodeId,
setNodePreviewsByLocatorId: vi.fn(),
revokePreviewsByLocatorId: vi.fn(),
nodeOutputs: mockNodeOutputs
})
}))
vi.mock('@/stores/widgetValueStore', () => {
const widgetMap = new Map<string, { value: unknown }>()
const getWidget = vi.fn((_graphId: string, _nodeId: string, name: string) =>
widgetMap.get(name)
)
return {
useWidgetValueStore: () => ({
getWidget,
_widgetMap: widgetMap
})
}
})
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
nodeIdToNodeLocatorId: (id: string | number) => String(id),
nodeToNodeLocatorId: (node: { id: string | number }) => String(node.id)
})
}))
vi.mock('@/utils/objectUrlUtil', () => ({
createSharedObjectUrl: () => 'blob:test',
releaseSharedObjectUrl: vi.fn()
}))
function createMockNode(overrides: Record<string, unknown> = {}): LGraphNode {
const graph = { id: 'test-graph-id', rootGraph: { id: 'test-graph-id' } }
return {
id: 1,
type: 'GLSLShader',
inputs: [],
graph,
getInputNode: vi.fn(() => null),
isSubgraphNode: () => false,
...overrides
} as unknown as LGraphNode
}
function wrapNode(
node: LGraphNode | null
): MaybeRefOrGetter<LGraphNode | null> {
return ref(node) as MaybeRefOrGetter<LGraphNode | null>
}
describe('useGLSLPreview', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockRendererFactory.lastConfig.value = undefined
globalThis.URL.createObjectURL = vi.fn(() => 'blob:test')
globalThis.URL.revokeObjectURL = vi.fn()
})
it('does not activate for non-GLSLShader nodes', () => {
const node = createMockNode({ type: 'KSampler' })
const { isActive } = useGLSLPreview(wrapNode(node))
expect(isActive.value).toBe(false)
})
it('does not activate before first execution', () => {
const node = createMockNode()
Object.keys(mockNodeOutputs).forEach((k) => delete mockNodeOutputs[k])
const { isActive } = useGLSLPreview(wrapNode(node))
expect(isActive.value).toBe(false)
})
it('activates for GLSLShader nodes with execution output', () => {
const node = createMockNode()
mockNodeOutputs['1'] = {
images: [{ filename: 'test.png', subfolder: '', type: 'temp' }]
}
const { isActive } = useGLSLPreview(wrapNode(node))
expect(isActive.value).toBe(true)
})
it('exposes lastError as null initially', () => {
const node = createMockNode()
const { lastError } = useGLSLPreview(wrapNode(node))
expect(lastError.value).toBe(null)
})
it('does not activate for null node', () => {
const { isActive } = useGLSLPreview(wrapNode(null))
expect(isActive.value).toBe(false)
})
it('cleans up on dispose', () => {
const node = createMockNode()
const { dispose } = useGLSLPreview(wrapNode(node))
expect(() => dispose()).not.toThrow()
})
describe('autogrow config extraction', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
async function triggerRender(node: LGraphNode) {
mockNodeOutputs[String(node.id)] = {
images: [{ filename: 'test.png', subfolder: '', type: 'temp' }]
}
const store = useWidgetValueStore() as unknown as {
_widgetMap: Map<string, { value: unknown }>
}
store._widgetMap.set('fragment_shader', {
value: 'void main() {}'
})
const nodeRef = shallowRef<LGraphNode | null>(null)
useGLSLPreview(nodeRef)
nodeRef.value = node
await nextTick()
vi.advanceTimersByTime(100)
await nextTick()
}
it('passes default config when node has no comfyDynamic', async () => {
const node = createMockNode()
await triggerRender(node)
expect(mockRendererFactory.lastConfig.value).toEqual({
maxInputs: 5,
maxFloatUniforms: 20,
maxIntUniforms: 20,
maxBoolUniforms: 10,
maxCurves: 4
})
})
it('extracts autogrow limits from node comfyDynamic', async () => {
const node = createMockNode({
comfyDynamic: {
autogrow: {
images: { min: 1, max: 3 },
floats: { min: 0, max: 8 },
ints: { min: 0, max: 4 }
}
}
})
await triggerRender(node)
expect(mockRendererFactory.lastConfig.value).toEqual({
maxInputs: 3,
maxFloatUniforms: 8,
maxIntUniforms: 4,
maxBoolUniforms: 10,
maxCurves: 4
})
})
})
describe('render pipeline', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
async function setupAndRender(node: LGraphNode) {
mockNodeOutputs[String(node.id)] = {
images: [{ filename: 'test.png', subfolder: '', type: 'temp' }]
}
const store = useWidgetValueStore() as unknown as {
_widgetMap: Map<string, { value: unknown }>
}
store._widgetMap.set('fragment_shader', {
value: 'void main() {}'
})
const nodeRef = shallowRef<LGraphNode | null>(null)
const result = useGLSLPreview(nodeRef)
nodeRef.value = node
await nextTick()
vi.advanceTimersByTime(100)
await nextTick()
// Allow async renderPreview to complete
await nextTick()
return result
}
it('calls compileFragment, render, and toBlob in sequence', async () => {
const node = createMockNode()
await setupAndRender(node)
expect(mockRendererFactory.compileFragment).toHaveBeenCalledWith(
'void main() {}'
)
expect(mockRendererFactory.render).toHaveBeenCalled()
expect(mockRendererFactory.toBlob).toHaveBeenCalled()
const compileOrder =
mockRendererFactory.compileFragment.mock.invocationCallOrder[0]
const renderOrder = mockRendererFactory.render.mock.invocationCallOrder[0]
const toBlobOrder = mockRendererFactory.toBlob.mock.invocationCallOrder[0]
expect(compileOrder).toBeLessThan(renderOrder)
expect(renderOrder).toBeLessThan(toBlobOrder)
})
it('sets lastError on compilation failure', async () => {
mockRendererFactory.compileFragment.mockReturnValueOnce({
success: false,
log: 'syntax error at line 5'
})
const node = createMockNode()
const { lastError } = await setupAndRender(node)
expect(lastError.value).toBe('syntax error at line 5')
})
it('clears lastError on successful compilation', async () => {
const node = createMockNode()
const { lastError } = await setupAndRender(node)
expect(lastError.value).toBe(null)
})
it('skips render when shader source is unavailable', async () => {
const store = useWidgetValueStore() as unknown as {
_widgetMap: Map<string, { value: unknown }>
}
store._widgetMap.delete('fragment_shader')
const node = createMockNode()
mockNodeOutputs[String(node.id)] = {
images: [{ filename: 'test.png', subfolder: '', type: 'temp' }]
}
const nodeRef = shallowRef<LGraphNode | null>(null)
useGLSLPreview(nodeRef)
nodeRef.value = node
await nextTick()
vi.advanceTimersByTime(100)
await nextTick()
expect(mockRendererFactory.compileFragment).not.toHaveBeenCalled()
})
it('disposes renderer and cancels debounce on cleanup', async () => {
const node = createMockNode()
const { dispose } = await setupAndRender(node)
dispose()
expect(mockRendererFactory.dispose).toHaveBeenCalled()
})
})
})

View File

@@ -1,500 +0,0 @@
import { debounce } from 'es-toolkit/compat'
import { computed, effectScope, onScopeDispose, ref, toValue, watch } from 'vue'
import type { ComputedRef, EffectScope, MaybeRefOrGetter, Ref } from 'vue'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { curveDataToFloatLUT } from '@/components/curve/curveUtils'
import type { GLSLRendererConfig } from '@/renderer/glsl/useGLSLRenderer'
import { useGLSLRenderer } from '@/renderer/glsl/useGLSLRenderer'
import {
extractUniformSources,
getAutogrowLimits,
useGLSLUniforms
} from '@/renderer/glsl/useGLSLUniforms'
import {
createSharedObjectUrl,
releaseSharedObjectUrl
} from '@/utils/objectUrlUtil'
import {
clampResolution,
DEBOUNCE_MS,
DEFAULT_SIZE,
getImageThroughSubgraphBoundary,
GLSL_NODE_TYPE,
normalizeDimension
} from '@/renderer/glsl/glslPreviewUtils'
/**
* Two-tier composable for GLSL live preview.
*
* Outer tier (always created): only 2 cheap computed refs to detect
* whether the node is GLSL-related. For non-GLSL nodes this is the
* only cost — no watchers, store subscriptions, or renderer.
*
* Inner tier (lazy): created via effectScope when the node is detected
* as a GLSLShader or a subgraph containing one. Contains all the
* expensive logic: store reads, watchers, debounce, WebGL renderer.
*/
export function useGLSLPreview(
nodeMaybe: MaybeRefOrGetter<LGraphNode | null | undefined>
) {
const lastError = ref<string | null>(null)
const nodeRef = computed(() => toValue(nodeMaybe) ?? null)
const isGLSLNode = computed(() => nodeRef.value?.type === GLSL_NODE_TYPE)
const isGLSLSubgraphNode = computed(() => {
const node = nodeRef.value
if (!node?.isSubgraphNode()) return false
const subgraph = node.subgraph as Subgraph | undefined
return subgraph?.nodes.some((n) => n.type === GLSL_NODE_TYPE) ?? false
})
const isGLSLRelated = computed(
() => isGLSLNode.value || isGLSLSubgraphNode.value
)
let innerScope: EffectScope | null = null
let innerDispose: (() => void) | null = null
const isActive = ref(false)
watch(
isGLSLRelated,
(related) => {
if (related && !innerScope) {
innerScope = effectScope()
innerDispose = innerScope.run(() =>
createInnerPreview(
nodeRef,
isGLSLNode,
isGLSLSubgraphNode,
lastError,
isActive
)
)!
} else if (!related && innerScope) {
innerDispose?.()
innerScope.stop()
innerScope = null
innerDispose = null
isActive.value = false
}
},
{ immediate: true }
)
onScopeDispose(() => {
innerDispose?.()
innerScope?.stop()
})
return {
isActive: computed(() => isActive.value),
lastError,
dispose() {
innerDispose?.()
innerScope?.stop()
innerScope = null
innerDispose = null
}
}
}
/**
* Inner tier: all expensive GLSL preview logic.
* Runs inside its own effectScope so it can be created/destroyed
* independently of the component lifecycle.
* Returns a dispose function.
*/
function createInnerPreview(
nodeRef: ComputedRef<LGraphNode | null>,
isGLSLNode: ComputedRef<boolean>,
isGLSLSubgraphNode: ComputedRef<boolean>,
lastError: Ref<string | null>,
isActiveOut: Ref<boolean>
): () => void {
const widgetValueStore = useWidgetValueStore()
const nodeOutputStore = useNodeOutputStore()
const { nodeToNodeLocatorId } = useWorkflowStore()
let renderer: ReturnType<typeof useGLSLRenderer> | null = null
let rendererReady = false
let renderRequestId = 0
const innerGLSLNode = (() => {
const node = nodeRef.value
if (!node?.isSubgraphNode()) return null
const subgraph = node.subgraph as Subgraph | undefined
return subgraph?.nodes.find((n) => n.type === GLSL_NODE_TYPE) ?? null
})()
const ownerSubgraphNode = (() => {
const node = nodeRef.value
const graph = node?.graph
if (!graph) return null
const rootGraph = graph.rootGraph
if (!rootGraph || graph === rootGraph) return null
return (
rootGraph._nodes?.find(
(n) => n.isSubgraphNode() && n.subgraph === graph
) ?? null
)
})()
const graphId = computed(
() => nodeRef.value?.graph?.rootGraph?.id as UUID | undefined
)
const nodeId = computed(() => nodeRef.value?.id as NodeId | undefined)
const hasExecutionOutput = computed(() => {
const node = nodeRef.value
if (!node) return false
const outputs = nodeOutputStore.nodeOutputs
const locatorId = nodeToNodeLocatorId(node)
if (outputs[locatorId]?.images?.length) return true
const inner = innerGLSLNode
if (inner) {
const innerLocatorId = nodeToNodeLocatorId(inner)
if (outputs[innerLocatorId]?.images?.length) return true
}
return false
})
const shouldRender = computed(
() =>
(isGLSLNode.value || isGLSLSubgraphNode.value) && hasExecutionOutput.value
)
watch(
shouldRender,
(v) => {
isActiveOut.value = v
},
{ immediate: true }
)
const shaderSource = computed(() => {
const gId = graphId.value
if (!gId) return undefined
if (isGLSLNode.value) {
const nId = nodeId.value
if (nId == null) return undefined
return widgetValueStore.getWidget(gId, nId, 'fragment_shader')?.value as
| string
| undefined
}
const inner = innerGLSLNode
if (inner) {
return widgetValueStore.getWidget(
gId,
inner.id as NodeId,
'fragment_shader'
)?.value as string | undefined
}
return undefined
})
const rendererConfig = computed(() => {
const inner = innerGLSLNode
if (inner) return getAutogrowLimits(inner)
const node = nodeRef.value
if (!node)
return {
maxInputs: 5,
maxFloatUniforms: 20,
maxIntUniforms: 20,
maxBoolUniforms: 10,
maxCurves: 4
}
return getAutogrowLimits(node)
})
const uniformSources = computed(() => {
const node = nodeRef.value
const inner = innerGLSLNode
if (!node?.isSubgraphNode() || !inner) return null
return extractUniformSources(inner, node.subgraph as Subgraph)
})
const { floatValues, intValues, boolValues, curveValues } = useGLSLUniforms(
graphId,
nodeId,
nodeRef,
uniformSources,
rendererConfig
)
function loadInputImages(): void {
const node = nodeRef.value
if (!node?.inputs || !renderer) return
if (isGLSLSubgraphNode.value) {
let imageSlotIndex = 0
for (let slot = 0; slot < node.inputs.length; slot++) {
if (node.inputs[slot].type !== 'IMAGE') continue
const upstreamNode = node.getInputNode(slot)
if (upstreamNode?.imgs?.length) {
renderer.bindInputImage(imageSlotIndex, upstreamNode.imgs[0])
}
imageSlotIndex++
}
return
}
let imageSlotIndex = 0
for (let slot = 0; slot < node.inputs.length; slot++) {
const input = node.inputs[slot]
if (!input.name.startsWith('images.image')) continue
const upstreamNode = node.getInputNode(slot)
if (upstreamNode?.imgs?.length) {
renderer.bindInputImage(imageSlotIndex, upstreamNode.imgs[0])
imageSlotIndex++
continue
}
const owner = ownerSubgraphNode
if (owner) {
const img = getImageThroughSubgraphBoundary(node, slot, owner)
if (img) {
renderer.bindInputImage(imageSlotIndex, img)
}
}
imageSlotIndex++
}
}
function getResolution(): [number, number] {
const node = nodeRef.value
if (!node?.inputs) return [DEFAULT_SIZE, DEFAULT_SIZE]
if (isGLSLSubgraphNode.value) {
for (let slot = 0; slot < node.inputs.length; slot++) {
if (node.inputs[slot].type !== 'IMAGE') continue
const upstreamNode = node.getInputNode(slot)
if (!upstreamNode?.imgs?.length) continue
const img = upstreamNode.imgs[0]
return clampResolution(
img.naturalWidth || DEFAULT_SIZE,
img.naturalHeight || DEFAULT_SIZE
)
}
return [DEFAULT_SIZE, DEFAULT_SIZE]
}
for (let slot = 0; slot < node.inputs.length; slot++) {
const input = node.inputs[slot]
if (!input.name.startsWith('images.image')) continue
const upstreamNode = node.getInputNode(slot)
if (upstreamNode?.imgs?.length) {
const img = upstreamNode.imgs[0]
return clampResolution(
img.naturalWidth || DEFAULT_SIZE,
img.naturalHeight || DEFAULT_SIZE
)
}
const owner = ownerSubgraphNode
if (owner) {
const img = getImageThroughSubgraphBoundary(node, slot, owner)
if (img) {
return clampResolution(
img.naturalWidth || DEFAULT_SIZE,
img.naturalHeight || DEFAULT_SIZE
)
}
}
}
const gId = graphId.value
const nId = nodeId.value
if (gId && nId != null) {
const widthWidget = widgetValueStore.getWidget(
gId,
nId,
'size_mode.width'
)
const heightWidget = widgetValueStore.getWidget(
gId,
nId,
'size_mode.height'
)
if (widthWidget && heightWidget) {
return clampResolution(
normalizeDimension(widthWidget.value),
normalizeDimension(heightWidget.value)
)
}
}
return [DEFAULT_SIZE, DEFAULT_SIZE]
}
let disposed = false
let lastRendererConfig: GLSLRendererConfig | null = null
function ensureRenderer(): ReturnType<typeof useGLSLRenderer> {
const config = rendererConfig.value
if (renderer && lastRendererConfig) {
const changed =
config.maxInputs !== lastRendererConfig.maxInputs ||
config.maxFloatUniforms !== lastRendererConfig.maxFloatUniforms ||
config.maxIntUniforms !== lastRendererConfig.maxIntUniforms ||
config.maxBoolUniforms !== lastRendererConfig.maxBoolUniforms ||
config.maxCurves !== lastRendererConfig.maxCurves
if (changed) {
renderer.dispose()
renderer = null
rendererReady = false
}
}
if (!renderer) {
renderer = useGLSLRenderer(config)
lastRendererConfig = { ...config }
}
return renderer
}
async function renderPreview(): Promise<void> {
const requestId = ++renderRequestId
const source = shaderSource.value
if (!source || !shouldRender.value) return
const r = ensureRenderer()
try {
if (!rendererReady) {
const [w, h] = getResolution()
if (!r.init(w, h)) {
lastError.value = 'WebGL2 not available'
return
}
rendererReady = true
}
const result = r.compileFragment(source)
if (!result.success) {
lastError.value = result.log
return
}
lastError.value = null
const [w, h] = getResolution()
r.setResolution(w, h)
loadInputImages()
for (let i = 0; i < floatValues.value.length; i++) {
r.setFloatUniform(i, floatValues.value[i])
}
for (let i = 0; i < intValues.value.length; i++) {
r.setIntUniform(i, intValues.value[i])
}
for (let i = 0; i < boolValues.value.length; i++) {
r.setBoolUniform(i, boolValues.value[i])
}
const curves = curveValues.value
for (let i = 0; i < curves.length; i++) {
r.bindCurveTexture(i, curveDataToFloatLUT(curves[i]))
}
r.render()
const blob = await r.toBlob()
if (requestId !== renderRequestId || disposed) return
const blobUrl = createSharedObjectUrl(blob)
try {
const inner = innerGLSLNode
if (inner) {
const innerLocatorId = nodeToNodeLocatorId(inner)
nodeOutputStore.setNodePreviewsByLocatorId(innerLocatorId, [blobUrl])
} else {
const nId = nodeId.value
if (nId != null) {
nodeOutputStore.setNodePreviewsByNodeId(nId, [blobUrl])
}
}
} finally {
releaseSharedObjectUrl(blobUrl)
}
} catch (error) {
if (requestId !== renderRequestId) return
lastError.value =
error instanceof Error ? error.message : 'Failed to render preview'
}
}
const debouncedRender = debounce((): void => {
void renderPreview()
}, DEBOUNCE_MS)
watch(
shouldRender,
(active) => {
if (isGLSLNode.value) {
const node = nodeRef.value
if (node) node.hideOutputImages = active
}
if (active) debouncedRender()
},
{ immediate: true }
)
watch(
() =>
[
floatValues.value,
intValues.value,
boolValues.value,
curveValues.value
] as const,
() => {
if (shouldRender.value) debouncedRender()
},
{ deep: true }
)
watch(shaderSource, () => {
if (shouldRender.value) debouncedRender()
})
// Return dispose function for the inner tier
return () => {
disposed = true
debouncedRender.cancel()
renderer?.dispose()
renderer = null
// Revoke preview blob URLs to avoid memory leaks
const inner = innerGLSLNode
if (inner) {
const locatorId = nodeToNodeLocatorId(inner)
nodeOutputStore.revokePreviewsByLocatorId(locatorId)
} else {
const nId = nodeId.value
if (nId != null) {
const locatorId = nodeToNodeLocatorId(nodeRef.value!)
nodeOutputStore.revokePreviewsByLocatorId(locatorId)
}
}
}
}

View File

@@ -1,61 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { GLSLRendererConfig } from '@/renderer/glsl/useGLSLRenderer'
vi.mock('vue', async () => {
const actual = await vi.importActual('vue')
return {
...actual,
onScopeDispose: vi.fn()
}
})
describe('useGLSLRenderer', () => {
it('returns renderer API with expected methods', async () => {
const { useGLSLRenderer } = await import('@/renderer/glsl/useGLSLRenderer')
const renderer = useGLSLRenderer()
expect(renderer).toHaveProperty('init')
expect(renderer).toHaveProperty('compileFragment')
expect(renderer).toHaveProperty('setResolution')
expect(renderer).toHaveProperty('setFloatUniform')
expect(renderer).toHaveProperty('setIntUniform')
expect(renderer).toHaveProperty('bindInputImage')
expect(renderer).toHaveProperty('render')
expect(renderer).toHaveProperty('readPixels')
expect(renderer).toHaveProperty('toBlob')
expect(renderer).toHaveProperty('dispose')
})
it('init returns false when WebGL2 is unavailable', async () => {
const { useGLSLRenderer } = await import('@/renderer/glsl/useGLSLRenderer')
const renderer = useGLSLRenderer()
expect(renderer.init(256, 256)).toBe(false)
})
it('compileFragment reports error before initialization', async () => {
const { useGLSLRenderer } = await import('@/renderer/glsl/useGLSLRenderer')
const renderer = useGLSLRenderer()
const result = renderer.compileFragment('void main() {}')
expect(result.success).toBe(false)
})
it('toBlob rejects before initialization', async () => {
const { useGLSLRenderer } = await import('@/renderer/glsl/useGLSLRenderer')
const renderer = useGLSLRenderer()
await expect(renderer.toBlob()).rejects.toThrow('Renderer not initialized')
})
it('accepts custom config without error', async () => {
const { useGLSLRenderer } = await import('@/renderer/glsl/useGLSLRenderer')
const config: GLSLRendererConfig = {
maxInputs: 3,
maxFloatUniforms: 2,
maxIntUniforms: 1,
maxBoolUniforms: 1,
maxCurves: 2
}
const renderer = useGLSLRenderer(config)
expect(renderer.init(256, 256)).toBe(false)
})
})

View File

@@ -1,3 +1,5 @@
import { onScopeDispose } from 'vue'
import { detectPassCount } from '@/renderer/glsl/glslUtils'
const VERTEX_SHADER_SOURCE = `#version 300 es
@@ -15,16 +17,12 @@ export interface GLSLRendererConfig {
maxInputs: number
maxFloatUniforms: number
maxIntUniforms: number
maxBoolUniforms: number
maxCurves: number
}
const DEFAULT_CONFIG: GLSLRendererConfig = {
maxInputs: 5,
maxFloatUniforms: 20,
maxIntUniforms: 20,
maxBoolUniforms: 10,
maxCurves: 4
maxFloatUniforms: 5,
maxIntUniforms: 5
}
interface CompileResult {
@@ -52,22 +50,15 @@ function compileShader(
}
export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) {
const {
maxInputs,
maxFloatUniforms,
maxIntUniforms,
maxBoolUniforms,
maxCurves
} = config
const { maxInputs, maxFloatUniforms, maxIntUniforms } = config
const uniformNames = [
'u_resolution',
'u_pass',
'u_prevPass',
...Array.from({ length: maxInputs }, (_, i) => `u_image${i}`),
...Array.from({ length: maxFloatUniforms }, (_, i) => `u_float${i}`),
...Array.from({ length: maxIntUniforms }, (_, i) => `u_int${i}`),
...Array.from({ length: maxBoolUniforms }, (_, i) => `u_bool${i}`),
...Array.from({ length: maxCurves }, (_, i) => `u_curve${i}`)
...Array.from({ length: maxIntUniforms }, (_, i) => `u_int${i}`)
]
let canvas: OffscreenCanvas | null = null
@@ -81,13 +72,9 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) {
const inputTextures: (WebGLTexture | null)[] = Array.from<null>({
length: maxInputs
}).fill(null)
const curveTextures: (WebGLTexture | null)[] = Array.from<null>({
length: maxCurves
}).fill(null)
const uniformLocations = new Map<string, WebGLUniformLocation | null>()
let passCount = 1
let disposed = false
let lastCompiledSource: string | null = null
function initPingPongFBOs(
ctx: WebGL2RenderingContext,
@@ -105,12 +92,12 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) {
ctx.texImage2D(
ctx.TEXTURE_2D,
0,
ctx.RGBA16F,
ctx.RGBA8,
width,
height,
0,
ctx.RGBA,
ctx.HALF_FLOAT,
ctx.UNSIGNED_BYTE,
null
)
ctx.texParameteri(ctx.TEXTURE_2D, ctx.TEXTURE_MIN_FILTER, ctx.LINEAR)
@@ -204,9 +191,6 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) {
if (!ctx) return false
gl = ctx
if (!gl.getExtension('EXT_color_buffer_float')) return false
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true)
vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER_SOURCE)
initPingPongFBOs(gl, width, height)
@@ -222,11 +206,6 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) {
passCount = Math.min(detectPassCount(source), MAX_PASSES)
if (source === lastCompiledSource && program) {
return { success: true, log: '' }
}
lastCompiledSource = source
if (fragmentShader) {
gl.deleteShader(fragmentShader)
fragmentShader = null
@@ -291,51 +270,6 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) {
}
}
function setBoolUniform(index: number, value: boolean): void {
if (disposed || !program || !gl) return
const loc = uniformLocations.get(`u_bool${index}`)
if (loc != null) {
gl.useProgram(program)
gl.uniform1i(loc, value ? 1 : 0)
}
}
function bindCurveTexture(index: number, lut: Float32Array): void {
if (disposed || !gl) return
if (index < 0 || index >= maxCurves) return
if (curveTextures[index]) {
gl.deleteTexture(curveTextures[index])
curveTextures[index] = null
}
const texture = gl.createTexture()
if (!texture) return
const unit = maxInputs + index
gl.activeTexture(gl.TEXTURE0 + unit)
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false)
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.R16F,
lut.length,
1,
0,
gl.RED,
gl.FLOAT,
lut
)
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
curveTextures[index] = texture
}
function bindInputImage(
index: number,
image: HTMLImageElement | ImageBitmap
@@ -370,7 +304,6 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) {
if (disposed || !program || !pingPongFBOs || !gl || !canvas) return
gl.useProgram(program)
gl.disable(gl.BLEND)
const resLoc = uniformLocations.get('u_resolution')
if (resLoc != null) {
@@ -386,15 +319,8 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) {
}
}
for (let i = 0; i < maxCurves; i++) {
const loc = uniformLocations.get(`u_curve${i}`)
if (loc != null && curveTextures[i]) {
const unit = maxInputs + i
gl.activeTexture(gl.TEXTURE0 + unit)
gl.bindTexture(gl.TEXTURE_2D, curveTextures[i])
gl.uniform1i(loc, unit)
}
}
const prevPassUnit = maxInputs
const prevPassLoc = uniformLocations.get('u_prevPass')
for (let pass = 0; pass < passCount; pass++) {
const passLoc = uniformLocations.get('u_pass')
@@ -402,26 +328,31 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) {
const isLastPass = pass === passCount - 1
const writeIdx = pass % 2
const readIdx = 1 - writeIdx
if (isLastPass) {
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
gl.drawBuffers([gl.BACK])
} else {
gl.bindFramebuffer(gl.FRAMEBUFFER, pingPongFBOs[writeIdx])
}
// Note: u_prevPass uses ping-pong FBOs rather than overwriting the input
// texture in-place as the backend does for single-input iteration.
if (pass > 0 && prevPassLoc != null) {
gl.activeTexture(gl.TEXTURE0 + prevPassUnit)
gl.bindTexture(gl.TEXTURE_2D, pingPongTextures![readIdx])
gl.uniform1i(prevPassLoc, prevPassUnit)
}
// Ping-pong FBOs have a single color attachment, so intermediate
// passes always target COLOR_ATTACHMENT0. MRT is only possible on
// the default framebuffer (last pass).
if (isLastPass) {
gl.drawBuffers([gl.BACK])
} else {
gl.drawBuffers([gl.COLOR_ATTACHMENT0])
}
// Match backend behavior: pass > 0 binds previous pass output to
// texture unit 0, overriding u_image0 so shaders read the previous
// pass result via the same sampler.
if (pass > 0) {
const sourceTexture = pingPongTextures![(pass - 1) % 2]
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, sourceTexture)
}
gl.clearColor(0, 0, 0, 0)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.drawArrays(gl.TRIANGLES, 0, 3)
}
}
@@ -440,7 +371,7 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) {
async function toBlob(): Promise<Blob> {
if (!canvas) throw new Error('Renderer not initialized')
return canvas.convertToBlob({ type: 'image/webp', quality: 0.92 })
return canvas.convertToBlob({ type: 'image/jpeg', quality: 0.92 })
}
function dispose(): void {
@@ -453,11 +384,6 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) {
}
inputTextures.fill(null)
for (const tex of curveTextures) {
if (tex) gl.deleteTexture(tex)
}
curveTextures.fill(null)
if (fallbackTexture) {
gl.deleteTexture(fallbackTexture)
fallbackTexture = null
@@ -485,14 +411,14 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) {
ext?.loseContext()
}
onScopeDispose(dispose)
return {
init,
compileFragment,
setResolution,
setFloatUniform,
setIntUniform,
setBoolUniform,
bindCurveTexture,
bindInputImage,
render,
readPixels,

View File

@@ -1,247 +0,0 @@
import { computed } from 'vue'
import type { ComputedRef } from 'vue'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import { SUBGRAPH_INPUT_ID } from '@/lib/litegraph/src/constants'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { isCurveData } from '@/components/curve/curveUtils'
import type { CurveData } from '@/components/curve/types'
import type { GLSLRendererConfig } from '@/renderer/glsl/useGLSLRenderer'
interface AutogrowGroup {
max: number
min: number
prefix?: string
}
export interface UniformSource {
nodeId: NodeId
widgetName: string
}
export interface UniformSources {
floats: UniformSource[]
ints: UniformSource[]
bools: UniformSource[]
curves: UniformSource[]
}
export function getAutogrowLimits(node: LGraphNode): GLSLRendererConfig {
const defaults: GLSLRendererConfig = {
maxInputs: 5,
maxFloatUniforms: 20,
maxIntUniforms: 20,
maxBoolUniforms: 10,
maxCurves: 4
}
if (!('comfyDynamic' in node)) return defaults
const dynamic = node.comfyDynamic
if (
typeof dynamic !== 'object' ||
dynamic === null ||
!('autogrow' in dynamic)
)
return defaults
const groups = dynamic.autogrow as Record<string, AutogrowGroup> | undefined
if (!groups) return defaults
return {
maxInputs: groups['images']?.max ?? defaults.maxInputs,
maxFloatUniforms: groups['floats']?.max ?? defaults.maxFloatUniforms,
maxIntUniforms: groups['ints']?.max ?? defaults.maxIntUniforms,
maxBoolUniforms: groups['bools']?.max ?? defaults.maxBoolUniforms,
maxCurves: groups['curves']?.max ?? defaults.maxCurves
}
}
export function extractUniformSources(
glslNode: LGraphNode,
subgraph: Subgraph
): UniformSources {
const floats: UniformSource[] = []
const ints: UniformSource[] = []
const bools: UniformSource[] = []
const curves: UniformSource[] = []
if (!glslNode.inputs) return { floats, ints, bools, curves }
for (const input of glslNode.inputs) {
if (input.link == null) continue
const link = subgraph.getLink(input.link)
if (!link || link.origin_id === SUBGRAPH_INPUT_ID) continue
const sourceNode = subgraph.getNodeById(link.origin_id)
if (!sourceNode?.widgets?.[0]) continue
const inputName = input.name ?? ''
const dotIndex = inputName.indexOf('.')
if (dotIndex === -1) continue
const prefix = inputName.slice(0, dotIndex)
const source: UniformSource = {
nodeId: sourceNode.id as NodeId,
widgetName: sourceNode.widgets[0].name
}
if (prefix === 'floats') floats.push(source)
else if (prefix === 'ints') ints.push(source)
else if (prefix === 'bools') bools.push(source)
else if (prefix === 'curves') curves.push(source)
}
return { floats, ints, bools, curves }
}
export function useGLSLUniforms(
graphId: ComputedRef<UUID | undefined>,
nodeId: ComputedRef<NodeId | undefined>,
nodeRef: ComputedRef<LGraphNode | null>,
uniformSources: ComputedRef<UniformSources | null>,
rendererConfig: ComputedRef<GLSLRendererConfig>
) {
const widgetValueStore = useWidgetValueStore()
function collectValues<T>(
subgraphSources: UniformSource[] | undefined,
groupName: string,
uniformPrefix: string,
maxCount: number,
coerce: (value: unknown) => T,
defaultValue: T
): T[] {
const gId = graphId.value
if (!gId) return []
if (subgraphSources) {
return subgraphSources.map(({ nodeId: nId, widgetName }) => {
const widget = widgetValueStore.getWidget(gId, nId, widgetName)
return coerce(widget?.value ?? defaultValue)
})
}
const nId = nodeId.value
const node = nodeRef.value
if (nId == null || !node) return []
const values: T[] = []
for (let i = 0; i < maxCount; i++) {
const inputName = `${groupName}.${uniformPrefix}${i}`
const widget = widgetValueStore.getWidget(gId, nId, inputName)
if (widget !== undefined) {
values.push(coerce(widget.value))
continue
}
const slot = node.inputs?.findIndex((inp) => inp.name === inputName)
if (slot == null || slot < 0) break
const upstreamNode = node.getInputNode(slot)
if (!upstreamNode) break
const upstreamWidgets = widgetValueStore.getNodeWidgets(
gId,
upstreamNode.id as NodeId
)
if (upstreamWidgets.length === 0) break
values.push(coerce(upstreamWidgets[0].value))
}
return values
}
const toNumber = (v: unknown): number => Number(v) || 0
const toBool = (v: unknown): boolean => Boolean(v)
const floatValues = computed(() =>
collectValues(
uniformSources.value?.floats,
'floats',
'u_float',
rendererConfig.value.maxFloatUniforms,
toNumber,
0
)
)
const intValues = computed(() =>
collectValues(
uniformSources.value?.ints,
'ints',
'u_int',
rendererConfig.value.maxIntUniforms,
toNumber,
0
)
)
const boolValues = computed(() =>
collectValues(
uniformSources.value?.bools,
'bools',
'u_bool',
rendererConfig.value.maxBoolUniforms,
toBool,
false
)
)
const curveValues = computed((): CurveData[] => {
const gId = graphId.value
if (!gId) return []
const sources = uniformSources.value?.curves
if (sources && sources.length > 0) {
return sources
.map(({ nodeId: nId, widgetName }) => {
const widget = widgetValueStore.getWidget(gId, nId, widgetName)
return widget && isCurveData(widget.value)
? (widget.value as CurveData)
: null
})
.filter((v): v is CurveData => v !== null)
}
const node = nodeRef.value
const nId = nodeId.value
if (nId == null || !node?.inputs) return []
const values: CurveData[] = []
const max = rendererConfig.value.maxCurves
for (let i = 0; i < max; i++) {
const inputName = `curves.u_curve${i}`
const widget = widgetValueStore.getWidget(gId, nId, inputName)
if (widget && isCurveData(widget.value)) {
values.push(widget.value as CurveData)
continue
}
const slot = node.inputs.findIndex((inp) => inp.name === inputName)
if (slot < 0) break
const upstreamNode = node.getInputNode(slot)
if (!upstreamNode) break
const upstreamWidgets = widgetValueStore.getNodeWidgets(
gId,
upstreamNode.id as NodeId
)
const curveWidget = upstreamWidgets.find((w) => isCurveData(w.value))
if (!curveWidget) break
values.push(curveWidget.value as CurveData)
}
return values
})
return {
floatValues,
intValues,
boolValues,
curveValues
}
}

View File

@@ -261,17 +261,6 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
) {
const nodeLocatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
if (!nodeLocatorId) return
setNodePreviewsByLocatorId(nodeLocatorId, previewImages)
latestPreview.value = previewImages
}
/**
* Set node preview images by NodeLocatorId directly.
*/
function setNodePreviewsByLocatorId(
nodeLocatorId: NodeLocatorId,
previewImages: string[]
) {
const existingPreviews = app.nodePreviewImages[nodeLocatorId]
if (scheduledRevoke[nodeLocatorId]) {
scheduledRevoke[nodeLocatorId].stop()
@@ -285,6 +274,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
for (const url of previewImages) {
retainSharedObjectUrl(url)
}
latestPreview.value = previewImages
app.nodePreviewImages[nodeLocatorId] = previewImages
nodePreviewImages.value[nodeLocatorId] = previewImages
}
@@ -300,7 +290,22 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
nodeId: string | number,
previewImages: string[]
) {
setNodePreviewsByLocatorId(nodeIdToNodeLocatorId(nodeId), previewImages)
const nodeLocatorId = nodeIdToNodeLocatorId(nodeId)
const existingPreviews = app.nodePreviewImages[nodeLocatorId]
if (scheduledRevoke[nodeLocatorId]) {
scheduledRevoke[nodeLocatorId].stop()
delete scheduledRevoke[nodeLocatorId]
}
if (existingPreviews?.[Symbol.iterator]) {
for (const url of existingPreviews) {
releaseSharedObjectUrl(url)
}
}
for (const url of previewImages) {
retainSharedObjectUrl(url)
}
app.nodePreviewImages[nodeLocatorId] = previewImages
nodePreviewImages.value[nodeLocatorId] = previewImages
}
/**
@@ -481,7 +486,6 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
setNodeOutputs,
setNodeOutputsByExecutionId,
setNodePreviewsByExecutionId,
setNodePreviewsByLocatorId,
setNodePreviewsByNodeId,
updateNodeImages,
refreshNodeOutputs,
@@ -489,7 +493,6 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
// Cleanup
revokePreviewsByExecutionId,
revokePreviewsByLocatorId,
revokeAllPreviews,
revokeSubgraphPreviews,
removeNodeOutputs,

View File

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

View File

@@ -161,6 +161,7 @@ export default defineConfig({
ignored: [
'./browser_tests/**',
'./node_modules/**',
'./tests-ui/**',
'.eslintcache',
'.oxlintrc.json',
'*.config.{ts,mts}',