mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-21 07:14:11 +00:00
Merge remote-tracking branch 'origin/main' into bl-stacked-assets
This commit is contained in:
@@ -7,7 +7,7 @@ import { webSocketFixture } from '../fixtures/ws.ts'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
test.describe('Actionbar', () => {
|
||||
test.describe('Actionbar', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Bottom Panel Shortcuts', () => {
|
||||
test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Browser tab title', () => {
|
||||
test.describe('Browser tab title', { tag: '@smoke' }, () => {
|
||||
test.describe('Beta Menu', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
@@ -19,7 +19,7 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Change Tracker', () => {
|
||||
test.describe('Change Tracker', { tag: '@workflow' }, () => {
|
||||
test.describe('Undo/Redo', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
@@ -151,7 +151,7 @@ const customColorPalettes: Record<string, Palette> = {
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Color Palette', () => {
|
||||
test.describe('Color Palette', { tag: ['@screenshot', '@settings'] }, () => {
|
||||
test('Can show custom color palette', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.CustomColorPalettes', customColorPalettes)
|
||||
// Reload to apply the new setting. Setting Comfy.CustomColorPalettes directly
|
||||
@@ -194,104 +194,110 @@ test.describe('Color Palette', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Node Color Adjustments', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('nodes/every_node_color')
|
||||
})
|
||||
|
||||
test('should adjust opacity via node opacity setting', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setSetting('Comfy.Node.Opacity', 0.5)
|
||||
|
||||
// Drag mouse to force canvas to redraw
|
||||
await comfyPage.page.mouse.move(0, 0)
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-0.5.png')
|
||||
|
||||
await comfyPage.setSetting('Comfy.Node.Opacity', 1.0)
|
||||
|
||||
await comfyPage.page.mouse.move(8, 8)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-1.png')
|
||||
})
|
||||
|
||||
test('should persist color adjustments when changing themes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setSetting('Comfy.Node.Opacity', 0.2)
|
||||
await comfyPage.setSetting('Comfy.ColorPalette', 'arc')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.mouse.move(0, 0)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'node-opacity-0.2-arc-theme.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('should not serialize color adjustments in workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setSetting('Comfy.Node.Opacity', 0.5)
|
||||
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
|
||||
await comfyPage.nextFrame()
|
||||
const parsed = await (
|
||||
await comfyPage.page.waitForFunction(
|
||||
() => {
|
||||
const workflow = localStorage.getItem('workflow')
|
||||
if (!workflow) return null
|
||||
try {
|
||||
const data = JSON.parse(workflow)
|
||||
return Array.isArray(data?.nodes) ? data : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
{ timeout: 3000 }
|
||||
)
|
||||
).jsonValue()
|
||||
expect(parsed.nodes).toBeDefined()
|
||||
expect(Array.isArray(parsed.nodes)).toBe(true)
|
||||
for (const node of parsed.nodes) {
|
||||
if (node.bgcolor) expect(node.bgcolor).not.toMatch(/hsla/)
|
||||
if (node.color) expect(node.color).not.toMatch(/hsla/)
|
||||
}
|
||||
})
|
||||
|
||||
test('should lighten node colors when switching to light theme', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('node-lightened-colors.png')
|
||||
})
|
||||
|
||||
test.describe('Context menu color adjustments', () => {
|
||||
test.describe(
|
||||
'Node Color Adjustments',
|
||||
{ tag: ['@screenshot', '@settings'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('nodes/every_node_color')
|
||||
})
|
||||
|
||||
test('should adjust opacity via node opacity setting', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setSetting('Comfy.Node.Opacity', 0.5)
|
||||
|
||||
// Drag mouse to force canvas to redraw
|
||||
await comfyPage.page.mouse.move(0, 0)
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-0.5.png')
|
||||
|
||||
await comfyPage.setSetting('Comfy.Node.Opacity', 1.0)
|
||||
|
||||
await comfyPage.page.mouse.move(8, 8)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-1.png')
|
||||
})
|
||||
|
||||
test('should persist color adjustments when changing themes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setSetting('Comfy.Node.Opacity', 0.2)
|
||||
await comfyPage.setSetting('Comfy.ColorPalette', 'arc')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.mouse.move(0, 0)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'node-opacity-0.2-arc-theme.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('should not serialize color adjustments in workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setSetting('Comfy.Node.Opacity', 0.5)
|
||||
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
|
||||
await comfyPage.setSetting('Comfy.Node.Opacity', 0.3)
|
||||
const node = await comfyPage.getFirstNodeRef()
|
||||
await node?.clickContextMenuOption('Colors')
|
||||
await comfyPage.nextFrame()
|
||||
const parsed = await (
|
||||
await comfyPage.page.waitForFunction(
|
||||
() => {
|
||||
const workflow = localStorage.getItem('workflow')
|
||||
if (!workflow) return null
|
||||
try {
|
||||
const data = JSON.parse(workflow)
|
||||
return Array.isArray(data?.nodes) ? data : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
{ timeout: 3000 }
|
||||
)
|
||||
).jsonValue()
|
||||
expect(parsed.nodes).toBeDefined()
|
||||
expect(Array.isArray(parsed.nodes)).toBe(true)
|
||||
for (const node of parsed.nodes) {
|
||||
if (node.bgcolor) expect(node.bgcolor).not.toMatch(/hsla/)
|
||||
if (node.color) expect(node.color).not.toMatch(/hsla/)
|
||||
}
|
||||
})
|
||||
|
||||
test('should persist color adjustments when changing custom node colors', async ({
|
||||
test('should lighten node colors when switching to light theme', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page
|
||||
.locator('.litemenu-entry.submenu span:has-text("red")')
|
||||
.click()
|
||||
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'node-opacity-0.3-color-changed.png'
|
||||
'node-lightened-colors.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('should persist color adjustments when removing custom node color', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page
|
||||
.locator('.litemenu-entry.submenu span:has-text("No color")')
|
||||
.click()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'node-opacity-0.3-color-removed.png'
|
||||
)
|
||||
test.describe('Context menu color adjustments', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
|
||||
await comfyPage.setSetting('Comfy.Node.Opacity', 0.3)
|
||||
const node = await comfyPage.getFirstNodeRef()
|
||||
await node?.clickContextMenuOption('Colors')
|
||||
})
|
||||
|
||||
test('should persist color adjustments when changing custom node colors', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page
|
||||
.locator('.litemenu-entry.submenu span:has-text("red")')
|
||||
.click()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'node-opacity-0.3-color-changed.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('should persist color adjustments when removing custom node color', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page
|
||||
.locator('.litemenu-entry.submenu span:has-text("No color")')
|
||||
.click()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'node-opacity-0.3-color-removed.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Keybindings', () => {
|
||||
test.describe('Keybindings', { tag: '@keyboard' }, () => {
|
||||
test('Should execute command', async ({ comfyPage }) => {
|
||||
await comfyPage.registerCommand('TestCommand', () => {
|
||||
window['foo'] = true
|
||||
|
||||
@@ -6,7 +6,7 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Copy Paste', () => {
|
||||
test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
|
||||
test('Can copy and paste node', async ({ comfyPage }) => {
|
||||
await comfyPage.clickEmptyLatentNode()
|
||||
await comfyPage.page.mouse.move(10, 10)
|
||||
|
||||
@@ -22,7 +22,7 @@ async function verifyCustomIconSvg(iconElement: Locator) {
|
||||
expect(decodedSvg).toContain("<svg xmlns='http://www.w3.org/2000/svg'")
|
||||
}
|
||||
|
||||
test.describe('Custom Icons', () => {
|
||||
test.describe('Custom Icons', { tag: '@settings' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Load workflow warning', () => {
|
||||
test.describe('Load workflow warning', { tag: '@ui' }, () => {
|
||||
test('Should display a warning when loading a workflow with missing nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -6,7 +6,7 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('DOM Widget', () => {
|
||||
test.describe('DOM Widget', { tag: '@widget' }, () => {
|
||||
test('Collapsed multiline textarea is not visible', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('widgets/collapsed_multiline')
|
||||
const textareaWidget = comfyPage.page.locator('.comfy-multiline-input')
|
||||
@@ -29,12 +29,16 @@ test.describe('DOM Widget', () => {
|
||||
await expect(lastMultiline).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Position update when entering focus mode', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.executeCommand('Workspace.ToggleFocusMode')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('focus-mode-on.png')
|
||||
})
|
||||
test(
|
||||
'Position update when entering focus mode',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.executeCommand('Workspace.ToggleFocusMode')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('focus-mode-on.png')
|
||||
}
|
||||
)
|
||||
|
||||
// No DOM widget should be created by creation of interim LGraphNode objects.
|
||||
test('Copy node with DOM widget by dragging + alt', async ({ comfyPage }) => {
|
||||
|
||||
@@ -6,40 +6,48 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Execution', () => {
|
||||
test('Report error on unconnected slot', async ({ comfyPage }) => {
|
||||
await comfyPage.disconnectEdge()
|
||||
await comfyPage.clickEmptySpace()
|
||||
test.describe('Execution', { tag: ['@smoke', '@workflow'] }, () => {
|
||||
test(
|
||||
'Report error on unconnected slot',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.disconnectEdge()
|
||||
await comfyPage.clickEmptySpace()
|
||||
|
||||
await comfyPage.executeCommand('Comfy.QueuePrompt')
|
||||
await expect(comfyPage.page.locator('.comfy-error-report')).toBeVisible()
|
||||
await comfyPage.page.locator('.p-dialog-close-button').click()
|
||||
await comfyPage.page.locator('.comfy-error-report').waitFor({
|
||||
state: 'hidden'
|
||||
})
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'execution-error-unconnected-slot.png'
|
||||
)
|
||||
})
|
||||
await comfyPage.executeCommand('Comfy.QueuePrompt')
|
||||
await expect(comfyPage.page.locator('.comfy-error-report')).toBeVisible()
|
||||
await comfyPage.page.locator('.p-dialog-close-button').click()
|
||||
await comfyPage.page.locator('.comfy-error-report').waitFor({
|
||||
state: 'hidden'
|
||||
})
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'execution-error-unconnected-slot.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Execute to selected output nodes', () => {
|
||||
test('Execute to selected output nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('execution/partial_execution')
|
||||
const input = await comfyPage.getNodeRefById(3)
|
||||
const output1 = await comfyPage.getNodeRefById(1)
|
||||
const output2 = await comfyPage.getNodeRefById(4)
|
||||
expect(await (await input.getWidget(0)).getValue()).toBe('foo')
|
||||
expect(await (await output1.getWidget(0)).getValue()).toBe('')
|
||||
expect(await (await output2.getWidget(0)).getValue()).toBe('')
|
||||
|
||||
await output1.click('title')
|
||||
|
||||
await comfyPage.executeCommand('Comfy.QueueSelectedOutputNodes')
|
||||
await expect(async () => {
|
||||
test.describe(
|
||||
'Execute to selected output nodes',
|
||||
{ tag: ['@smoke', '@workflow'] },
|
||||
() => {
|
||||
test('Execute to selected output nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('execution/partial_execution')
|
||||
const input = await comfyPage.getNodeRefById(3)
|
||||
const output1 = await comfyPage.getNodeRefById(1)
|
||||
const output2 = await comfyPage.getNodeRefById(4)
|
||||
expect(await (await input.getWidget(0)).getValue()).toBe('foo')
|
||||
expect(await (await output1.getWidget(0)).getValue()).toBe('foo')
|
||||
expect(await (await output1.getWidget(0)).getValue()).toBe('')
|
||||
expect(await (await output2.getWidget(0)).getValue()).toBe('')
|
||||
}).toPass({ timeout: 2_000 })
|
||||
})
|
||||
})
|
||||
|
||||
await output1.click('title')
|
||||
|
||||
await comfyPage.executeCommand('Comfy.QueueSelectedOutputNodes')
|
||||
await expect(async () => {
|
||||
expect(await (await input.getWidget(0)).getValue()).toBe('foo')
|
||||
expect(await (await output1.getWidget(0)).getValue()).toBe('foo')
|
||||
expect(await (await output2.getWidget(0)).getValue()).toBe('')
|
||||
}).toPass({ timeout: 2_000 })
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Feature Flags', () => {
|
||||
test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
||||
test('Client and server exchange feature flags on connection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -6,7 +6,7 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Graph', () => {
|
||||
test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
||||
// Should be able to fix link input slot index after swap the input order
|
||||
// Ref: https://github.com/Comfy-Org/ComfyUI_frontend/issues/3348
|
||||
test('Fix link input slots', async ({ comfyPage }) => {
|
||||
|
||||
@@ -6,7 +6,7 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Graph Canvas Menu', () => {
|
||||
test.describe('Graph Canvas Menu', { tag: ['@screenshot', '@canvas'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Set link render mode to spline to make sure it's not affected by other tests'
|
||||
// side effects.
|
||||
@@ -15,29 +15,33 @@ test.describe('Graph Canvas Menu', () => {
|
||||
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
|
||||
})
|
||||
|
||||
test('Can toggle link visibility', async ({ comfyPage }) => {
|
||||
const button = comfyPage.page.getByTestId('toggle-link-visibility-button')
|
||||
await button.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'canvas-with-hidden-links.png'
|
||||
)
|
||||
const hiddenLinkRenderMode = await comfyPage.page.evaluate(() => {
|
||||
return window['LiteGraph'].HIDDEN_LINK
|
||||
})
|
||||
expect(await comfyPage.getSetting('Comfy.LinkRenderMode')).toBe(
|
||||
hiddenLinkRenderMode
|
||||
)
|
||||
test(
|
||||
'Can toggle link visibility',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const button = comfyPage.page.getByTestId('toggle-link-visibility-button')
|
||||
await button.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'canvas-with-hidden-links.png'
|
||||
)
|
||||
const hiddenLinkRenderMode = await comfyPage.page.evaluate(() => {
|
||||
return window['LiteGraph'].HIDDEN_LINK
|
||||
})
|
||||
expect(await comfyPage.getSetting('Comfy.LinkRenderMode')).toBe(
|
||||
hiddenLinkRenderMode
|
||||
)
|
||||
|
||||
await button.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'canvas-with-visible-links.png'
|
||||
)
|
||||
expect(await comfyPage.getSetting('Comfy.LinkRenderMode')).not.toBe(
|
||||
hiddenLinkRenderMode
|
||||
)
|
||||
})
|
||||
await button.click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'canvas-with-visible-links.png'
|
||||
)
|
||||
expect(await comfyPage.getSetting('Comfy.LinkRenderMode')).not.toBe(
|
||||
hiddenLinkRenderMode
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('Toggle minimap button is clickable and has correct test id', async ({
|
||||
comfyPage
|
||||
|
||||
@@ -8,7 +8,7 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Group Node', () => {
|
||||
test.describe('Group Node', { tag: '@node' }, () => {
|
||||
test.describe('Node library sidebar', () => {
|
||||
const groupNodeName = 'DefautWorkflowGroupNode'
|
||||
const groupNodeCategory = 'group nodes>workflow'
|
||||
@@ -89,16 +89,20 @@ test.describe('Group Node', () => {
|
||||
// does not have a v-model on the query, so we cannot observe the raw
|
||||
// query update, and thus cannot set the spinning state between the raw query
|
||||
// update and the debounced search update.
|
||||
test.skip('Can be added to canvas using search', async ({ comfyPage }) => {
|
||||
const groupNodeName = 'DefautWorkflowGroupNode'
|
||||
await comfyPage.convertAllNodesToGroupNode(groupNodeName)
|
||||
await comfyPage.doubleClickCanvas()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode(groupNodeName)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'group-node-copy-added-from-search.png'
|
||||
)
|
||||
})
|
||||
test.skip(
|
||||
'Can be added to canvas using search',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const groupNodeName = 'DefautWorkflowGroupNode'
|
||||
await comfyPage.convertAllNodesToGroupNode(groupNodeName)
|
||||
await comfyPage.doubleClickCanvas()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode(groupNodeName)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'group-node-copy-added-from-search.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('Displays tooltip on title hover', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.EnableTooltips', true)
|
||||
|
||||
@@ -13,7 +13,7 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Item Interaction', () => {
|
||||
test.describe('Item Interaction', { tag: ['@screenshot', '@node'] }, () => {
|
||||
test('Can select/delete all items', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('groups/mixed_graph_items')
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
@@ -60,13 +60,17 @@ test.describe('Node Interaction', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('@2x Can highlight selected', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('default.png')
|
||||
await comfyPage.clickTextEncodeNode1()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('selected-node1.png')
|
||||
await comfyPage.clickTextEncodeNode2()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('selected-node2.png')
|
||||
})
|
||||
test(
|
||||
'@2x Can highlight selected',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('default.png')
|
||||
await comfyPage.clickTextEncodeNode1()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('selected-node1.png')
|
||||
await comfyPage.clickTextEncodeNode2()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('selected-node2.png')
|
||||
}
|
||||
)
|
||||
|
||||
const dragSelectNodes = async (
|
||||
comfyPage: ComfyPage,
|
||||
@@ -150,12 +154,12 @@ test.describe('Node Interaction', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('Can drag node', async ({ comfyPage }) => {
|
||||
test('Can drag node', { tag: '@screenshot' }, async ({ comfyPage }) => {
|
||||
await comfyPage.dragNode2()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png')
|
||||
})
|
||||
|
||||
test.describe('Edge Interaction', () => {
|
||||
test.describe('Edge Interaction', { tag: '@screenshot' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.LinkRelease.Action', 'no action')
|
||||
await comfyPage.setSetting('Comfy.LinkRelease.ActionShift', 'no action')
|
||||
@@ -222,12 +226,18 @@ test.describe('Node Interaction', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('Can adjust widget value', async ({ comfyPage }) => {
|
||||
await comfyPage.adjustWidgetValue()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('adjusted-widget-value.png')
|
||||
})
|
||||
test(
|
||||
'Can adjust widget value',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.adjustWidgetValue()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'adjusted-widget-value.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('Link snap to slot', async ({ comfyPage }) => {
|
||||
test('Link snap to slot', { tag: '@screenshot' }, async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('links/snap_to_slot')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('snap_to_slot.png')
|
||||
|
||||
@@ -244,57 +254,67 @@ test.describe('Node Interaction', () => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('snap_to_slot_linked.png')
|
||||
})
|
||||
|
||||
test('Can batch move links by drag with shift', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('links/batch_move_links')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('batch_move_links.png')
|
||||
test(
|
||||
'Can batch move links by drag with shift',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('links/batch_move_links')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('batch_move_links.png')
|
||||
|
||||
const outputSlot1Pos = {
|
||||
x: 304,
|
||||
y: 127
|
||||
const outputSlot1Pos = {
|
||||
x: 304,
|
||||
y: 127
|
||||
}
|
||||
const outputSlot2Pos = {
|
||||
x: 307,
|
||||
y: 310
|
||||
}
|
||||
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.dragAndDrop(outputSlot1Pos, outputSlot2Pos)
|
||||
await comfyPage.page.keyboard.up('Shift')
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'batch_move_links_moved.png'
|
||||
)
|
||||
}
|
||||
const outputSlot2Pos = {
|
||||
x: 307,
|
||||
y: 310
|
||||
)
|
||||
|
||||
test(
|
||||
'Can batch disconnect links with ctrl+alt+click',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const loadCheckpointClipSlotPos = {
|
||||
x: 332,
|
||||
y: 508
|
||||
}
|
||||
await comfyPage.canvas.click({
|
||||
modifiers: ['Control', 'Alt'],
|
||||
position: loadCheckpointClipSlotPos
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'batch-disconnect-links-disconnected.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.dragAndDrop(outputSlot1Pos, outputSlot2Pos)
|
||||
await comfyPage.page.keyboard.up('Shift')
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'batch_move_links_moved.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Can batch disconnect links with ctrl+alt+click', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const loadCheckpointClipSlotPos = {
|
||||
x: 332,
|
||||
y: 508
|
||||
test(
|
||||
'Can toggle dom widget node open/closed',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('default.png')
|
||||
await comfyPage.clickTextEncodeNodeToggler()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'text-encode-toggled-off.png'
|
||||
)
|
||||
await comfyPage.delay(1000)
|
||||
await comfyPage.clickTextEncodeNodeToggler()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'text-encode-toggled-back-open.png'
|
||||
)
|
||||
}
|
||||
await comfyPage.canvas.click({
|
||||
modifiers: ['Control', 'Alt'],
|
||||
position: loadCheckpointClipSlotPos
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'batch-disconnect-links-disconnected.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Can toggle dom widget node open/closed', async ({ comfyPage }) => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('default.png')
|
||||
await comfyPage.clickTextEncodeNodeToggler()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'text-encode-toggled-off.png'
|
||||
)
|
||||
await comfyPage.delay(1000)
|
||||
await comfyPage.clickTextEncodeNodeToggler()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'text-encode-toggled-back-open.png'
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
test('Can close prompt dialog with canvas click (number widget)', async ({
|
||||
comfyPage
|
||||
@@ -341,19 +361,23 @@ test.describe('Node Interaction', () => {
|
||||
await expect(legacyPrompt).toBeHidden()
|
||||
})
|
||||
|
||||
test('Can double click node title to edit', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('nodes/single_ksampler')
|
||||
await comfyPage.canvas.dblclick({
|
||||
position: {
|
||||
x: 50,
|
||||
y: 10
|
||||
},
|
||||
delay: 5
|
||||
})
|
||||
await comfyPage.page.keyboard.type('Hello World')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('node-title-edited.png')
|
||||
})
|
||||
test(
|
||||
'Can double click node title to edit',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('nodes/single_ksampler')
|
||||
await comfyPage.canvas.dblclick({
|
||||
position: {
|
||||
x: 50,
|
||||
y: 10
|
||||
},
|
||||
delay: 5
|
||||
})
|
||||
await comfyPage.page.keyboard.type('Hello World')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('node-title-edited.png')
|
||||
}
|
||||
)
|
||||
|
||||
test('Double click node body does not trigger edit', async ({
|
||||
comfyPage
|
||||
@@ -369,29 +393,41 @@ test.describe('Node Interaction', () => {
|
||||
expect(await comfyPage.page.locator('.node-title-editor').count()).toBe(0)
|
||||
})
|
||||
|
||||
test('Can group selected nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.GroupSelectedNodes.Padding', 10)
|
||||
await comfyPage.select2Nodes()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.keyboard.press('KeyG')
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
await comfyPage.nextFrame()
|
||||
// Confirm group title
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('group-selected-nodes.png')
|
||||
})
|
||||
test(
|
||||
'Can group selected nodes',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.GroupSelectedNodes.Padding', 10)
|
||||
await comfyPage.select2Nodes()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.keyboard.press('KeyG')
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
await comfyPage.nextFrame()
|
||||
// Confirm group title
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'group-selected-nodes.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('Can fit group to contents', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('groups/oversized_group')
|
||||
await comfyPage.ctrlA()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.executeCommand('Comfy.Graph.FitGroupToContents')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('group-fit-to-contents.png')
|
||||
})
|
||||
test(
|
||||
'Can fit group to contents',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('groups/oversized_group')
|
||||
await comfyPage.ctrlA()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.executeCommand('Comfy.Graph.FitGroupToContents')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'group-fit-to-contents.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('Can pin/unpin nodes', async ({ comfyPage }) => {
|
||||
test('Can pin/unpin nodes', { tag: '@screenshot' }, async ({ comfyPage }) => {
|
||||
await comfyPage.select2Nodes()
|
||||
await comfyPage.executeCommand('Comfy.Canvas.ToggleSelectedNodes.Pin')
|
||||
await comfyPage.nextFrame()
|
||||
@@ -401,20 +437,22 @@ test.describe('Node Interaction', () => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('nodes-unpinned.png')
|
||||
})
|
||||
|
||||
test('Can bypass/unbypass nodes with keyboard shortcut', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.select2Nodes()
|
||||
await comfyPage.canvas.press('Control+b')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('nodes-bypassed.png')
|
||||
await comfyPage.canvas.press('Control+b')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('nodes-unbypassed.png')
|
||||
})
|
||||
test(
|
||||
'Can bypass/unbypass nodes with keyboard shortcut',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.select2Nodes()
|
||||
await comfyPage.canvas.press('Control+b')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('nodes-bypassed.png')
|
||||
await comfyPage.canvas.press('Control+b')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('nodes-unbypassed.png')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Group Interaction', () => {
|
||||
test.describe('Group Interaction', { tag: '@screenshot' }, () => {
|
||||
test('Can double click group title to edit', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('groups/single_group')
|
||||
await comfyPage.canvas.dblclick({
|
||||
@@ -430,7 +468,7 @@ test.describe('Group Interaction', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Canvas Interaction', () => {
|
||||
test.describe('Canvas Interaction', { tag: '@screenshot' }, () => {
|
||||
test('Can zoom in/out', async ({ comfyPage }) => {
|
||||
await comfyPage.zoom(-100)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in.png')
|
||||
@@ -632,7 +670,7 @@ test.describe('Widget Interaction', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Load workflow', () => {
|
||||
test.describe('Load workflow', { tag: '@screenshot' }, () => {
|
||||
test('Can load workflow with string node id', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('nodes/string_node_id')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('string_node_id.png')
|
||||
@@ -824,7 +862,7 @@ test.describe('Viewport settings', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Canvas Navigation', () => {
|
||||
test.describe('Canvas Navigation', { tag: '@screenshot' }, () => {
|
||||
test.describe('Legacy Mode', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.Canvas.NavigationMode', 'legacy')
|
||||
|
||||
@@ -6,7 +6,7 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Keybindings', () => {
|
||||
test.describe('Keybindings', { tag: '@keyboard' }, () => {
|
||||
test('Should not trigger non-modifier keybinding when typing in input fields', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -14,7 +14,7 @@ function listenForEvent(): Promise<Event> {
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Canvas Event', () => {
|
||||
test.describe('Canvas Event', { tag: '@canvas' }, () => {
|
||||
test('Emit litegraph:canvas empty-release', async ({ comfyPage }) => {
|
||||
const eventPromise = comfyPage.page.evaluate(listenForEvent)
|
||||
const disconnectPromise = comfyPage.disconnectEdge()
|
||||
|
||||
@@ -6,46 +6,50 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Load Workflow in Media', () => {
|
||||
const fileNames = [
|
||||
'workflow.webp',
|
||||
'edited_workflow.webp',
|
||||
'no_workflow.webp',
|
||||
'large_workflow.webp',
|
||||
'workflow_prompt_parameters.png',
|
||||
'workflow.webm',
|
||||
// Skipped due to 3d widget unstable visual result.
|
||||
// 3d widget shows grid after fully loaded.
|
||||
// 'workflow.glb',
|
||||
'workflow.mp4',
|
||||
'workflow.mov',
|
||||
'workflow.m4v',
|
||||
'workflow.svg'
|
||||
// TODO: Re-enable after fixing test asset to use core nodes only
|
||||
// Currently opens missing nodes dialog which is outside scope of AVIF loading functionality
|
||||
// 'workflow.avif'
|
||||
]
|
||||
fileNames.forEach(async (fileName) => {
|
||||
test(`Load workflow in ${fileName} (drop from filesystem)`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.dragAndDropFile(`workflowInMedia/${fileName}`)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(`${fileName}.png`)
|
||||
test.describe(
|
||||
'Load Workflow in Media',
|
||||
{ tag: ['@screenshot', '@workflow'] },
|
||||
() => {
|
||||
const fileNames = [
|
||||
'workflow.webp',
|
||||
'edited_workflow.webp',
|
||||
'no_workflow.webp',
|
||||
'large_workflow.webp',
|
||||
'workflow_prompt_parameters.png',
|
||||
'workflow.webm',
|
||||
// Skipped due to 3d widget unstable visual result.
|
||||
// 3d widget shows grid after fully loaded.
|
||||
// 'workflow.glb',
|
||||
'workflow.mp4',
|
||||
'workflow.mov',
|
||||
'workflow.m4v',
|
||||
'workflow.svg'
|
||||
// TODO: Re-enable after fixing test asset to use core nodes only
|
||||
// Currently opens missing nodes dialog which is outside scope of AVIF loading functionality
|
||||
// 'workflow.avif'
|
||||
]
|
||||
fileNames.forEach(async (fileName) => {
|
||||
test(`Load workflow in ${fileName} (drop from filesystem)`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.dragAndDropFile(`workflowInMedia/${fileName}`)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(`${fileName}.png`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const urls = [
|
||||
'https://comfyanonymous.github.io/ComfyUI_examples/hidream/hidream_dev_example.png'
|
||||
]
|
||||
urls.forEach(async (url) => {
|
||||
test(`Load workflow from URL ${url} (drop from different browser tabs)`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.dragAndDropURL(url)
|
||||
const readableName = url.split('/').pop()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
`dropped_workflow_url_${readableName}.png`
|
||||
)
|
||||
const urls = [
|
||||
'https://comfyanonymous.github.io/ComfyUI_examples/hidream/hidream_dev_example.png'
|
||||
]
|
||||
urls.forEach(async (url) => {
|
||||
test(`Load workflow from URL ${url} (drop from different browser tabs)`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.dragAndDropURL(url)
|
||||
const readableName = url.split('/').pop()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
`dropped_workflow_url_${readableName}.png`
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('LOD Threshold', () => {
|
||||
test.describe('LOD Threshold', { tag: ['@screenshot', '@canvas'] }, () => {
|
||||
test('Should switch to low quality mode at correct zoom threshold', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -149,53 +149,55 @@ test.describe('LOD Threshold', () => {
|
||||
expect(state.scale).toBeLessThan(0.2) // Very zoomed out
|
||||
})
|
||||
|
||||
test('Should show visual difference between LOD on and off', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Load a workflow with text-heavy nodes for clear visual difference
|
||||
await comfyPage.loadWorkflow('default')
|
||||
test(
|
||||
'Should show visual difference between LOD on and off',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
// Load a workflow with text-heavy nodes for clear visual difference
|
||||
await comfyPage.loadWorkflow('default')
|
||||
|
||||
// Set zoom level clearly below the threshold to ensure LOD activates
|
||||
const targetZoom = 0.4 // Well below default threshold of ~0.571
|
||||
// Set zoom level clearly below the threshold to ensure LOD activates
|
||||
const targetZoom = 0.4 // Well below default threshold of ~0.571
|
||||
|
||||
// Zoom to target level
|
||||
await comfyPage.page.evaluate((zoom) => {
|
||||
window['app'].canvas.ds.scale = zoom
|
||||
window['app'].canvas.setDirty(true, true)
|
||||
}, targetZoom)
|
||||
await comfyPage.nextFrame()
|
||||
// Zoom to target level
|
||||
await comfyPage.page.evaluate((zoom) => {
|
||||
window['app'].canvas.ds.scale = zoom
|
||||
window['app'].canvas.setDirty(true, true)
|
||||
}, targetZoom)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Take snapshot with LOD active (default 8px setting)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'lod-comparison-low-quality.png'
|
||||
)
|
||||
// Take snapshot with LOD active (default 8px setting)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'lod-comparison-low-quality.png'
|
||||
)
|
||||
|
||||
const lowQualityState = await comfyPage.page.evaluate(() => {
|
||||
const canvas = window['app'].canvas
|
||||
return {
|
||||
lowQuality: canvas.low_quality,
|
||||
scale: canvas.ds.scale
|
||||
}
|
||||
})
|
||||
expect(lowQualityState.lowQuality).toBe(true)
|
||||
const lowQualityState = await comfyPage.page.evaluate(() => {
|
||||
const canvas = window['app'].canvas
|
||||
return {
|
||||
lowQuality: canvas.low_quality,
|
||||
scale: canvas.ds.scale
|
||||
}
|
||||
})
|
||||
expect(lowQualityState.lowQuality).toBe(true)
|
||||
|
||||
// Disable LOD to see high quality at same zoom
|
||||
await comfyPage.setSetting('LiteGraph.Canvas.MinFontSizeForLOD', 0)
|
||||
await comfyPage.nextFrame()
|
||||
// Disable LOD to see high quality at same zoom
|
||||
await comfyPage.setSetting('LiteGraph.Canvas.MinFontSizeForLOD', 0)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Take snapshot with LOD disabled (full quality at same zoom)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'lod-comparison-high-quality.png'
|
||||
)
|
||||
// Take snapshot with LOD disabled (full quality at same zoom)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'lod-comparison-high-quality.png'
|
||||
)
|
||||
|
||||
const highQualityState = await comfyPage.page.evaluate(() => {
|
||||
const canvas = window['app'].canvas
|
||||
return {
|
||||
lowQuality: canvas.low_quality,
|
||||
scale: canvas.ds.scale
|
||||
}
|
||||
})
|
||||
expect(highQualityState.lowQuality).toBe(false)
|
||||
expect(highQualityState.scale).toBeCloseTo(targetZoom, 2)
|
||||
})
|
||||
const highQualityState = await comfyPage.page.evaluate(() => {
|
||||
const canvas = window['app'].canvas
|
||||
return {
|
||||
lowQuality: canvas.low_quality,
|
||||
scale: canvas.ds.scale
|
||||
}
|
||||
})
|
||||
expect(highQualityState.lowQuality).toBe(false)
|
||||
expect(highQualityState.scale).toBeCloseTo(targetZoom, 2)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Menu', () => {
|
||||
test.describe('Menu', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Minimap', () => {
|
||||
test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setSetting('Comfy.Minimap.Visible', true)
|
||||
|
||||
@@ -1,35 +1,39 @@
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
test.describe('Mobile Baseline Snapshots', () => {
|
||||
test('@mobile empty canvas', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.ConfirmClear', false)
|
||||
await comfyPage.executeCommand('Comfy.ClearWorkflow')
|
||||
await expect(async () => {
|
||||
expect(await comfyPage.getGraphNodesCount()).toBe(0)
|
||||
}).toPass({ timeout: 256 })
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('mobile-empty-canvas.png')
|
||||
})
|
||||
test.describe(
|
||||
'Mobile Baseline Snapshots',
|
||||
{ tag: ['@mobile', '@screenshot'] },
|
||||
() => {
|
||||
test('@mobile empty canvas', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.ConfirmClear', false)
|
||||
await comfyPage.executeCommand('Comfy.ClearWorkflow')
|
||||
await expect(async () => {
|
||||
expect(await comfyPage.getGraphNodesCount()).toBe(0)
|
||||
}).toPass({ timeout: 256 })
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('mobile-empty-canvas.png')
|
||||
})
|
||||
|
||||
test('@mobile default workflow', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('default')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'mobile-default-workflow.png'
|
||||
)
|
||||
})
|
||||
test('@mobile default workflow', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('default')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'mobile-default-workflow.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('@mobile settings dialog', async ({ comfyPage }) => {
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.nextFrame()
|
||||
test('@mobile settings dialog', async ({ comfyPage }) => {
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.settingDialog.root).toHaveScreenshot(
|
||||
'mobile-settings-dialog.png',
|
||||
{
|
||||
mask: [
|
||||
comfyPage.settingDialog.root.getByTestId('current-user-indicator')
|
||||
]
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
await expect(comfyPage.settingDialog.root).toHaveScreenshot(
|
||||
'mobile-settings-dialog.png',
|
||||
{
|
||||
mask: [
|
||||
comfyPage.settingDialog.root.getByTestId('current-user-indicator')
|
||||
]
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Node Badge', () => {
|
||||
test.describe('Node Badge', { tag: ['@screenshot', '@smoke', '@node'] }, () => {
|
||||
test('Can add badge', async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const LGraphBadge = window['LGraphBadge']
|
||||
@@ -66,50 +66,60 @@ test.describe('Node Badge', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Node source badge', () => {
|
||||
Object.values(NodeBadgeMode).forEach(async (mode) => {
|
||||
test(`Shows node badges (${mode})`, async ({ comfyPage }) => {
|
||||
// Execution error workflow has both custom node and core node.
|
||||
await comfyPage.loadWorkflow('nodes/execution_error')
|
||||
await comfyPage.setSetting('Comfy.NodeBadge.NodeSourceBadgeMode', mode)
|
||||
await comfyPage.setSetting('Comfy.NodeBadge.NodeIdBadgeMode', mode)
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.resetView()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(`node-badge-${mode}.png`)
|
||||
test.describe(
|
||||
'Node source badge',
|
||||
{ tag: ['@screenshot', '@smoke', '@node'] },
|
||||
() => {
|
||||
Object.values(NodeBadgeMode).forEach(async (mode) => {
|
||||
test(`Shows node badges (${mode})`, async ({ comfyPage }) => {
|
||||
// Execution error workflow has both custom node and core node.
|
||||
await comfyPage.loadWorkflow('nodes/execution_error')
|
||||
await comfyPage.setSetting('Comfy.NodeBadge.NodeSourceBadgeMode', mode)
|
||||
await comfyPage.setSetting('Comfy.NodeBadge.NodeIdBadgeMode', mode)
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.resetView()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
`node-badge-${mode}.png`
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test.describe('Node badge color', () => {
|
||||
test('Can show node badge with unknown color palette', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setSetting(
|
||||
'Comfy.NodeBadge.NodeIdBadgeMode',
|
||||
NodeBadgeMode.ShowAll
|
||||
)
|
||||
await comfyPage.setSetting('Comfy.ColorPalette', 'unknown')
|
||||
await comfyPage.nextFrame()
|
||||
// Click empty space to trigger canvas re-render.
|
||||
await comfyPage.clickEmptySpace()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'node-badge-unknown-color-palette.png'
|
||||
)
|
||||
})
|
||||
test.describe(
|
||||
'Node badge color',
|
||||
{ tag: ['@screenshot', '@smoke', '@node'] },
|
||||
() => {
|
||||
test('Can show node badge with unknown color palette', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setSetting(
|
||||
'Comfy.NodeBadge.NodeIdBadgeMode',
|
||||
NodeBadgeMode.ShowAll
|
||||
)
|
||||
await comfyPage.setSetting('Comfy.ColorPalette', 'unknown')
|
||||
await comfyPage.nextFrame()
|
||||
// Click empty space to trigger canvas re-render.
|
||||
await comfyPage.clickEmptySpace()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'node-badge-unknown-color-palette.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Can show node badge with light color palette', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setSetting(
|
||||
'Comfy.NodeBadge.NodeIdBadgeMode',
|
||||
NodeBadgeMode.ShowAll
|
||||
)
|
||||
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
|
||||
await comfyPage.nextFrame()
|
||||
// Click empty space to trigger canvas re-render.
|
||||
await comfyPage.clickEmptySpace()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'node-badge-light-color-palette.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
test('Can show node badge with light color palette', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setSetting(
|
||||
'Comfy.NodeBadge.NodeIdBadgeMode',
|
||||
NodeBadgeMode.ShowAll
|
||||
)
|
||||
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
|
||||
await comfyPage.nextFrame()
|
||||
// Click empty space to trigger canvas re-render.
|
||||
await comfyPage.clickEmptySpace()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'node-badge-light-color-palette.png'
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
// If an input is optional by node definition, it should be shown as
|
||||
// a hollow circle no matter what shape it was defined in the workflow JSON.
|
||||
test.describe('Optional input', () => {
|
||||
test.describe('Optional input', { tag: ['@screenshot', '@node'] }, () => {
|
||||
test('No shape specified', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('inputs/optional_input_no_shape')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('optional_input.png')
|
||||
|
||||
@@ -23,7 +23,7 @@ async function selectNodeWithPan(comfyPage: ComfyPage, nodeRef: NodeReference) {
|
||||
await nodeRef.click('title')
|
||||
}
|
||||
|
||||
test.describe('Node Help', () => {
|
||||
test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
@@ -7,7 +7,7 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Node search box', () => {
|
||||
test.describe('Node search box', { tag: '@node' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.LinkRelease.Action', 'search box')
|
||||
await comfyPage.setSetting('Comfy.LinkRelease.ActionShift', 'search box')
|
||||
@@ -46,14 +46,14 @@ test.describe('Node search box', () => {
|
||||
await expect(comfyPage.searchBox.input).toBeVisible()
|
||||
})
|
||||
|
||||
test('Can add node', async ({ comfyPage }) => {
|
||||
test('Can add node', { tag: '@screenshot' }, async ({ comfyPage }) => {
|
||||
await comfyPage.doubleClickCanvas()
|
||||
await expect(comfyPage.searchBox.input).toHaveCount(1)
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('added-node.png')
|
||||
})
|
||||
|
||||
test('Can auto link node', async ({ comfyPage }) => {
|
||||
test('Can auto link node', { tag: '@screenshot' }, async ({ comfyPage }) => {
|
||||
await comfyPage.disconnectEdge()
|
||||
// Select the second item as the first item is always reroute
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('CLIPTextEncode', {
|
||||
@@ -62,41 +62,47 @@ test.describe('Node search box', () => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('auto-linked-node.png')
|
||||
})
|
||||
|
||||
test('Can auto link batch moved node', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('links/batch_move_links')
|
||||
test(
|
||||
'Can auto link batch moved node',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('links/batch_move_links')
|
||||
|
||||
const outputSlot1Pos = {
|
||||
x: 304,
|
||||
y: 127
|
||||
const outputSlot1Pos = {
|
||||
x: 304,
|
||||
y: 127
|
||||
}
|
||||
const emptySpacePos = {
|
||||
x: 5,
|
||||
y: 5
|
||||
}
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.dragAndDrop(outputSlot1Pos, emptySpacePos)
|
||||
await comfyPage.page.keyboard.up('Shift')
|
||||
|
||||
// Select the second item as the first item is always reroute
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('Load Checkpoint', {
|
||||
suggestionIndex: 0
|
||||
})
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'auto-linked-node-batch.png'
|
||||
)
|
||||
}
|
||||
const emptySpacePos = {
|
||||
x: 5,
|
||||
y: 5
|
||||
)
|
||||
|
||||
test(
|
||||
'Link release connecting to node with no slots',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.disconnectEdge()
|
||||
await expect(comfyPage.searchBox.input).toHaveCount(1)
|
||||
await comfyPage.page.locator('.p-chip-remove-icon').click()
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'added-node-no-connection.png'
|
||||
)
|
||||
}
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.dragAndDrop(outputSlot1Pos, emptySpacePos)
|
||||
await comfyPage.page.keyboard.up('Shift')
|
||||
|
||||
// Select the second item as the first item is always reroute
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('Load Checkpoint', {
|
||||
suggestionIndex: 0
|
||||
})
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'auto-linked-node-batch.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Link release connecting to node with no slots', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.disconnectEdge()
|
||||
await expect(comfyPage.searchBox.input).toHaveCount(1)
|
||||
await comfyPage.page.locator('.p-chip-remove-icon').click()
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'added-node-no-connection.png'
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
test('Has correct aria-labels on search results', async ({ comfyPage }) => {
|
||||
const node = 'Load Checkpoint'
|
||||
@@ -251,40 +257,45 @@ test.describe('Node search box', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Release context menu', () => {
|
||||
test.describe('Release context menu', { tag: '@node' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.LinkRelease.Action', 'context menu')
|
||||
await comfyPage.setSetting('Comfy.LinkRelease.ActionShift', 'search box')
|
||||
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
|
||||
})
|
||||
|
||||
test('Can trigger on link release', async ({ comfyPage }) => {
|
||||
await comfyPage.disconnectEdge()
|
||||
const contextMenu = comfyPage.page.locator('.litecontextmenu')
|
||||
// Wait for context menu with correct title (slot name | slot type)
|
||||
// The title shows the output slot name and type from the disconnected link
|
||||
await expect(contextMenu.locator('.litemenu-title')).toContainText(
|
||||
'CLIP | CLIP'
|
||||
)
|
||||
await comfyPage.page.mouse.move(10, 10)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'link-release-context-menu.png'
|
||||
)
|
||||
})
|
||||
test(
|
||||
'Can trigger on link release',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.disconnectEdge()
|
||||
const contextMenu = comfyPage.page.locator('.litecontextmenu')
|
||||
// Wait for context menu with correct title (slot name | slot type)
|
||||
// The title shows the output slot name and type from the disconnected link
|
||||
await expect(contextMenu.locator('.litemenu-title')).toContainText(
|
||||
'CLIP | CLIP'
|
||||
)
|
||||
await comfyPage.page.mouse.move(10, 10)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'link-release-context-menu.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('Can search and add node from context menu', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
await comfyPage.disconnectEdge()
|
||||
await comfyMouse.move({ x: 10, y: 10 })
|
||||
await comfyPage.clickContextMenuItem('Search')
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('CLIP Prompt')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'link-context-menu-search.png'
|
||||
)
|
||||
})
|
||||
test(
|
||||
'Can search and add node from context menu',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage, comfyMouse }) => {
|
||||
await comfyPage.disconnectEdge()
|
||||
await comfyMouse.move({ x: 10, y: 10 })
|
||||
await comfyPage.clickContextMenuItem('Search')
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('CLIP Prompt')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'link-context-menu-search.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('Existing user (pre-1.24.1) gets context menu by default on link release', async ({
|
||||
comfyPage
|
||||
|
||||
@@ -6,8 +6,8 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Note Node', () => {
|
||||
test('Can load node nodes', async ({ comfyPage }) => {
|
||||
test.describe('Note Node', { tag: '@node' }, () => {
|
||||
test('Can load node nodes', { tag: '@screenshot' }, async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('nodes/note_nodes')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('note_nodes.png')
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Primitive Node', () => {
|
||||
test.describe('Primitive Node', { tag: ['@screenshot', '@node'] }, () => {
|
||||
test('Can load with correct size', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('primitive/primitive_node')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('primitive_node.png')
|
||||
|
||||
@@ -6,7 +6,7 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Record Audio Node', () => {
|
||||
test.describe('Record Audio Node', { tag: '@screenshot' }, () => {
|
||||
test('should add a record audio node and take a screenshot', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect } from '@playwright/test'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Remote COMBO Widget', () => {
|
||||
test.describe('Remote COMBO Widget', { tag: '@widget' }, () => {
|
||||
const mockOptions = ['d', 'c', 'b', 'a']
|
||||
|
||||
const addRemoteWidgetNode = async (
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { getMiddlePoint } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
test.describe('Reroute Node', () => {
|
||||
test.describe('Reroute Node', { tag: ['@screenshot', '@node'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
@@ -38,92 +38,96 @@ test.describe('Reroute Node', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('LiteGraph Native Reroute Node', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.setSetting('LiteGraph.Reroute.SplineOffset', 80)
|
||||
})
|
||||
test.describe(
|
||||
'LiteGraph Native Reroute Node',
|
||||
{ tag: ['@screenshot', '@node'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.setSetting('LiteGraph.Reroute.SplineOffset', 80)
|
||||
})
|
||||
|
||||
test('loads from workflow', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('reroute/native_reroute')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('native_reroute.png')
|
||||
})
|
||||
test('loads from workflow', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('reroute/native_reroute')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('native_reroute.png')
|
||||
})
|
||||
|
||||
test('@2x @0.5x Can add reroute by alt clicking on link', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const loadCheckpointNode = (
|
||||
await comfyPage.getNodeRefsByTitle('Load Checkpoint')
|
||||
)[0]
|
||||
const clipEncodeNode = (
|
||||
await comfyPage.getNodeRefsByTitle('CLIP Text Encode (Prompt)')
|
||||
)[0]
|
||||
test('@2x @0.5x Can add reroute by alt clicking on link', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const loadCheckpointNode = (
|
||||
await comfyPage.getNodeRefsByTitle('Load Checkpoint')
|
||||
)[0]
|
||||
const clipEncodeNode = (
|
||||
await comfyPage.getNodeRefsByTitle('CLIP Text Encode (Prompt)')
|
||||
)[0]
|
||||
|
||||
const slot1 = await loadCheckpointNode.getOutput(1)
|
||||
const slot2 = await clipEncodeNode.getInput(0)
|
||||
const middlePoint = getMiddlePoint(
|
||||
await slot1.getPosition(),
|
||||
await slot2.getPosition()
|
||||
)
|
||||
const slot1 = await loadCheckpointNode.getOutput(1)
|
||||
const slot2 = await clipEncodeNode.getInput(0)
|
||||
const middlePoint = getMiddlePoint(
|
||||
await slot1.getPosition(),
|
||||
await slot2.getPosition()
|
||||
)
|
||||
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
await comfyPage.page.mouse.click(middlePoint.x, middlePoint.y)
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
await comfyPage.page.mouse.click(middlePoint.x, middlePoint.y)
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'native_reroute_alt_click.png'
|
||||
)
|
||||
})
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'native_reroute_alt_click.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Can add reroute by clicking middle of link context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const loadCheckpointNode = (
|
||||
await comfyPage.getNodeRefsByTitle('Load Checkpoint')
|
||||
)[0]
|
||||
const clipEncodeNode = (
|
||||
await comfyPage.getNodeRefsByTitle('CLIP Text Encode (Prompt)')
|
||||
)[0]
|
||||
test('Can add reroute by clicking middle of link context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const loadCheckpointNode = (
|
||||
await comfyPage.getNodeRefsByTitle('Load Checkpoint')
|
||||
)[0]
|
||||
const clipEncodeNode = (
|
||||
await comfyPage.getNodeRefsByTitle('CLIP Text Encode (Prompt)')
|
||||
)[0]
|
||||
|
||||
const slot1 = await loadCheckpointNode.getOutput(1)
|
||||
const slot2 = await clipEncodeNode.getInput(0)
|
||||
const middlePoint = getMiddlePoint(
|
||||
await slot1.getPosition(),
|
||||
await slot2.getPosition()
|
||||
)
|
||||
const slot1 = await loadCheckpointNode.getOutput(1)
|
||||
const slot2 = await clipEncodeNode.getInput(0)
|
||||
const middlePoint = getMiddlePoint(
|
||||
await slot1.getPosition(),
|
||||
await slot2.getPosition()
|
||||
)
|
||||
|
||||
await comfyPage.page.mouse.click(middlePoint.x, middlePoint.y)
|
||||
await comfyPage.page
|
||||
.locator('.litecontextmenu .litemenu-entry', { hasText: 'Add Reroute' })
|
||||
.click()
|
||||
await comfyPage.page.mouse.click(middlePoint.x, middlePoint.y)
|
||||
await comfyPage.page
|
||||
.locator('.litecontextmenu .litemenu-entry', { hasText: 'Add Reroute' })
|
||||
.click()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'native_reroute_context_menu.png'
|
||||
)
|
||||
})
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'native_reroute_context_menu.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Can delete link that is connected to two reroutes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/4695
|
||||
await comfyPage.loadWorkflow(
|
||||
'reroute/single-native-reroute-default-workflow'
|
||||
)
|
||||
test('Can delete link that is connected to two reroutes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/4695
|
||||
await comfyPage.loadWorkflow(
|
||||
'reroute/single-native-reroute-default-workflow'
|
||||
)
|
||||
|
||||
// To find the clickable midpoint button, we use the hardcoded value from the browser logs
|
||||
// since the link is a bezier curve and not a straight line.
|
||||
const middlePoint = { x: 359.4188232421875, y: 468.7716979980469 }
|
||||
// To find the clickable midpoint button, we use the hardcoded value from the browser logs
|
||||
// since the link is a bezier curve and not a straight line.
|
||||
const middlePoint = { x: 359.4188232421875, y: 468.7716979980469 }
|
||||
|
||||
// Click the middle point of the link to open the context menu.
|
||||
await comfyPage.page.mouse.click(middlePoint.x, middlePoint.y)
|
||||
// Click the middle point of the link to open the context menu.
|
||||
await comfyPage.page.mouse.click(middlePoint.x, middlePoint.y)
|
||||
|
||||
// Click the "Delete" context menu option.
|
||||
await comfyPage.page
|
||||
.locator('.litecontextmenu .litemenu-entry', { hasText: 'Delete' })
|
||||
.click()
|
||||
// Click the "Delete" context menu option.
|
||||
await comfyPage.page
|
||||
.locator('.litecontextmenu .litemenu-entry', { hasText: 'Delete' })
|
||||
.click()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'native_reroute_delete_from_midpoint_context_menu.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'native_reroute_delete_from_midpoint_context_menu.png'
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -7,43 +7,49 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Canvas Right Click Menu', () => {
|
||||
test('Can add node', async ({ comfyPage }) => {
|
||||
await comfyPage.rightClickCanvas()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('right-click-menu.png')
|
||||
await comfyPage.page.getByText('Add Node').click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.getByText('loaders').click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.getByText('Load VAE').click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('add-node-node-added.png')
|
||||
})
|
||||
test.describe(
|
||||
'Canvas Right Click Menu',
|
||||
{ tag: ['@screenshot', '@ui'] },
|
||||
() => {
|
||||
test('Can add node', async ({ comfyPage }) => {
|
||||
await comfyPage.rightClickCanvas()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('right-click-menu.png')
|
||||
await comfyPage.page.getByText('Add Node').click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.getByText('loaders').click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.getByText('Load VAE').click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('add-node-node-added.png')
|
||||
})
|
||||
|
||||
test('Can add group', async ({ comfyPage }) => {
|
||||
await comfyPage.rightClickCanvas()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('right-click-menu.png')
|
||||
await comfyPage.page.getByText('Add Group', { exact: true }).click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('add-group-group-added.png')
|
||||
})
|
||||
test('Can add group', async ({ comfyPage }) => {
|
||||
await comfyPage.rightClickCanvas()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('right-click-menu.png')
|
||||
await comfyPage.page.getByText('Add Group', { exact: true }).click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'add-group-group-added.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Can convert to group node', async ({ comfyPage }) => {
|
||||
await comfyPage.select2Nodes()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('selected-2-nodes.png')
|
||||
await comfyPage.rightClickCanvas()
|
||||
await comfyPage.clickContextMenuItem('Convert to Group Node (Deprecated)')
|
||||
await comfyPage.promptDialogInput.fill('GroupNode2CLIP')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.promptDialogInput.waitFor({ state: 'hidden' })
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'right-click-node-group-node.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
test('Can convert to group node', async ({ comfyPage }) => {
|
||||
await comfyPage.select2Nodes()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('selected-2-nodes.png')
|
||||
await comfyPage.rightClickCanvas()
|
||||
await comfyPage.clickContextMenuItem('Convert to Group Node (Deprecated)')
|
||||
await comfyPage.promptDialogInput.fill('GroupNode2CLIP')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.promptDialogInput.waitFor({ state: 'hidden' })
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'right-click-node-group-node.png'
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test.describe('Node Right Click Menu', () => {
|
||||
test.describe('Node Right Click Menu', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
test('Can open properties panel', async ({ comfyPage }) => {
|
||||
await comfyPage.rightClickEmptyLatentNode()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
|
||||
|
||||
@@ -10,7 +10,7 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
const BLUE_COLOR = 'rgb(51, 51, 85)'
|
||||
const RED_COLOR = 'rgb(85, 51, 51)'
|
||||
|
||||
test.describe('Selection Toolbox', () => {
|
||||
test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
})
|
||||
|
||||
@@ -7,178 +7,190 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Selection Toolbox - More Options Submenus', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.loadWorkflow('nodes/single_ksampler')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.selectNodes(['KSampler'])
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
const openMoreOptions = async (comfyPage: ComfyPage) => {
|
||||
const ksamplerNodes = await comfyPage.getNodeRefsByTitle('KSampler')
|
||||
if (ksamplerNodes.length === 0) {
|
||||
throw new Error('No KSampler nodes found')
|
||||
}
|
||||
|
||||
// Drag the KSampler to the center of the screen
|
||||
const nodePos = await ksamplerNodes[0].getPosition()
|
||||
const viewportSize = comfyPage.page.viewportSize()
|
||||
const centerX = viewportSize.width / 3
|
||||
const centerY = viewportSize.height / 2
|
||||
await comfyPage.dragAndDrop(
|
||||
{ x: nodePos.x, y: nodePos.y },
|
||||
{ x: centerX, y: centerY }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await ksamplerNodes[0].click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
const moreOptionsBtn = comfyPage.page.locator(
|
||||
'[data-testid="more-options-button"]'
|
||||
)
|
||||
await expect(moreOptionsBtn).toBeVisible({ timeout: 3000 })
|
||||
|
||||
await comfyPage.page.click('[data-testid="more-options-button"]')
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const menuOptionsVisible = await comfyPage.page
|
||||
.getByText('Rename')
|
||||
.isVisible({ timeout: 2000 })
|
||||
.catch(() => false)
|
||||
if (menuOptionsVisible) {
|
||||
return
|
||||
}
|
||||
|
||||
await moreOptionsBtn.click({ force: true })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const menuOptionsVisibleAfterClick = await comfyPage.page
|
||||
.getByText('Rename')
|
||||
.isVisible({ timeout: 2000 })
|
||||
.catch(() => false)
|
||||
if (menuOptionsVisibleAfterClick) {
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error('Could not open More Options menu - popover not showing')
|
||||
}
|
||||
|
||||
test('opens Node Info from More Options menu', async ({ comfyPage }) => {
|
||||
await openMoreOptions(comfyPage)
|
||||
const nodeInfoButton = comfyPage.page.getByText('Node Info', {
|
||||
exact: true
|
||||
})
|
||||
await expect(nodeInfoButton).toBeVisible()
|
||||
await nodeInfoButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test('changes node shape via Shape submenu', async ({ comfyPage }) => {
|
||||
const nodeRef = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
|
||||
const initialShape = await nodeRef.getProperty<number>('shape')
|
||||
|
||||
await openMoreOptions(comfyPage)
|
||||
await comfyPage.page.getByText('Shape', { exact: true }).hover()
|
||||
await expect(comfyPage.page.getByText('Box', { exact: true })).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await comfyPage.page.getByText('Box', { exact: true }).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newShape = await nodeRef.getProperty<number>('shape')
|
||||
expect(newShape).not.toBe(initialShape)
|
||||
expect(newShape).toBe(1)
|
||||
})
|
||||
|
||||
test('changes node color via Color submenu swatch', async ({ comfyPage }) => {
|
||||
const nodeRef = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
|
||||
const initialColor = await nodeRef.getProperty<string | undefined>('color')
|
||||
|
||||
await openMoreOptions(comfyPage)
|
||||
await comfyPage.page.getByText('Color', { exact: true }).click()
|
||||
const blueSwatch = comfyPage.page.locator('[title="Blue"]')
|
||||
await expect(blueSwatch.first()).toBeVisible({ timeout: 5000 })
|
||||
await blueSwatch.first().click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newColor = await nodeRef.getProperty<string | undefined>('color')
|
||||
expect(newColor).toBe('#223')
|
||||
if (initialColor) {
|
||||
expect(newColor).not.toBe(initialColor)
|
||||
}
|
||||
})
|
||||
|
||||
test('renames a node using Rename action', async ({ comfyPage }) => {
|
||||
const nodeRef = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
|
||||
await openMoreOptions(comfyPage)
|
||||
await comfyPage.page
|
||||
.getByText('Rename', { exact: true })
|
||||
.click({ force: true })
|
||||
const input = comfyPage.page.locator(
|
||||
'.group-title-editor.node-title-editor .editable-text input'
|
||||
)
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill('RenamedNode')
|
||||
await input.press('Enter')
|
||||
await comfyPage.nextFrame()
|
||||
const newTitle = await nodeRef.getProperty<string>('title')
|
||||
expect(newTitle).toBe('RenamedNode')
|
||||
})
|
||||
|
||||
test('closes More Options menu when clicking outside', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openMoreOptions(comfyPage)
|
||||
const renameItem = comfyPage.page.getByText('Rename', { exact: true })
|
||||
await expect(renameItem).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Wait for multiple frames to allow PrimeVue's outside click handler to initialize
|
||||
for (let i = 0; i < 30; i++) {
|
||||
test.describe(
|
||||
'Selection Toolbox - More Options Submenus',
|
||||
{ tag: '@ui' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.loadWorkflow('nodes/single_ksampler')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.selectNodes(['KSampler'])
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
const openMoreOptions = async (comfyPage: ComfyPage) => {
|
||||
const ksamplerNodes = await comfyPage.getNodeRefsByTitle('KSampler')
|
||||
if (ksamplerNodes.length === 0) {
|
||||
throw new Error('No KSampler nodes found')
|
||||
}
|
||||
|
||||
// Drag the KSampler to the center of the screen
|
||||
const nodePos = await ksamplerNodes[0].getPosition()
|
||||
const viewportSize = comfyPage.page.viewportSize()
|
||||
const centerX = viewportSize.width / 3
|
||||
const centerY = viewportSize.height / 2
|
||||
await comfyPage.dragAndDrop(
|
||||
{ x: nodePos.x, y: nodePos.y },
|
||||
{ x: centerX, y: centerY }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await ksamplerNodes[0].click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
const moreOptionsBtn = comfyPage.page.locator(
|
||||
'[data-testid="more-options-button"]'
|
||||
)
|
||||
await expect(moreOptionsBtn).toBeVisible({ timeout: 3000 })
|
||||
|
||||
await comfyPage.page.click('[data-testid="more-options-button"]')
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const menuOptionsVisible = await comfyPage.page
|
||||
.getByText('Rename')
|
||||
.isVisible({ timeout: 2000 })
|
||||
.catch(() => false)
|
||||
if (menuOptionsVisible) {
|
||||
return
|
||||
}
|
||||
|
||||
await moreOptionsBtn.click({ force: true })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const menuOptionsVisibleAfterClick = await comfyPage.page
|
||||
.getByText('Rename')
|
||||
.isVisible({ timeout: 2000 })
|
||||
.catch(() => false)
|
||||
if (menuOptionsVisibleAfterClick) {
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error('Could not open More Options menu - popover not showing')
|
||||
}
|
||||
|
||||
await comfyPage.page
|
||||
.locator('#graph-canvas')
|
||||
.click({ position: { x: 0, y: 50 }, force: true })
|
||||
test('opens Node Info from More Options menu', async ({ comfyPage }) => {
|
||||
await openMoreOptions(comfyPage)
|
||||
const nodeInfoButton = comfyPage.page.getByText('Node Info', {
|
||||
exact: true
|
||||
})
|
||||
await expect(nodeInfoButton).toBeVisible()
|
||||
await nodeInfoButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
await expect(
|
||||
comfyPage.page.getByText('Rename', { exact: true })
|
||||
).not.toBeVisible()
|
||||
})
|
||||
test('changes node shape via Shape submenu', async ({ comfyPage }) => {
|
||||
const nodeRef = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
|
||||
const initialShape = await nodeRef.getProperty<number>('shape')
|
||||
|
||||
test('closes More Options menu when clicking the button again (toggle)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openMoreOptions(comfyPage)
|
||||
await expect(
|
||||
comfyPage.page.getByText('Rename', { exact: true })
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
await openMoreOptions(comfyPage)
|
||||
await comfyPage.page.getByText('Shape', { exact: true }).hover()
|
||||
await expect(
|
||||
comfyPage.page.getByText('Box', { exact: true })
|
||||
).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await comfyPage.page.getByText('Box', { exact: true }).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const btn = document.querySelector('[data-testid="more-options-button"]')
|
||||
if (btn) {
|
||||
const event = new MouseEvent('click', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
detail: 1
|
||||
})
|
||||
btn.dispatchEvent(event)
|
||||
const newShape = await nodeRef.getProperty<number>('shape')
|
||||
expect(newShape).not.toBe(initialShape)
|
||||
expect(newShape).toBe(1)
|
||||
})
|
||||
|
||||
test('changes node color via Color submenu swatch', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodeRef = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
|
||||
const initialColor = await nodeRef.getProperty<string | undefined>(
|
||||
'color'
|
||||
)
|
||||
|
||||
await openMoreOptions(comfyPage)
|
||||
await comfyPage.page.getByText('Color', { exact: true }).click()
|
||||
const blueSwatch = comfyPage.page.locator('[title="Blue"]')
|
||||
await expect(blueSwatch.first()).toBeVisible({ timeout: 5000 })
|
||||
await blueSwatch.first().click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newColor = await nodeRef.getProperty<string | undefined>('color')
|
||||
expect(newColor).toBe('#223')
|
||||
if (initialColor) {
|
||||
expect(newColor).not.toBe(initialColor)
|
||||
}
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByText('Rename', { exact: true })
|
||||
).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
test('renames a node using Rename action', async ({ comfyPage }) => {
|
||||
const nodeRef = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
|
||||
await openMoreOptions(comfyPage)
|
||||
await comfyPage.page
|
||||
.getByText('Rename', { exact: true })
|
||||
.click({ force: true })
|
||||
const input = comfyPage.page.locator(
|
||||
'.group-title-editor.node-title-editor .editable-text input'
|
||||
)
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill('RenamedNode')
|
||||
await input.press('Enter')
|
||||
await comfyPage.nextFrame()
|
||||
const newTitle = await nodeRef.getProperty<string>('title')
|
||||
expect(newTitle).toBe('RenamedNode')
|
||||
})
|
||||
|
||||
test('closes More Options menu when clicking outside', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openMoreOptions(comfyPage)
|
||||
const renameItem = comfyPage.page.getByText('Rename', { exact: true })
|
||||
await expect(renameItem).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Wait for multiple frames to allow PrimeVue's outside click handler to initialize
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
await comfyPage.page
|
||||
.locator('#graph-canvas')
|
||||
.click({ position: { x: 0, y: 50 }, force: true })
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
await expect(
|
||||
comfyPage.page.getByText('Rename', { exact: true })
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('closes More Options menu when clicking the button again (toggle)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openMoreOptions(comfyPage)
|
||||
await expect(
|
||||
comfyPage.page.getByText('Rename', { exact: true })
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const btn = document.querySelector(
|
||||
'[data-testid="more-options-button"]'
|
||||
)
|
||||
if (btn) {
|
||||
const event = new MouseEvent('click', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
detail: 1
|
||||
})
|
||||
btn.dispatchEvent(event)
|
||||
}
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByText('Rename', { exact: true })
|
||||
).not.toBeVisible()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ const SELECTORS = {
|
||||
promptDialog: '.graphdialog input'
|
||||
} as const
|
||||
|
||||
test.describe('Subgraph Slot Rename Dialog', () => {
|
||||
test.describe('Subgraph Slot Rename Dialog', { tag: '@subgraph' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@ const SELECTORS = {
|
||||
domWidget: '.comfy-multiline-input'
|
||||
} as const
|
||||
|
||||
test.describe('Subgraph Operations', () => {
|
||||
test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ async function checkTemplateFileExists(
|
||||
return response.ok()
|
||||
}
|
||||
|
||||
test.describe('Templates', () => {
|
||||
test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setSetting('Comfy.Workflow.ShowMissingModelsWarning', false)
|
||||
@@ -207,109 +207,114 @@ test.describe('Templates', () => {
|
||||
await expect(nav).toBeVisible() // Nav should be visible at tablet size
|
||||
})
|
||||
|
||||
test('template cards descriptions adjust height dynamically', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Setup test by intercepting templates response to inject cards with varying description lengths
|
||||
await comfyPage.page.route('**/templates/index.json', async (route, _) => {
|
||||
const response = [
|
||||
{
|
||||
moduleName: 'default',
|
||||
title: 'Test Templates',
|
||||
type: 'image',
|
||||
templates: [
|
||||
test(
|
||||
'template cards descriptions adjust height dynamically',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
// Setup test by intercepting templates response to inject cards with varying description lengths
|
||||
await comfyPage.page.route(
|
||||
'**/templates/index.json',
|
||||
async (route, _) => {
|
||||
const response = [
|
||||
{
|
||||
name: 'short-description',
|
||||
title: 'Short Description',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'webp',
|
||||
description: 'This is a short description.'
|
||||
},
|
||||
{
|
||||
name: 'medium-description',
|
||||
title: 'Medium Description',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'webp',
|
||||
description:
|
||||
'This is a medium length description that should take up two lines on most displays.'
|
||||
},
|
||||
{
|
||||
name: 'long-description',
|
||||
title: 'Long Description',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'webp',
|
||||
description:
|
||||
'This is a much longer description that should definitely wrap to multiple lines. It contains enough text to demonstrate how the cards handle varying amounts of content while maintaining a consistent layout grid.'
|
||||
moduleName: 'default',
|
||||
title: 'Test Templates',
|
||||
type: 'image',
|
||||
templates: [
|
||||
{
|
||||
name: 'short-description',
|
||||
title: 'Short Description',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'webp',
|
||||
description: 'This is a short description.'
|
||||
},
|
||||
{
|
||||
name: 'medium-description',
|
||||
title: 'Medium Description',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'webp',
|
||||
description:
|
||||
'This is a medium length description that should take up two lines on most displays.'
|
||||
},
|
||||
{
|
||||
name: 'long-description',
|
||||
title: 'Long Description',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'webp',
|
||||
description:
|
||||
'This is a much longer description that should definitely wrap to multiple lines. It contains enough text to demonstrate how the cards handle varying amounts of content while maintaining a consistent layout grid.'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(response),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
}
|
||||
]
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(response),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
)
|
||||
|
||||
// Mock the thumbnail images to avoid 404s
|
||||
await comfyPage.page.route('**/templates/**.webp', async (route) => {
|
||||
const headers = {
|
||||
'Content-Type': 'image/webp',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
path: 'browser_tests/assets/example.webp',
|
||||
headers
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Mock the thumbnail images to avoid 404s
|
||||
await comfyPage.page.route('**/templates/**.webp', async (route) => {
|
||||
const headers = {
|
||||
'Content-Type': 'image/webp',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
path: 'browser_tests/assets/example.webp',
|
||||
headers
|
||||
})
|
||||
})
|
||||
// Open templates dialog
|
||||
await comfyPage.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
// Open templates dialog
|
||||
await comfyPage.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
// Wait for cards to load
|
||||
await expect(
|
||||
comfyPage.page.locator(
|
||||
'[data-testid="template-workflow-short-description"]'
|
||||
)
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Wait for cards to load
|
||||
await expect(
|
||||
comfyPage.page.locator(
|
||||
// Verify all three cards with different descriptions are visible
|
||||
const shortDescCard = comfyPage.page.locator(
|
||||
'[data-testid="template-workflow-short-description"]'
|
||||
)
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
const mediumDescCard = comfyPage.page.locator(
|
||||
'[data-testid="template-workflow-medium-description"]'
|
||||
)
|
||||
const longDescCard = comfyPage.page.locator(
|
||||
'[data-testid="template-workflow-long-description"]'
|
||||
)
|
||||
|
||||
// Verify all three cards with different descriptions are visible
|
||||
const shortDescCard = comfyPage.page.locator(
|
||||
'[data-testid="template-workflow-short-description"]'
|
||||
)
|
||||
const mediumDescCard = comfyPage.page.locator(
|
||||
'[data-testid="template-workflow-medium-description"]'
|
||||
)
|
||||
const longDescCard = comfyPage.page.locator(
|
||||
'[data-testid="template-workflow-long-description"]'
|
||||
)
|
||||
await expect(shortDescCard).toBeVisible()
|
||||
await expect(mediumDescCard).toBeVisible()
|
||||
await expect(longDescCard).toBeVisible()
|
||||
|
||||
await expect(shortDescCard).toBeVisible()
|
||||
await expect(mediumDescCard).toBeVisible()
|
||||
await expect(longDescCard).toBeVisible()
|
||||
// Verify descriptions are visible and have line-clamp class
|
||||
// The description is in a p tag with text-muted class
|
||||
const shortDesc = shortDescCard.locator('p.text-muted.line-clamp-2')
|
||||
const mediumDesc = mediumDescCard.locator('p.text-muted.line-clamp-2')
|
||||
const longDesc = longDescCard.locator('p.text-muted.line-clamp-2')
|
||||
|
||||
// Verify descriptions are visible and have line-clamp class
|
||||
// The description is in a p tag with text-muted class
|
||||
const shortDesc = shortDescCard.locator('p.text-muted.line-clamp-2')
|
||||
const mediumDesc = mediumDescCard.locator('p.text-muted.line-clamp-2')
|
||||
const longDesc = longDescCard.locator('p.text-muted.line-clamp-2')
|
||||
await expect(shortDesc).toContainText('short description')
|
||||
await expect(mediumDesc).toContainText('medium length description')
|
||||
await expect(longDesc).toContainText('much longer description')
|
||||
|
||||
await expect(shortDesc).toContainText('short description')
|
||||
await expect(mediumDesc).toContainText('medium length description')
|
||||
await expect(longDesc).toContainText('much longer description')
|
||||
|
||||
// Verify grid layout maintains consistency
|
||||
const templateGrid = comfyPage.page.locator(
|
||||
'[data-testid="template-workflows-content"]'
|
||||
)
|
||||
await expect(templateGrid).toBeVisible()
|
||||
await expect(templateGrid).toHaveScreenshot(
|
||||
'template-grid-varying-content.png'
|
||||
)
|
||||
})
|
||||
// Verify grid layout maintains consistency
|
||||
const templateGrid = comfyPage.page.locator(
|
||||
'[data-testid="template-workflows-content"]'
|
||||
)
|
||||
await expect(templateGrid).toBeVisible()
|
||||
await expect(templateGrid).toHaveScreenshot(
|
||||
'template-grid-varying-content.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Settings Search functionality', () => {
|
||||
test.describe('Settings Search functionality', { tag: '@settings' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Register test settings to verify hidden/deprecated filtering
|
||||
await comfyPage.page.evaluate(() => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { userSelectPageFixture as test } from '../fixtures/UserSelectPage'
|
||||
/**
|
||||
* Expects ComfyUI backend to be launched with `--multi-user` flag.
|
||||
*/
|
||||
test.describe('User Select View', () => {
|
||||
test.describe('User Select View', { tag: '@settings' }, () => {
|
||||
test.beforeEach(async ({ userSelectPage, page }) => {
|
||||
await page.goto(userSelectPage.url)
|
||||
await page.evaluate(() => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect } from '@playwright/test'
|
||||
import type { SystemStats } from '../../src/schemas/apiSchema'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Version Mismatch Warnings', () => {
|
||||
test.describe('Version Mismatch Warnings', { tag: '@slow' }, () => {
|
||||
const ALWAYS_AHEAD_OF_INSTALLED_VERSION = '100.100.100'
|
||||
const ALWAYS_BEHIND_INSTALLED_VERSION = '0.0.0'
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Viewport', () => {
|
||||
test.describe('Viewport', { tag: ['@screenshot', '@smoke', '@canvas'] }, () => {
|
||||
test('Fits view to nodes when saved viewport position is offscreen', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
|
||||
const CREATE_GROUP_HOTKEY = 'Control+g'
|
||||
|
||||
test.describe('Vue Node Groups', () => {
|
||||
test.describe('Vue Node Groups', { tag: '@screenshot' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setSetting('Comfy.Minimap.ShowGroups', true)
|
||||
|
||||
@@ -9,10 +9,14 @@ test.describe('Vue Nodes Canvas Pan', () => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('@mobile Can pan with touch', async ({ comfyPage }) => {
|
||||
await comfyPage.panWithTouch({ x: 64, y: 64 }, { x: 256, y: 256 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-nodes-paned-with-touch.png'
|
||||
)
|
||||
})
|
||||
test(
|
||||
'@mobile Can pan with touch',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.panWithTouch({ x: 64, y: 64 }, { x: 256, y: 256 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-nodes-paned-with-touch.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -10,25 +10,30 @@ test.describe('Vue Nodes Zoom', () => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('should not capture drag while zooming with ctrl+shift+drag', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
const nodeBoundingBox = await checkpointNode.boundingBox()
|
||||
if (!nodeBoundingBox) throw new Error('Node bounding box not available')
|
||||
test(
|
||||
'should not capture drag while zooming with ctrl+shift+drag',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const checkpointNode =
|
||||
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
const nodeBoundingBox = await checkpointNode.boundingBox()
|
||||
if (!nodeBoundingBox) throw new Error('Node bounding box not available')
|
||||
|
||||
const nodeMidpointX = nodeBoundingBox.x + nodeBoundingBox.width / 2
|
||||
const nodeMidpointY = nodeBoundingBox.y + nodeBoundingBox.height / 2
|
||||
const nodeMidpointX = nodeBoundingBox.x + nodeBoundingBox.width / 2
|
||||
const nodeMidpointY = nodeBoundingBox.y + nodeBoundingBox.height / 2
|
||||
|
||||
// Start the Ctrl+Shift drag-to-zoom on the canvas and continue dragging over
|
||||
// the node. The node should not capture the drag while drag-zooming.
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.dragAndDrop(
|
||||
{ x: 200, y: 300 },
|
||||
{ x: nodeMidpointX, y: nodeMidpointY }
|
||||
)
|
||||
// Start the Ctrl+Shift drag-to-zoom on the canvas and continue dragging over
|
||||
// the node. The node should not capture the drag while drag-zooming.
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.dragAndDrop(
|
||||
{ x: 200, y: 300 },
|
||||
{ x: nodeMidpointX, y: nodeMidpointY }
|
||||
)
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in-ctrl-shift.png')
|
||||
})
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'zoomed-in-ctrl-shift.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -98,7 +98,7 @@ async function connectSlots(
|
||||
await nextFrame()
|
||||
}
|
||||
|
||||
test.describe('Vue Node Link Interaction', () => {
|
||||
test.describe('Vue Node Link Interaction', { tag: '@screenshot' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
import type { ComfyPage } from '../../../../fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from '../../../../helpers/fitToView'
|
||||
|
||||
test.describe('Vue Node Bring to Front', () => {
|
||||
test.describe('Vue Node Bring to Front', { tag: '@screenshot' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
type ComfyPage,
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../../../../fixtures/ComfyPage'
|
||||
import type { Position } from '../../../../fixtures/types'
|
||||
|
||||
test.describe('Vue Node Moving', () => {
|
||||
@@ -29,39 +29,47 @@ test.describe('Vue Node Moving', () => {
|
||||
expect(diffY).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
|
||||
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await comfyPage.dragAndDrop(loadCheckpointHeaderPos, {
|
||||
x: 256,
|
||||
y: 256
|
||||
})
|
||||
test(
|
||||
'should allow moving nodes by dragging',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const loadCheckpointHeaderPos =
|
||||
await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await comfyPage.dragAndDrop(loadCheckpointHeaderPos, {
|
||||
x: 256,
|
||||
y: 256
|
||||
})
|
||||
|
||||
const newHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await expectPosChanged(loadCheckpointHeaderPos, newHeaderPos)
|
||||
const newHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await expectPosChanged(loadCheckpointHeaderPos, newHeaderPos)
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('vue-node-moved-node.png')
|
||||
})
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('vue-node-moved-node.png')
|
||||
}
|
||||
)
|
||||
|
||||
test('@mobile should allow moving nodes by dragging on touch devices', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Disable minimap (gets in way of the node on small screens)
|
||||
await comfyPage.setSetting('Comfy.Minimap.Visible', false)
|
||||
test(
|
||||
'@mobile should allow moving nodes by dragging on touch devices',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
// Disable minimap (gets in way of the node on small screens)
|
||||
await comfyPage.setSetting('Comfy.Minimap.Visible', false)
|
||||
|
||||
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await comfyPage.panWithTouch(
|
||||
{
|
||||
x: 64,
|
||||
y: 64
|
||||
},
|
||||
loadCheckpointHeaderPos
|
||||
)
|
||||
const loadCheckpointHeaderPos =
|
||||
await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await comfyPage.panWithTouch(
|
||||
{
|
||||
x: 64,
|
||||
y: 64
|
||||
},
|
||||
loadCheckpointHeaderPos
|
||||
)
|
||||
|
||||
const newHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await expectPosChanged(loadCheckpointHeaderPos, newHeaderPos)
|
||||
const newHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await expectPosChanged(loadCheckpointHeaderPos, newHeaderPos)
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-moved-node-touch.png'
|
||||
)
|
||||
})
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-moved-node-touch.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -15,22 +15,25 @@ test.describe('Vue Node Bypass', () => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('should allow toggling bypass on a selected node with hotkey', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
|
||||
test(
|
||||
'should allow toggling bypass on a selected node with hotkey',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
|
||||
|
||||
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-bypassed-state.png'
|
||||
)
|
||||
const checkpointNode =
|
||||
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-bypassed-state.png'
|
||||
)
|
||||
|
||||
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
|
||||
await expect(checkpointNode).not.toHaveClass(BYPASS_CLASS)
|
||||
})
|
||||
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
|
||||
await expect(checkpointNode).not.toHaveClass(BYPASS_CLASS)
|
||||
}
|
||||
)
|
||||
|
||||
test('should allow toggling bypass on multiple selected nodes with hotkey', async ({
|
||||
comfyPage
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Node Custom Colors', () => {
|
||||
test.describe('Vue Node Custom Colors', { tag: '@screenshot' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
|
||||
@@ -12,19 +12,24 @@ test.describe('Vue Node Mute', () => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('should allow toggling mute on a selected node with hotkey', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
|
||||
test(
|
||||
'should allow toggling mute on a selected node with hotkey',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
|
||||
|
||||
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
await expect(checkpointNode).toHaveCSS('opacity', MUTE_OPACITY)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('vue-node-muted-state.png')
|
||||
const checkpointNode =
|
||||
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
await expect(checkpointNode).toHaveCSS('opacity', MUTE_OPACITY)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-muted-state.png'
|
||||
)
|
||||
|
||||
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
|
||||
await expect(checkpointNode).not.toHaveCSS('opacity', MUTE_OPACITY)
|
||||
})
|
||||
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
|
||||
await expect(checkpointNode).not.toHaveCSS('opacity', MUTE_OPACITY)
|
||||
}
|
||||
)
|
||||
|
||||
test('should allow toggling mute on multiple selected nodes with hotkey', async ({
|
||||
comfyPage
|
||||
|
||||
@@ -9,13 +9,17 @@ test.describe('Vue Upload Widgets', () => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('should hide canvas-only upload buttons', async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.loadWorkflow('widgets/all_load_widgets')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
test(
|
||||
'should hide canvas-only upload buttons',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.loadWorkflow('widgets/all_load_widgets')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-nodes-upload-widgets.png'
|
||||
)
|
||||
})
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-nodes-upload-widgets.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Combo text widget', () => {
|
||||
test.describe('Combo text widget', { tag: ['@screenshot', '@widget'] }, () => {
|
||||
test('Truncates text when resized', async ({ comfyPage }) => {
|
||||
await comfyPage.resizeLoadCheckpointNode(0.2, 1)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
@@ -79,7 +79,7 @@ test.describe('Combo text widget', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Boolean widget', () => {
|
||||
test.describe('Boolean widget', { tag: ['@screenshot', '@widget'] }, () => {
|
||||
test('Can toggle', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('widgets/boolean_widget')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('boolean_widget.png')
|
||||
@@ -92,7 +92,7 @@ test.describe('Boolean widget', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Slider widget', () => {
|
||||
test.describe('Slider widget', { tag: ['@screenshot', '@widget'] }, () => {
|
||||
test('Can drag adjust value', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('inputs/simple_slider')
|
||||
const node = (await comfyPage.getFirstNodeRef())!
|
||||
@@ -113,7 +113,7 @@ test.describe('Slider widget', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Number widget', () => {
|
||||
test.describe('Number widget', { tag: ['@screenshot', '@widget'] }, () => {
|
||||
test('Can drag adjust value', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('widgets/seed_widget')
|
||||
|
||||
@@ -134,22 +134,28 @@ test.describe('Number widget', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Dynamic widget manipulation', () => {
|
||||
test('Auto expand node when widget is added dynamically', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('nodes/single_ksampler')
|
||||
test.describe(
|
||||
'Dynamic widget manipulation',
|
||||
{ tag: ['@screenshot', '@widget'] },
|
||||
() => {
|
||||
test('Auto expand node when widget is added dynamically', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('nodes/single_ksampler')
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window['graph'].nodes[0].addWidget('number', 'new_widget', 10)
|
||||
window['graph'].setDirtyCanvas(true, true)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window['graph'].nodes[0].addWidget('number', 'new_widget', 10)
|
||||
window['graph'].setDirtyCanvas(true, true)
|
||||
})
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'ksampler_widget_added.png'
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('ksampler_widget_added.png')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Image widget', () => {
|
||||
test.describe('Image widget', { tag: ['@screenshot', '@widget'] }, () => {
|
||||
test('Can load image', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('widgets/load_image_widget')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('load_image_widget.png')
|
||||
@@ -236,99 +242,103 @@ test.describe('Image widget', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Animated image widget', () => {
|
||||
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3718
|
||||
test.skip('Shows preview of uploaded animated image', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('widgets/load_animated_webp')
|
||||
test.describe(
|
||||
'Animated image widget',
|
||||
{ tag: ['@screenshot', '@widget'] },
|
||||
() => {
|
||||
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3718
|
||||
test.skip('Shows preview of uploaded animated image', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('widgets/load_animated_webp')
|
||||
|
||||
// Get position of the load animated webp node
|
||||
const nodes = await comfyPage.getNodeRefsByType(
|
||||
'DevToolsLoadAnimatedImageTest'
|
||||
)
|
||||
const loadAnimatedWebpNode = nodes[0]
|
||||
const { x, y } = await loadAnimatedWebpNode.getPosition()
|
||||
// Get position of the load animated webp node
|
||||
const nodes = await comfyPage.getNodeRefsByType(
|
||||
'DevToolsLoadAnimatedImageTest'
|
||||
)
|
||||
const loadAnimatedWebpNode = nodes[0]
|
||||
const { x, y } = await loadAnimatedWebpNode.getPosition()
|
||||
|
||||
// Drag and drop image file onto the load animated webp node
|
||||
await comfyPage.dragAndDropFile('animated_webp.webp', {
|
||||
dropPosition: { x, y }
|
||||
// Drag and drop image file onto the load animated webp node
|
||||
await comfyPage.dragAndDropFile('animated_webp.webp', {
|
||||
dropPosition: { x, y }
|
||||
})
|
||||
|
||||
// Expect the image preview to change automatically
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'animated_image_preview_drag_and_dropped.png'
|
||||
)
|
||||
|
||||
// Move mouse and click on canvas to trigger render
|
||||
await comfyPage.page.mouse.click(64, 64)
|
||||
|
||||
// Expect the image preview to change to the next frame of the animation
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'animated_image_preview_drag_and_dropped_next_frame.png'
|
||||
)
|
||||
})
|
||||
|
||||
// Expect the image preview to change automatically
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'animated_image_preview_drag_and_dropped.png'
|
||||
)
|
||||
test('Can drag-and-drop animated webp image', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('widgets/load_animated_webp')
|
||||
|
||||
// Move mouse and click on canvas to trigger render
|
||||
await comfyPage.page.mouse.click(64, 64)
|
||||
// Get position of the load animated webp node
|
||||
const nodes = await comfyPage.getNodeRefsByType(
|
||||
'DevToolsLoadAnimatedImageTest'
|
||||
)
|
||||
const loadAnimatedWebpNode = nodes[0]
|
||||
const { x, y } = await loadAnimatedWebpNode.getPosition()
|
||||
|
||||
// Expect the image preview to change to the next frame of the animation
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'animated_image_preview_drag_and_dropped_next_frame.png'
|
||||
)
|
||||
})
|
||||
// Drag and drop image file onto the load animated webp node
|
||||
await comfyPage.dragAndDropFile('animated_webp.webp', {
|
||||
dropPosition: { x, y },
|
||||
waitForUpload: true
|
||||
})
|
||||
|
||||
test('Can drag-and-drop animated webp image', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('widgets/load_animated_webp')
|
||||
|
||||
// Get position of the load animated webp node
|
||||
const nodes = await comfyPage.getNodeRefsByType(
|
||||
'DevToolsLoadAnimatedImageTest'
|
||||
)
|
||||
const loadAnimatedWebpNode = nodes[0]
|
||||
const { x, y } = await loadAnimatedWebpNode.getPosition()
|
||||
|
||||
// Drag and drop image file onto the load animated webp node
|
||||
await comfyPage.dragAndDropFile('animated_webp.webp', {
|
||||
dropPosition: { x, y },
|
||||
waitForUpload: true
|
||||
// Expect the filename combo value to be updated
|
||||
const fileComboWidget = await loadAnimatedWebpNode.getWidget(0)
|
||||
const filename = await fileComboWidget.getValue()
|
||||
expect(filename).toContain('animated_webp.webp')
|
||||
})
|
||||
|
||||
// Expect the filename combo value to be updated
|
||||
const fileComboWidget = await loadAnimatedWebpNode.getWidget(0)
|
||||
const filename = await fileComboWidget.getValue()
|
||||
expect(filename).toContain('animated_webp.webp')
|
||||
})
|
||||
test('Can preview saved animated webp image', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('widgets/save_animated_webp')
|
||||
|
||||
test('Can preview saved animated webp image', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('widgets/save_animated_webp')
|
||||
// Get position of the load animated webp node
|
||||
const loadNodes = await comfyPage.getNodeRefsByType(
|
||||
'DevToolsLoadAnimatedImageTest'
|
||||
)
|
||||
const loadAnimatedWebpNode = loadNodes[0]
|
||||
const { x, y } = await loadAnimatedWebpNode.getPosition()
|
||||
|
||||
// Get position of the load animated webp node
|
||||
const loadNodes = await comfyPage.getNodeRefsByType(
|
||||
'DevToolsLoadAnimatedImageTest'
|
||||
)
|
||||
const loadAnimatedWebpNode = loadNodes[0]
|
||||
const { x, y } = await loadAnimatedWebpNode.getPosition()
|
||||
// Drag and drop image file onto the load animated webp node
|
||||
await comfyPage.dragAndDropFile('animated_webp.webp', {
|
||||
dropPosition: { x, y }
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Drag and drop image file onto the load animated webp node
|
||||
await comfyPage.dragAndDropFile('animated_webp.webp', {
|
||||
dropPosition: { x, y }
|
||||
// Get the SaveAnimatedWEBP node
|
||||
const saveNodes = await comfyPage.getNodeRefsByType('SaveAnimatedWEBP')
|
||||
const saveAnimatedWebpNode = saveNodes[0]
|
||||
if (!saveAnimatedWebpNode)
|
||||
throw new Error('SaveAnimatedWEBP node not found')
|
||||
|
||||
// Simulate the graph executing
|
||||
await comfyPage.page.evaluate(
|
||||
([loadId, saveId]) => {
|
||||
// Set the output of the SaveAnimatedWEBP node to equal the loader node's image
|
||||
window['app'].nodeOutputs[saveId] = window['app'].nodeOutputs[loadId]
|
||||
app.canvas.setDirty(true)
|
||||
},
|
||||
[loadAnimatedWebpNode.id, saveAnimatedWebpNode.id]
|
||||
)
|
||||
await expect(
|
||||
comfyPage.page.locator('.dom-widget').locator('img')
|
||||
).toHaveCount(2)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
)
|
||||
|
||||
// Get the SaveAnimatedWEBP node
|
||||
const saveNodes = await comfyPage.getNodeRefsByType('SaveAnimatedWEBP')
|
||||
const saveAnimatedWebpNode = saveNodes[0]
|
||||
if (!saveAnimatedWebpNode)
|
||||
throw new Error('SaveAnimatedWEBP node not found')
|
||||
|
||||
// Simulate the graph executing
|
||||
await comfyPage.page.evaluate(
|
||||
([loadId, saveId]) => {
|
||||
// Set the output of the SaveAnimatedWEBP node to equal the loader node's image
|
||||
window['app'].nodeOutputs[saveId] = window['app'].nodeOutputs[loadId]
|
||||
app.canvas.setDirty(true)
|
||||
},
|
||||
[loadAnimatedWebpNode.id, saveAnimatedWebpNode.id]
|
||||
)
|
||||
await expect(
|
||||
comfyPage.page.locator('.dom-widget').locator('img')
|
||||
).toHaveCount(2)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Load audio widget', () => {
|
||||
test.describe('Load audio widget', { tag: ['@screenshot', '@widget'] }, () => {
|
||||
test('Can load audio', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('widgets/load_audio_widget')
|
||||
// Wait for the audio widget to be rendered in the DOM
|
||||
@@ -338,7 +348,7 @@ test.describe('Load audio widget', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Unserialized widgets', () => {
|
||||
test.describe('Unserialized widgets', { tag: '@widget' }, () => {
|
||||
test('Unserialized widgets values do not mark graph as modified', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { type ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Workflow Tab Thumbnails', () => {
|
||||
test.describe('Workflow Tab Thumbnails', { tag: '@workflow' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setSetting('Comfy.Workflow.WorkflowTabsPosition', 'Topbar')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.39.1",
|
||||
"version": "1.39.2",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -265,18 +265,15 @@ function cancelEdit() {
|
||||
}
|
||||
|
||||
async function saveKeybinding() {
|
||||
if (currentEditingCommand.value && newBindingKeyCombo.value) {
|
||||
const updated = keybindingStore.updateKeybindingOnCommand(
|
||||
new KeybindingImpl({
|
||||
commandId: currentEditingCommand.value.id,
|
||||
combo: newBindingKeyCombo.value
|
||||
})
|
||||
)
|
||||
if (updated) {
|
||||
await keybindingService.persistUserKeybindings()
|
||||
}
|
||||
}
|
||||
const commandId = currentEditingCommand.value?.id
|
||||
const combo = newBindingKeyCombo.value
|
||||
cancelEdit()
|
||||
if (!combo || commandId == undefined) return
|
||||
|
||||
const updated = keybindingStore.updateKeybindingOnCommand(
|
||||
new KeybindingImpl({ commandId, combo })
|
||||
)
|
||||
if (updated) await keybindingService.persistUserKeybindings()
|
||||
}
|
||||
|
||||
async function resetKeybinding(commandData: ICommandData) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<!-- Help Center Popup positioned within canvas area -->
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isHelpCenterVisible"
|
||||
class="help-center-popup"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="flex h-full flex-col">
|
||||
<!-- Active Jobs Grid -->
|
||||
<div
|
||||
v-if="activeJobItems.length"
|
||||
v-if="isQueuePanelV2Enabled && activeJobItems.length"
|
||||
class="grid max-h-[50%] scrollbar-custom overflow-y-auto"
|
||||
:style="gridStyle"
|
||||
>
|
||||
@@ -65,6 +65,7 @@ import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { isActiveJobState } from '@/utils/queueUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const {
|
||||
assets,
|
||||
@@ -90,6 +91,11 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
const { jobItems } = useJobList()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
|
||||
type AssetGridItem = { key: string; asset: AssetItem }
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<div
|
||||
v-if="activeJobItems.length"
|
||||
v-if="isQueuePanelV2Enabled && activeJobItems.length"
|
||||
class="flex max-h-[50%] scrollbar-custom flex-col gap-2 overflow-y-auto px-2"
|
||||
>
|
||||
<AssetsListItem
|
||||
@@ -143,6 +143,7 @@ import {
|
||||
} from '@/utils/formatUtil'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const {
|
||||
assetItems,
|
||||
@@ -170,6 +171,11 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
const { jobItems } = useJobList()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
const hoveredJobId = ref<string | null>(null)
|
||||
const hoveredAssetId = ref<string | null>(null)
|
||||
const activeJobItems = computed(() =>
|
||||
|
||||
@@ -557,7 +557,7 @@ function withComfyAutogrow(node: LGraphNode): asserts node is AutogrowNode {
|
||||
if (!autogrowGroup) return
|
||||
if (app.configuringGraph && input.widget)
|
||||
ensureWidgetForInput(node, input)
|
||||
if (iscon && linf) {
|
||||
if (iscon) {
|
||||
if (swappingConnection || !linf) return
|
||||
autogrowInputConnected(slot, this)
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraphData,
|
||||
createTestSubgraphNode
|
||||
} from './subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
import { test } from './__fixtures__/testExtensions'
|
||||
|
||||
@@ -206,6 +210,70 @@ describe('Graph Clearing and Callbacks', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph Definition Garbage Collection', () => {
|
||||
function createSubgraphWithNodes(rootGraph: LGraph, nodeCount: number) {
|
||||
const subgraph = rootGraph.createSubgraph(createTestSubgraphData())
|
||||
|
||||
const innerNodes: LGraphNode[] = []
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
const node = new LGraphNode(`Inner Node ${i}`)
|
||||
subgraph.add(node)
|
||||
innerNodes.push(node)
|
||||
}
|
||||
|
||||
return { subgraph, innerNodes }
|
||||
}
|
||||
|
||||
it('removing SubgraphNode fires onRemoved for inner nodes', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const { subgraph, innerNodes } = createSubgraphWithNodes(rootGraph, 2)
|
||||
const removedNodeIds = new Set<string>()
|
||||
|
||||
for (const node of innerNodes) {
|
||||
node.onRemoved = () => removedNodeIds.add(String(node.id))
|
||||
}
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
expect(subgraph.nodes.length).toBe(2)
|
||||
|
||||
rootGraph.remove(subgraphNode)
|
||||
|
||||
expect(removedNodeIds.size).toBe(2)
|
||||
})
|
||||
|
||||
it('removing SubgraphNode fires onNodeRemoved callback', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const { subgraph } = createSubgraphWithNodes(rootGraph, 2)
|
||||
const graphRemovedNodeIds = new Set<string>()
|
||||
|
||||
subgraph.onNodeRemoved = (node) => graphRemovedNodeIds.add(String(node.id))
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
rootGraph.remove(subgraphNode)
|
||||
|
||||
expect(graphRemovedNodeIds.size).toBe(2)
|
||||
})
|
||||
|
||||
it('subgraph definition is removed when SubgraphNode is removed', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const { subgraph } = createSubgraphWithNodes(rootGraph, 1)
|
||||
const subgraphId = subgraph.id
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
expect(rootGraph.subgraphs.has(subgraphId)).toBe(true)
|
||||
|
||||
rootGraph.remove(subgraphNode)
|
||||
|
||||
expect(rootGraph.subgraphs.has(subgraphId)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Legacy LGraph Compatibility Layer', () => {
|
||||
test('can be extended via prototype', ({ expect, minimalGraph }) => {
|
||||
// @ts-expect-error Should always be an error.
|
||||
|
||||
@@ -992,6 +992,16 @@ export class LGraph
|
||||
}
|
||||
}
|
||||
|
||||
// Subgraph cleanup (use local const to avoid type narrowing affecting node.graph assignment)
|
||||
const subgraphNode = node.isSubgraphNode() ? node : null
|
||||
if (subgraphNode) {
|
||||
for (const innerNode of subgraphNode.subgraph.nodes) {
|
||||
innerNode.onRemoved?.()
|
||||
subgraphNode.subgraph.onNodeRemoved?.(innerNode)
|
||||
}
|
||||
this.rootGraph.subgraphs.delete(subgraphNode.subgraph.id)
|
||||
}
|
||||
|
||||
// callback
|
||||
node.onRemoved?.()
|
||||
|
||||
|
||||
@@ -343,6 +343,28 @@
|
||||
"errorConnecting": "Error connecting to the Comfy Node Registry.",
|
||||
"noResultsFound": "No results found matching your search.",
|
||||
"tryDifferentSearch": "Please try a different search query.",
|
||||
"emptyState": {
|
||||
"allInstalled": {
|
||||
"title": "No Extensions Installed",
|
||||
"message": "You haven't installed any extensions yet."
|
||||
},
|
||||
"updateAvailable": {
|
||||
"title": "All Up to Date",
|
||||
"message": "All your extensions are up to date."
|
||||
},
|
||||
"conflicting": {
|
||||
"title": "No Conflicts Detected",
|
||||
"message": "All your extensions are compatible."
|
||||
},
|
||||
"workflow": {
|
||||
"title": "No Extensions in Workflow",
|
||||
"message": "This workflow doesn't use any extension nodes."
|
||||
},
|
||||
"missing": {
|
||||
"title": "No Missing Nodes",
|
||||
"message": "All required nodes are installed."
|
||||
}
|
||||
},
|
||||
"tryAgainLater": "Please try again later.",
|
||||
"gettingInfo": "Getting info...",
|
||||
"nodePack": "Node Pack",
|
||||
|
||||
@@ -1302,6 +1302,28 @@
|
||||
"discoverCommunityContent": "커뮤니티에서 만든 노드 팩 및 확장 프로그램을 찾아보세요...",
|
||||
"downloads": "다운로드",
|
||||
"enablePackToChangeVersion": "버전을 변경하려면 이 팩을 활성화하세요",
|
||||
"emptyState": {
|
||||
"allInstalled": {
|
||||
"title": "설치된 확장 프로그램 없음",
|
||||
"message": "아직 설치한 확장 프로그램이 없습니다."
|
||||
},
|
||||
"conflicting": {
|
||||
"title": "충돌 없음",
|
||||
"message": "모든 확장 프로그램이 호환됩니다."
|
||||
},
|
||||
"missing": {
|
||||
"title": "누락된 노드 없음",
|
||||
"message": "필요한 모든 노드가 설치되어 있습니다."
|
||||
},
|
||||
"updateAvailable": {
|
||||
"title": "모두 최신 상태",
|
||||
"message": "모든 확장 프로그램이 최신 버전입니다."
|
||||
},
|
||||
"workflow": {
|
||||
"title": "워크플로에 확장 프로그램 없음",
|
||||
"message": "이 워크플로는 확장 노드를 사용하지 않습니다."
|
||||
}
|
||||
},
|
||||
"errorConnecting": "Comfy Node Registry에 연결하는 중 오류가 발생했습니다.",
|
||||
"extensionsSuccessfullyInstalled": "확장이 성공적으로 설치되어 사용할 준비가 되었습니다!",
|
||||
"failed": "실패 ({count})",
|
||||
|
||||
@@ -4,86 +4,272 @@ import { ref } from 'vue'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
// Mock useKeyModifier before importing the composable
|
||||
const mockShiftKey = ref(false)
|
||||
const mockCtrlKey = ref(false)
|
||||
const mockMetaKey = ref(false)
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as object),
|
||||
useKeyModifier: (key: string) => {
|
||||
if (key === 'Shift') return mockShiftKey
|
||||
if (key === 'Control') return mockCtrlKey
|
||||
if (key === 'Meta') return mockMetaKey
|
||||
return ref(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import { useAssetSelection } from './useAssetSelection'
|
||||
import { useAssetSelectionStore } from './useAssetSelectionStore'
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useKeyModifier: vi.fn(() => ref(false))
|
||||
}))
|
||||
function createMockAssets(count: number): AssetItem[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: `asset-${i}`,
|
||||
name: `Asset ${i}`,
|
||||
size: 1000,
|
||||
created_at: new Date().toISOString(),
|
||||
tags: ['output'],
|
||||
preview_url: `http://example.com/asset-${i}.png`
|
||||
}))
|
||||
}
|
||||
|
||||
describe('useAssetSelection', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockShiftKey.value = false
|
||||
mockCtrlKey.value = false
|
||||
mockMetaKey.value = false
|
||||
})
|
||||
|
||||
it('prunes selection to visible assets', () => {
|
||||
const selection = useAssetSelection()
|
||||
const store = useAssetSelectionStore()
|
||||
const assets: AssetItem[] = [
|
||||
{ id: 'a', name: 'a.png', tags: [] },
|
||||
{ id: 'b', name: 'b.png', tags: [] }
|
||||
]
|
||||
describe('reconcileSelection', () => {
|
||||
it('prunes selection to visible assets', () => {
|
||||
const selection = useAssetSelection()
|
||||
const store = useAssetSelectionStore()
|
||||
const assets: AssetItem[] = [
|
||||
{ id: 'a', name: 'a.png', tags: [] },
|
||||
{ id: 'b', name: 'b.png', tags: [] }
|
||||
]
|
||||
|
||||
store.setSelection(['a', 'b'])
|
||||
store.setLastSelectedIndex(1)
|
||||
store.setLastSelectedAssetId('b')
|
||||
store.setSelection(['a', 'b'])
|
||||
store.setLastSelectedIndex(1)
|
||||
store.setLastSelectedAssetId('b')
|
||||
|
||||
selection.reconcileSelection([assets[1]])
|
||||
selection.reconcileSelection([assets[1]])
|
||||
|
||||
expect(Array.from(store.selectedAssetIds)).toEqual(['b'])
|
||||
expect(store.lastSelectedIndex).toBe(0)
|
||||
expect(store.lastSelectedAssetId).toBe('b')
|
||||
expect(Array.from(store.selectedAssetIds)).toEqual(['b'])
|
||||
expect(store.lastSelectedIndex).toBe(0)
|
||||
expect(store.lastSelectedAssetId).toBe('b')
|
||||
})
|
||||
|
||||
it('clears selection when no visible assets remain', () => {
|
||||
const selection = useAssetSelection()
|
||||
const store = useAssetSelectionStore()
|
||||
|
||||
store.setSelection(['a'])
|
||||
store.setLastSelectedIndex(0)
|
||||
store.setLastSelectedAssetId('a')
|
||||
|
||||
selection.reconcileSelection([])
|
||||
|
||||
expect(store.selectedAssetIds.size).toBe(0)
|
||||
expect(store.lastSelectedIndex).toBe(-1)
|
||||
expect(store.lastSelectedAssetId).toBeNull()
|
||||
})
|
||||
|
||||
it('recomputes the anchor index when assets reorder', () => {
|
||||
const selection = useAssetSelection()
|
||||
const store = useAssetSelectionStore()
|
||||
const assets: AssetItem[] = [
|
||||
{ id: 'a', name: 'a.png', tags: [] },
|
||||
{ id: 'b', name: 'b.png', tags: [] }
|
||||
]
|
||||
|
||||
store.setSelection(['a'])
|
||||
store.setLastSelectedIndex(0)
|
||||
store.setLastSelectedAssetId('a')
|
||||
|
||||
selection.reconcileSelection([assets[1], assets[0]])
|
||||
|
||||
expect(store.lastSelectedIndex).toBe(1)
|
||||
expect(store.lastSelectedAssetId).toBe('a')
|
||||
})
|
||||
|
||||
it('clears anchor when the anchored asset is no longer visible', () => {
|
||||
const selection = useAssetSelection()
|
||||
const store = useAssetSelectionStore()
|
||||
const assets: AssetItem[] = [
|
||||
{ id: 'a', name: 'a.png', tags: [] },
|
||||
{ id: 'b', name: 'b.png', tags: [] }
|
||||
]
|
||||
|
||||
store.setSelection(['a', 'b'])
|
||||
store.setLastSelectedIndex(0)
|
||||
store.setLastSelectedAssetId('a')
|
||||
|
||||
selection.reconcileSelection([assets[1]])
|
||||
|
||||
expect(Array.from(store.selectedAssetIds)).toEqual(['b'])
|
||||
expect(store.lastSelectedIndex).toBe(-1)
|
||||
expect(store.lastSelectedAssetId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('clears selection when no visible assets remain', () => {
|
||||
const selection = useAssetSelection()
|
||||
const store = useAssetSelectionStore()
|
||||
describe('handleAssetClick - normal click', () => {
|
||||
it('selects single asset and clears previous selection', () => {
|
||||
const { handleAssetClick, isSelected, selectedCount } =
|
||||
useAssetSelection()
|
||||
const assets = createMockAssets(3)
|
||||
|
||||
store.setSelection(['a'])
|
||||
store.setLastSelectedIndex(0)
|
||||
store.setLastSelectedAssetId('a')
|
||||
handleAssetClick(assets[0], 0, assets)
|
||||
expect(isSelected('asset-0')).toBe(true)
|
||||
expect(selectedCount.value).toBe(1)
|
||||
|
||||
selection.reconcileSelection([])
|
||||
|
||||
expect(store.selectedAssetIds.size).toBe(0)
|
||||
expect(store.lastSelectedIndex).toBe(-1)
|
||||
expect(store.lastSelectedAssetId).toBeNull()
|
||||
handleAssetClick(assets[1], 1, assets)
|
||||
expect(isSelected('asset-0')).toBe(false)
|
||||
expect(isSelected('asset-1')).toBe(true)
|
||||
expect(selectedCount.value).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('recomputes the anchor index when assets reorder', () => {
|
||||
const selection = useAssetSelection()
|
||||
const store = useAssetSelectionStore()
|
||||
const assets: AssetItem[] = [
|
||||
{ id: 'a', name: 'a.png', tags: [] },
|
||||
{ id: 'b', name: 'b.png', tags: [] }
|
||||
]
|
||||
describe('handleAssetClick - shift+click', () => {
|
||||
it('selects range from anchor to clicked item', () => {
|
||||
const { handleAssetClick, isSelected, selectedCount } =
|
||||
useAssetSelection()
|
||||
const assets = createMockAssets(5)
|
||||
|
||||
store.setSelection(['a'])
|
||||
store.setLastSelectedIndex(0)
|
||||
store.setLastSelectedAssetId('a')
|
||||
// Click first item (sets anchor)
|
||||
handleAssetClick(assets[0], 0, assets)
|
||||
|
||||
selection.reconcileSelection([assets[1], assets[0]])
|
||||
// Shift+click third item
|
||||
mockShiftKey.value = true
|
||||
handleAssetClick(assets[2], 2, assets)
|
||||
|
||||
expect(store.lastSelectedIndex).toBe(1)
|
||||
expect(store.lastSelectedAssetId).toBe('a')
|
||||
expect(isSelected('asset-0')).toBe(true)
|
||||
expect(isSelected('asset-1')).toBe(true)
|
||||
expect(isSelected('asset-2')).toBe(true)
|
||||
expect(selectedCount.value).toBe(3)
|
||||
})
|
||||
|
||||
it('replaces selection when shift+clicking smaller range (bug fix)', () => {
|
||||
const { handleAssetClick, isSelected, selectedCount } =
|
||||
useAssetSelection()
|
||||
const assets = createMockAssets(5)
|
||||
|
||||
// Click first item
|
||||
handleAssetClick(assets[0], 0, assets)
|
||||
|
||||
// Shift+click third item -> selects 0,1,2
|
||||
mockShiftKey.value = true
|
||||
handleAssetClick(assets[2], 2, assets)
|
||||
expect(selectedCount.value).toBe(3)
|
||||
|
||||
// Shift+click first item again -> should select only 0 (not 0,1,2)
|
||||
handleAssetClick(assets[0], 0, assets)
|
||||
expect(isSelected('asset-0')).toBe(true)
|
||||
expect(isSelected('asset-1')).toBe(false)
|
||||
expect(isSelected('asset-2')).toBe(false)
|
||||
expect(selectedCount.value).toBe(1)
|
||||
})
|
||||
|
||||
it('works in reverse direction', () => {
|
||||
const { handleAssetClick, isSelected, selectedCount } =
|
||||
useAssetSelection()
|
||||
const assets = createMockAssets(5)
|
||||
|
||||
// Click third item (sets anchor)
|
||||
handleAssetClick(assets[2], 2, assets)
|
||||
|
||||
// Shift+click first item
|
||||
mockShiftKey.value = true
|
||||
handleAssetClick(assets[0], 0, assets)
|
||||
|
||||
expect(isSelected('asset-0')).toBe(true)
|
||||
expect(isSelected('asset-1')).toBe(true)
|
||||
expect(isSelected('asset-2')).toBe(true)
|
||||
expect(selectedCount.value).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
it('clears anchor when the anchored asset is no longer visible', () => {
|
||||
const selection = useAssetSelection()
|
||||
const store = useAssetSelectionStore()
|
||||
const assets: AssetItem[] = [
|
||||
{ id: 'a', name: 'a.png', tags: [] },
|
||||
{ id: 'b', name: 'b.png', tags: [] }
|
||||
]
|
||||
describe('handleAssetClick - ctrl/cmd+click', () => {
|
||||
it('toggles individual selection without clearing others', () => {
|
||||
const { handleAssetClick, isSelected, selectedCount } =
|
||||
useAssetSelection()
|
||||
const assets = createMockAssets(3)
|
||||
|
||||
store.setSelection(['a', 'b'])
|
||||
store.setLastSelectedIndex(0)
|
||||
store.setLastSelectedAssetId('a')
|
||||
handleAssetClick(assets[0], 0, assets)
|
||||
|
||||
selection.reconcileSelection([assets[1]])
|
||||
mockCtrlKey.value = true
|
||||
handleAssetClick(assets[2], 2, assets)
|
||||
|
||||
expect(Array.from(store.selectedAssetIds)).toEqual(['b'])
|
||||
expect(store.lastSelectedIndex).toBe(-1)
|
||||
expect(store.lastSelectedAssetId).toBeNull()
|
||||
expect(isSelected('asset-0')).toBe(true)
|
||||
expect(isSelected('asset-2')).toBe(true)
|
||||
expect(selectedCount.value).toBe(2)
|
||||
})
|
||||
|
||||
it('can deselect with ctrl+click', () => {
|
||||
const { handleAssetClick, isSelected, selectedCount } =
|
||||
useAssetSelection()
|
||||
const assets = createMockAssets(3)
|
||||
|
||||
handleAssetClick(assets[0], 0, assets)
|
||||
mockCtrlKey.value = true
|
||||
handleAssetClick(assets[0], 0, assets)
|
||||
|
||||
expect(isSelected('asset-0')).toBe(false)
|
||||
expect(selectedCount.value).toBe(0)
|
||||
})
|
||||
|
||||
it('toggles with meta key (macOS)', () => {
|
||||
const { handleAssetClick, isSelected, selectedCount } =
|
||||
useAssetSelection()
|
||||
const assets = createMockAssets(3)
|
||||
|
||||
handleAssetClick(assets[0], 0, assets)
|
||||
|
||||
mockMetaKey.value = true
|
||||
handleAssetClick(assets[2], 2, assets)
|
||||
|
||||
expect(isSelected('asset-0')).toBe(true)
|
||||
expect(isSelected('asset-2')).toBe(true)
|
||||
expect(selectedCount.value).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectAll', () => {
|
||||
it('selects all assets', () => {
|
||||
const { selectAll, selectedCount } = useAssetSelection()
|
||||
const assets = createMockAssets(5)
|
||||
|
||||
selectAll(assets)
|
||||
expect(selectedCount.value).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearSelection', () => {
|
||||
it('clears all selections', () => {
|
||||
const { handleAssetClick, clearSelection, selectedCount } =
|
||||
useAssetSelection()
|
||||
const assets = createMockAssets(3)
|
||||
|
||||
handleAssetClick(assets[0], 0, assets)
|
||||
clearSelection()
|
||||
expect(selectedCount.value).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSelectedAssets', () => {
|
||||
it('returns selected asset objects', () => {
|
||||
const { handleAssetClick, getSelectedAssets } = useAssetSelection()
|
||||
const assets = createMockAssets(3)
|
||||
|
||||
handleAssetClick(assets[1], 1, assets)
|
||||
const selected = getSelectedAssets(assets)
|
||||
|
||||
expect(selected).toHaveLength(1)
|
||||
expect(selected[0].id).toBe('asset-1')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -64,13 +64,9 @@ export function useAssetSelection() {
|
||||
const start = Math.min(selectionStore.lastSelectedIndex, index)
|
||||
const end = Math.max(selectionStore.lastSelectedIndex, index)
|
||||
|
||||
// Batch operation for better performance
|
||||
// Select only the range from anchor to clicked item
|
||||
const rangeIds = allAssets.slice(start, end + 1).map((a) => a.id)
|
||||
const existingIds = Array.from(selectionStore.selectedAssetIds)
|
||||
const combinedIds = [...new Set([...existingIds, ...rangeIds])]
|
||||
|
||||
// Single update instead of multiple forEach operations
|
||||
selectionStore.setSelection(combinedIds)
|
||||
selectionStore.setSelection(rangeIds)
|
||||
|
||||
// Don't update lastSelectedIndex for shift selection
|
||||
return
|
||||
|
||||
135
src/platform/assets/composables/useAssetSelectionStore.test.ts
Normal file
135
src/platform/assets/composables/useAssetSelectionStore.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { useAssetSelectionStore } from './useAssetSelectionStore'
|
||||
|
||||
describe('useAssetSelectionStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
describe('addToSelection', () => {
|
||||
it('adds an asset ID to the selection', () => {
|
||||
const store = useAssetSelectionStore()
|
||||
store.addToSelection('asset-1')
|
||||
expect(store.isSelected('asset-1')).toBe(true)
|
||||
expect(store.selectedCount).toBe(1)
|
||||
})
|
||||
|
||||
it('can add multiple IDs', () => {
|
||||
const store = useAssetSelectionStore()
|
||||
store.addToSelection('asset-1')
|
||||
store.addToSelection('asset-2')
|
||||
expect(store.selectedCount).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeFromSelection', () => {
|
||||
it('removes an asset ID from the selection', () => {
|
||||
const store = useAssetSelectionStore()
|
||||
store.addToSelection('asset-1')
|
||||
store.removeFromSelection('asset-1')
|
||||
expect(store.isSelected('asset-1')).toBe(false)
|
||||
expect(store.selectedCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setSelection', () => {
|
||||
it('replaces entire selection with new IDs', () => {
|
||||
const store = useAssetSelectionStore()
|
||||
store.addToSelection('asset-1')
|
||||
store.addToSelection('asset-2')
|
||||
|
||||
store.setSelection(['asset-3', 'asset-4'])
|
||||
|
||||
expect(store.isSelected('asset-1')).toBe(false)
|
||||
expect(store.isSelected('asset-2')).toBe(false)
|
||||
expect(store.isSelected('asset-3')).toBe(true)
|
||||
expect(store.isSelected('asset-4')).toBe(true)
|
||||
expect(store.selectedCount).toBe(2)
|
||||
})
|
||||
|
||||
it('can set to empty selection', () => {
|
||||
const store = useAssetSelectionStore()
|
||||
store.addToSelection('asset-1')
|
||||
store.setSelection([])
|
||||
expect(store.selectedCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearSelection', () => {
|
||||
it('clears all selections and resets lastSelectedIndex', () => {
|
||||
const store = useAssetSelectionStore()
|
||||
store.addToSelection('asset-1')
|
||||
store.setLastSelectedIndex(5)
|
||||
|
||||
store.clearSelection()
|
||||
|
||||
expect(store.selectedCount).toBe(0)
|
||||
expect(store.lastSelectedIndex).toBe(-1)
|
||||
})
|
||||
|
||||
it('resets lastSelectedAssetId', () => {
|
||||
const store = useAssetSelectionStore()
|
||||
store.addToSelection('asset-1')
|
||||
store.setLastSelectedAssetId('asset-1')
|
||||
|
||||
store.clearSelection()
|
||||
|
||||
expect(store.lastSelectedAssetId).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleSelection', () => {
|
||||
it('adds unselected item', () => {
|
||||
const store = useAssetSelectionStore()
|
||||
store.toggleSelection('asset-1')
|
||||
expect(store.isSelected('asset-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('removes selected item', () => {
|
||||
const store = useAssetSelectionStore()
|
||||
store.addToSelection('asset-1')
|
||||
store.toggleSelection('asset-1')
|
||||
expect(store.isSelected('asset-1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSelected', () => {
|
||||
it('returns true for selected items', () => {
|
||||
const store = useAssetSelectionStore()
|
||||
store.addToSelection('asset-1')
|
||||
expect(store.isSelected('asset-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for unselected items', () => {
|
||||
const store = useAssetSelectionStore()
|
||||
expect(store.isSelected('asset-1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setLastSelectedIndex', () => {
|
||||
it('updates lastSelectedIndex', () => {
|
||||
const store = useAssetSelectionStore()
|
||||
store.setLastSelectedIndex(10)
|
||||
expect(store.lastSelectedIndex).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computed properties', () => {
|
||||
it('hasSelection returns true when items are selected', () => {
|
||||
const store = useAssetSelectionStore()
|
||||
expect(store.hasSelection).toBe(false)
|
||||
store.addToSelection('asset-1')
|
||||
expect(store.hasSelection).toBe(true)
|
||||
})
|
||||
|
||||
it('selectedIdsArray returns array of selected IDs', () => {
|
||||
const store = useAssetSelectionStore()
|
||||
store.addToSelection('asset-1')
|
||||
store.addToSelection('asset-2')
|
||||
expect(store.selectedIdsArray).toContain('asset-1')
|
||||
expect(store.selectedIdsArray).toContain('asset-2')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -22,14 +22,7 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => {
|
||||
}
|
||||
|
||||
function setSelection(assetIds: string[]) {
|
||||
// Only update if there's an actual change to prevent unnecessary re-renders
|
||||
const newSet = new Set(assetIds)
|
||||
if (
|
||||
newSet.size !== selectedAssetIds.value.size ||
|
||||
!assetIds.every((id) => selectedAssetIds.value.has(id))
|
||||
) {
|
||||
selectedAssetIds.value = newSet
|
||||
}
|
||||
selectedAssetIds.value = new Set(assetIds)
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
|
||||
@@ -110,13 +110,19 @@ async function createMockNode(overrides?: {
|
||||
widgets: { value: [widget], writable: true }
|
||||
})
|
||||
}
|
||||
function createMockNodeProvider() {
|
||||
function createMockNodeProvider(
|
||||
overrides: {
|
||||
nodeDef?: { name: string; display_name: string }
|
||||
key?: string
|
||||
} = {}
|
||||
) {
|
||||
return {
|
||||
nodeDef: {
|
||||
name: 'CheckpointLoaderSimple',
|
||||
display_name: 'Load Checkpoint'
|
||||
display_name: 'Load Checkpoint',
|
||||
...overrides.nodeDef
|
||||
},
|
||||
key: 'ckpt_name'
|
||||
key: overrides.key ?? 'ckpt_name'
|
||||
}
|
||||
}
|
||||
/**
|
||||
@@ -270,6 +276,24 @@ describe('createModelNodeFromAsset', () => {
|
||||
expect(mockSubgraph.add).toHaveBeenCalledWith(mockNode)
|
||||
expect(vi.mocked(app).canvas.graph!.add).not.toHaveBeenCalled()
|
||||
})
|
||||
it('should succeed when provider has empty key (auto-load nodes)', async () => {
|
||||
const asset = createMockAsset({
|
||||
tags: ['models', 'chatterbox/chatterbox_vc'],
|
||||
user_metadata: { filename: 'chatterbox_vc_model.pt' }
|
||||
})
|
||||
const mockNode = await createMockNode({ hasWidgets: false })
|
||||
const nodeProvider = createMockNodeProvider({
|
||||
nodeDef: {
|
||||
name: 'FL_ChatterboxVC',
|
||||
display_name: 'FL Chatterbox VC'
|
||||
},
|
||||
key: ''
|
||||
})
|
||||
await setupMocks({ createdNode: mockNode, nodeProvider })
|
||||
const result = createModelNodeFromAsset(asset)
|
||||
expect(result.success).toBe(true)
|
||||
expect(vi.mocked(app).canvas.graph!.add).toHaveBeenCalledWith(mockNode)
|
||||
})
|
||||
})
|
||||
describe('when asset data is incomplete or invalid', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -171,26 +171,27 @@ export function createModelNodeFromAsset(
|
||||
}
|
||||
}
|
||||
|
||||
const widget = node.widgets?.find((w) => w.name === provider.key)
|
||||
if (!widget) {
|
||||
console.error(
|
||||
`Widget ${provider.key} not found on node ${provider.nodeDef.name}`
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'MISSING_WIDGET',
|
||||
message: `Widget ${provider.key} not found on node ${provider.nodeDef.name}`,
|
||||
assetId: validAsset.id,
|
||||
details: { widgetName: provider.key, nodeType: provider.nodeDef.name }
|
||||
// Set widget value if provider specifies a key (some nodes auto-load models without a widget)
|
||||
if (provider.key) {
|
||||
const widget = node.widgets?.find((w) => w.name === provider.key)
|
||||
if (!widget) {
|
||||
console.error(
|
||||
`Widget ${provider.key} not found on node ${provider.nodeDef.name}`
|
||||
)
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'MISSING_WIDGET',
|
||||
message: `Widget ${provider.key} not found on node ${provider.nodeDef.name}`,
|
||||
assetId: validAsset.id,
|
||||
details: { widgetName: provider.key, nodeType: provider.nodeDef.name }
|
||||
}
|
||||
}
|
||||
}
|
||||
widget.value = filename
|
||||
}
|
||||
|
||||
// Set widget value BEFORE adding to graph so the node is created with correct value
|
||||
widget.value = filename
|
||||
|
||||
// Now add the node to the graph with the correct widget value already set
|
||||
// Add the node to the graph
|
||||
targetGraph.add(node)
|
||||
|
||||
return { success: true, value: node }
|
||||
|
||||
@@ -1178,7 +1178,7 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
type: 'boolean',
|
||||
tooltip:
|
||||
'Replaces the floating job queue panel with an equivalent job queue embedded in the Assets side panel. You can disable this to return to the floating panel layout.',
|
||||
defaultValue: true,
|
||||
defaultValue: false,
|
||||
experimental: true
|
||||
},
|
||||
{
|
||||
|
||||
@@ -36,6 +36,41 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock modelToNodeStore with proper node providers and category lookups
|
||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
useModelToNodeStore: () => ({
|
||||
getAllNodeProviders: vi.fn((category: string) => {
|
||||
const providers: Record<
|
||||
string,
|
||||
Array<{ nodeDef: { name: string }; key: string }>
|
||||
> = {
|
||||
checkpoints: [
|
||||
{ nodeDef: { name: 'CheckpointLoaderSimple' }, key: 'ckpt_name' },
|
||||
{ nodeDef: { name: 'ImageOnlyCheckpointLoader' }, key: 'ckpt_name' }
|
||||
],
|
||||
loras: [
|
||||
{ nodeDef: { name: 'LoraLoader' }, key: 'lora_name' },
|
||||
{ nodeDef: { name: 'LoraLoaderModelOnly' }, key: 'lora_name' }
|
||||
],
|
||||
vae: [{ nodeDef: { name: 'VAELoader' }, key: 'vae_name' }]
|
||||
}
|
||||
return providers[category] ?? []
|
||||
}),
|
||||
getCategoryForNodeType: vi.fn((nodeType: string) => {
|
||||
const nodeToCategory: Record<string, string> = {
|
||||
CheckpointLoaderSimple: 'checkpoints',
|
||||
ImageOnlyCheckpointLoader: 'checkpoints',
|
||||
LoraLoader: 'loras',
|
||||
LoraLoaderModelOnly: 'loras',
|
||||
VAELoader: 'vae'
|
||||
}
|
||||
return nodeToCategory[nodeType]
|
||||
}),
|
||||
getNodeProvider: vi.fn(),
|
||||
registerDefaults: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
// Mock TaskItemImpl
|
||||
vi.mock('@/stores/queueStore', () => ({
|
||||
TaskItemImpl: class {
|
||||
@@ -546,51 +581,70 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
|
||||
expect(assets.map((a) => a.id)).toContain('new-asset')
|
||||
})
|
||||
|
||||
it('should return cached array on subsequent getAssets calls', () => {
|
||||
it('should return cached array on subsequent getAssets calls', async () => {
|
||||
const store = useAssetsStore()
|
||||
const nodeType = 'TestLoader'
|
||||
const nodeType = 'CheckpointLoaderSimple'
|
||||
const assets = [createMockAsset('cache-test-1')]
|
||||
|
||||
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValue(assets)
|
||||
await store.updateModelsForNodeType(nodeType)
|
||||
|
||||
const firstCall = store.getAssets(nodeType)
|
||||
const secondCall = store.getAssets(nodeType)
|
||||
|
||||
expect(secondCall).toBe(firstCall)
|
||||
expect(firstCall).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('concurrent request handling', () => {
|
||||
it('should discard stale request when newer request starts', async () => {
|
||||
it('should short-circuit concurrent calls to prevent duplicate work', async () => {
|
||||
const store = useAssetsStore()
|
||||
const nodeType = 'CheckpointLoaderSimple'
|
||||
const firstBatch = Array.from({ length: 5 }, (_, i) =>
|
||||
createMockAsset(`first-${i}`)
|
||||
)
|
||||
const secondBatch = Array.from({ length: 10 }, (_, i) =>
|
||||
createMockAsset(`second-${i}`)
|
||||
)
|
||||
|
||||
let resolveFirst: (value: ReturnType<typeof createMockAsset>[]) => void
|
||||
const firstPromise = new Promise<ReturnType<typeof createMockAsset>[]>(
|
||||
(resolve) => {
|
||||
resolveFirst = resolve
|
||||
}
|
||||
)
|
||||
let callCount = 0
|
||||
vi.mocked(assetService.getAssetsForNodeType).mockImplementation(
|
||||
async () => {
|
||||
callCount++
|
||||
return callCount === 1 ? firstPromise : secondBatch
|
||||
}
|
||||
)
|
||||
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValue(firstBatch)
|
||||
|
||||
// Start two concurrent requests for the same category
|
||||
const firstRequest = store.updateModelsForNodeType(nodeType)
|
||||
const secondRequest = store.updateModelsForNodeType(nodeType)
|
||||
resolveFirst!(firstBatch)
|
||||
await Promise.all([firstRequest, secondRequest])
|
||||
|
||||
expect(store.getAssets(nodeType)).toHaveLength(10)
|
||||
// Second request should be short-circuited, only one API call made
|
||||
expect(
|
||||
store.getAssets(nodeType).every((a) => a.id.startsWith('second-'))
|
||||
).toBe(true)
|
||||
vi.mocked(assetService.getAssetsForNodeType)
|
||||
).toHaveBeenCalledTimes(1)
|
||||
expect(store.getAssets(nodeType)).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('should allow new request after previous completes', async () => {
|
||||
const store = useAssetsStore()
|
||||
const nodeType = 'CheckpointLoaderSimple'
|
||||
const firstBatch = [createMockAsset('first-1')]
|
||||
const secondBatch = [
|
||||
createMockAsset('second-1'),
|
||||
createMockAsset('second-2')
|
||||
]
|
||||
|
||||
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce(
|
||||
firstBatch
|
||||
)
|
||||
await store.updateModelsForNodeType(nodeType)
|
||||
expect(store.getAssets(nodeType)).toHaveLength(1)
|
||||
|
||||
// After first completes, a new request should work
|
||||
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce(
|
||||
secondBatch
|
||||
)
|
||||
store.invalidateCategory('checkpoints')
|
||||
await store.updateModelsForNodeType(nodeType)
|
||||
|
||||
expect(store.getAssets(nodeType)).toHaveLength(2)
|
||||
expect(
|
||||
vi.mocked(assetService.getAssetsForNodeType)
|
||||
).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -614,4 +668,87 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
|
||||
expect(loadingStates).toContain(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('category-keyed cache', () => {
|
||||
it('should share cache between node types of the same category', async () => {
|
||||
const store = useAssetsStore()
|
||||
const assets = [createMockAsset('shared-1'), createMockAsset('shared-2')]
|
||||
|
||||
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValue(assets)
|
||||
|
||||
await store.updateModelsForNodeType('CheckpointLoaderSimple')
|
||||
|
||||
expect(store.getAssets('CheckpointLoaderSimple')).toHaveLength(2)
|
||||
expect(store.getAssets('ImageOnlyCheckpointLoader')).toHaveLength(2)
|
||||
expect(
|
||||
vi.mocked(assetService.getAssetsForNodeType)
|
||||
).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should return empty array for unknown node types', () => {
|
||||
const store = useAssetsStore()
|
||||
expect(store.getAssets('UnknownNodeType')).toEqual([])
|
||||
})
|
||||
|
||||
it('should not fetch for unknown node types', async () => {
|
||||
const store = useAssetsStore()
|
||||
await store.updateModelsForNodeType('UnknownNodeType')
|
||||
expect(
|
||||
vi.mocked(assetService.getAssetsForNodeType)
|
||||
).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalidateCategory', () => {
|
||||
it('should clear cache for a category', async () => {
|
||||
const store = useAssetsStore()
|
||||
const assets = [createMockAsset('asset-1'), createMockAsset('asset-2')]
|
||||
|
||||
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValue(assets)
|
||||
await store.updateModelsForNodeType('CheckpointLoaderSimple')
|
||||
expect(store.getAssets('CheckpointLoaderSimple')).toHaveLength(2)
|
||||
|
||||
store.invalidateCategory('checkpoints')
|
||||
|
||||
expect(store.getAssets('CheckpointLoaderSimple')).toEqual([])
|
||||
expect(store.hasAssetKey('CheckpointLoaderSimple')).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow refetch after invalidation', async () => {
|
||||
const store = useAssetsStore()
|
||||
const initialAssets = [createMockAsset('initial-1')]
|
||||
const refreshedAssets = [
|
||||
createMockAsset('refreshed-1'),
|
||||
createMockAsset('refreshed-2')
|
||||
]
|
||||
|
||||
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce(
|
||||
initialAssets
|
||||
)
|
||||
await store.updateModelsForNodeType('LoraLoader')
|
||||
expect(store.getAssets('LoraLoader')).toHaveLength(1)
|
||||
|
||||
store.invalidateCategory('loras')
|
||||
|
||||
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce(
|
||||
refreshedAssets
|
||||
)
|
||||
await store.updateModelsForNodeType('LoraLoader')
|
||||
|
||||
expect(store.getAssets('LoraLoader')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should invalidate tag-based caches', async () => {
|
||||
const store = useAssetsStore()
|
||||
const assets = [createMockAsset('tag-asset-1')]
|
||||
|
||||
vi.mocked(assetService.getAssetsByTag).mockResolvedValue(assets)
|
||||
await store.updateModelsForTag('models')
|
||||
expect(store.getAssets('tag:models')).toHaveLength(1)
|
||||
|
||||
store.invalidateCategory('tag:models')
|
||||
|
||||
expect(store.getAssets('tag:models')).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -279,20 +279,22 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Model assets cached by node type (e.g., 'CheckpointLoaderSimple', 'LoraLoader')
|
||||
* Used by multiple loader nodes to avoid duplicate fetches
|
||||
* Model assets cached by category (e.g., 'checkpoints', 'loras')
|
||||
* Multiple node types sharing the same category share the same cache entry.
|
||||
* Public API accepts nodeType for backwards compatibility but translates
|
||||
* to category internally using modelToNodeStore.getCategoryForNodeType().
|
||||
* Cloud-only feature - empty Maps in desktop builds
|
||||
*/
|
||||
const getModelState = () => {
|
||||
if (isCloud) {
|
||||
const modelStateByKey = ref(new Map<string, ModelPaginationState>())
|
||||
const modelStateByCategory = ref(new Map<string, ModelPaginationState>())
|
||||
|
||||
const assetsArrayCache = new Map<
|
||||
string,
|
||||
{ source: Map<string, AssetItem>; array: AssetItem[] }
|
||||
>()
|
||||
|
||||
const pendingRequestByKey = new Map<string, ModelPaginationState>()
|
||||
const pendingRequestByCategory = new Map<string, ModelPaginationState>()
|
||||
|
||||
function createState(
|
||||
existingAssets?: Map<string, AssetItem>
|
||||
@@ -306,64 +308,103 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
})
|
||||
}
|
||||
|
||||
function isStale(key: string, state: ModelPaginationState): boolean {
|
||||
const committed = modelStateByKey.value.get(key)
|
||||
const pending = pendingRequestByKey.get(key)
|
||||
function isStale(category: string, state: ModelPaginationState): boolean {
|
||||
const committed = modelStateByCategory.value.get(category)
|
||||
const pending = pendingRequestByCategory.get(category)
|
||||
return committed !== state && pending !== state
|
||||
}
|
||||
|
||||
const EMPTY_ASSETS: AssetItem[] = []
|
||||
|
||||
/**
|
||||
* Resolve a key to a category. Handles both nodeType and tag:xxx formats.
|
||||
* @param key Either a nodeType (e.g., 'CheckpointLoaderSimple') or tag key (e.g., 'tag:models')
|
||||
* @returns The category or undefined if not resolvable
|
||||
*/
|
||||
function resolveCategory(key: string): string | undefined {
|
||||
if (key.startsWith('tag:')) {
|
||||
return key
|
||||
}
|
||||
return modelToNodeStore.getCategoryForNodeType(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get assets by nodeType or tag key.
|
||||
* Translates nodeType to category internally for cache lookup.
|
||||
* @param key Either a nodeType (e.g., 'CheckpointLoaderSimple') or tag key (e.g., 'tag:models')
|
||||
*/
|
||||
function getAssets(key: string): AssetItem[] {
|
||||
const state = modelStateByKey.value.get(key)
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return EMPTY_ASSETS
|
||||
|
||||
const state = modelStateByCategory.value.get(category)
|
||||
const assetsMap = state?.assets
|
||||
if (!assetsMap) return EMPTY_ASSETS
|
||||
|
||||
const cached = assetsArrayCache.get(key)
|
||||
const cached = assetsArrayCache.get(category)
|
||||
if (cached && cached.source === assetsMap) {
|
||||
return cached.array
|
||||
}
|
||||
|
||||
const array = Array.from(assetsMap.values())
|
||||
assetsArrayCache.set(key, { source: assetsMap, array })
|
||||
assetsArrayCache.set(category, { source: assetsMap, array })
|
||||
return array
|
||||
}
|
||||
|
||||
function isLoading(key: string): boolean {
|
||||
return modelStateByKey.value.get(key)?.isLoading ?? false
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return false
|
||||
return modelStateByCategory.value.get(category)?.isLoading ?? false
|
||||
}
|
||||
|
||||
function getError(key: string): Error | undefined {
|
||||
return modelStateByKey.value.get(key)?.error
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return undefined
|
||||
return modelStateByCategory.value.get(category)?.error
|
||||
}
|
||||
|
||||
function hasMore(key: string): boolean {
|
||||
return modelStateByKey.value.get(key)?.hasMore ?? false
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return false
|
||||
return modelStateByCategory.value.get(category)?.hasMore ?? false
|
||||
}
|
||||
|
||||
function hasAssetKey(key: string): boolean {
|
||||
return modelStateByKey.value.has(key)
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return false
|
||||
return modelStateByCategory.value.has(category)
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper to fetch and cache assets with a given key and fetcher.
|
||||
* Internal helper to fetch and cache assets for a category.
|
||||
* Loads first batch immediately, then progressively loads remaining batches.
|
||||
* Keeps existing data visible until new data is successfully fetched.
|
||||
*
|
||||
* Concurrent calls for the same category are short-circuited: if a request
|
||||
* is already in progress (tracked via pendingRequestByCategory), subsequent
|
||||
* calls return immediately to avoid redundant work.
|
||||
*/
|
||||
async function updateModelsForKey(
|
||||
key: string,
|
||||
async function updateModelsForCategory(
|
||||
category: string,
|
||||
fetcher: (options: PaginationOptions) => Promise<AssetItem[]>
|
||||
): Promise<void> {
|
||||
const existingState = modelStateByKey.value.get(key)
|
||||
// Short-circuit if a request for this category is already in progress
|
||||
if (pendingRequestByCategory.has(category)) {
|
||||
return
|
||||
}
|
||||
|
||||
const existingState = modelStateByCategory.value.get(category)
|
||||
const state = createState(existingState?.assets)
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
|
||||
const hasExistingData = modelStateByKey.value.has(key)
|
||||
const hasExistingData = modelStateByCategory.value.has(category)
|
||||
if (hasExistingData) {
|
||||
pendingRequestByKey.set(key, state)
|
||||
pendingRequestByCategory.set(category, state)
|
||||
} else {
|
||||
modelStateByKey.value.set(key, state)
|
||||
// Also track in pending map for initial loads to prevent concurrent calls
|
||||
pendingRequestByCategory.set(category, state)
|
||||
modelStateByCategory.value.set(category, state)
|
||||
}
|
||||
|
||||
async function loadBatches(): Promise<void> {
|
||||
@@ -374,14 +415,14 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
offset: state.offset
|
||||
})
|
||||
|
||||
if (isStale(key, state)) return
|
||||
if (isStale(category, state)) return
|
||||
|
||||
const isFirstBatch = state.offset === 0
|
||||
if (isFirstBatch) {
|
||||
assetsArrayCache.delete(key)
|
||||
assetsArrayCache.delete(category)
|
||||
if (hasExistingData) {
|
||||
pendingRequestByKey.delete(key)
|
||||
modelStateByKey.value.set(key, state)
|
||||
pendingRequestByCategory.delete(category)
|
||||
modelStateByCategory.value.set(category, state)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,13 +444,13 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
} catch (err) {
|
||||
if (isStale(key, state)) return
|
||||
console.error(`Error loading batch for ${key}:`, err)
|
||||
if (isStale(category, state)) return
|
||||
console.error(`Error loading batch for ${category}:`, err)
|
||||
|
||||
state.error = err instanceof Error ? err : new Error(String(err))
|
||||
state.hasMore = false
|
||||
state.isLoading = false
|
||||
pendingRequestByKey.delete(key)
|
||||
pendingRequestByCategory.delete(category)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -421,18 +462,25 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
for (const id of staleIds) {
|
||||
state.assets.delete(id)
|
||||
}
|
||||
assetsArrayCache.delete(key)
|
||||
assetsArrayCache.delete(category)
|
||||
pendingRequestByCategory.delete(category)
|
||||
}
|
||||
|
||||
await loadBatches()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and cache model assets for a specific node type
|
||||
* Fetch and cache model assets for a specific node type.
|
||||
* Translates nodeType to category internally - multiple node types
|
||||
* sharing the same category will share the same cache entry.
|
||||
* @param nodeType The node type to fetch assets for (e.g., 'CheckpointLoaderSimple')
|
||||
*/
|
||||
async function updateModelsForNodeType(nodeType: string): Promise<void> {
|
||||
await updateModelsForKey(nodeType, (opts) =>
|
||||
const category = modelToNodeStore.getCategoryForNodeType(nodeType)
|
||||
if (!category) return
|
||||
|
||||
// Use category as cache key but fetch using nodeType for API compatibility
|
||||
await updateModelsForCategory(category, (opts) =>
|
||||
assetService.getAssetsForNodeType(nodeType, opts)
|
||||
)
|
||||
}
|
||||
@@ -442,12 +490,23 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
* @param tag The tag to fetch assets for (e.g., 'models')
|
||||
*/
|
||||
async function updateModelsForTag(tag: string): Promise<void> {
|
||||
const key = `tag:${tag}`
|
||||
await updateModelsForKey(key, (opts) =>
|
||||
const category = `tag:${tag}`
|
||||
await updateModelsForCategory(category, (opts) =>
|
||||
assetService.getAssetsByTag(tag, true, opts)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the cache for a specific category.
|
||||
* Forces a refetch on next access.
|
||||
* @param category The category to invalidate (e.g., 'checkpoints', 'loras')
|
||||
*/
|
||||
function invalidateCategory(category: string): void {
|
||||
modelStateByCategory.value.delete(category)
|
||||
assetsArrayCache.delete(category)
|
||||
pendingRequestByCategory.delete(category)
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistically update an asset in the cache
|
||||
* @param assetId The asset ID to update
|
||||
@@ -459,19 +518,22 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
updates: Partial<AssetItem>,
|
||||
cacheKey?: string
|
||||
) {
|
||||
const keysToCheck = cacheKey
|
||||
? [cacheKey]
|
||||
: Array.from(modelStateByKey.value.keys())
|
||||
const category = cacheKey ? resolveCategory(cacheKey) : undefined
|
||||
if (cacheKey && !category) return
|
||||
|
||||
for (const key of keysToCheck) {
|
||||
const state = modelStateByKey.value.get(key)
|
||||
const categoriesToCheck = category
|
||||
? [category]
|
||||
: Array.from(modelStateByCategory.value.keys())
|
||||
|
||||
for (const cat of categoriesToCheck) {
|
||||
const state = modelStateByCategory.value.get(cat)
|
||||
if (!state?.assets) continue
|
||||
|
||||
const existingAsset = state.assets.get(assetId)
|
||||
if (existingAsset) {
|
||||
const updatedAsset = { ...existingAsset, ...updates }
|
||||
state.assets.set(assetId, updatedAsset)
|
||||
assetsArrayCache.delete(key)
|
||||
assetsArrayCache.delete(cat)
|
||||
if (cacheKey) return
|
||||
}
|
||||
}
|
||||
@@ -554,6 +616,7 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
hasAssetKey,
|
||||
updateModelsForNodeType,
|
||||
updateModelsForTag,
|
||||
invalidateCategory,
|
||||
updateAssetMetadata,
|
||||
updateAssetTags
|
||||
}
|
||||
@@ -567,6 +630,7 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
hasMore: () => false,
|
||||
hasAssetKey: () => false,
|
||||
updateModelsForNodeType: async () => {},
|
||||
invalidateCategory: () => {},
|
||||
updateModelsForTag: async () => {},
|
||||
updateAssetMetadata: async () => {},
|
||||
updateAssetTags: async () => {}
|
||||
@@ -581,6 +645,7 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
hasAssetKey,
|
||||
updateModelsForNodeType,
|
||||
updateModelsForTag,
|
||||
invalidateCategory,
|
||||
updateAssetMetadata,
|
||||
updateAssetTags
|
||||
} = getModelState()
|
||||
@@ -657,6 +722,7 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
// Model assets - actions
|
||||
updateModelsForNodeType,
|
||||
updateModelsForTag,
|
||||
invalidateCategory,
|
||||
updateAssetMetadata,
|
||||
updateAssetTags
|
||||
}
|
||||
|
||||
@@ -23,7 +23,11 @@ const EXPECTED_DEFAULT_TYPES = [
|
||||
'audio_encoders',
|
||||
'model_patches',
|
||||
'animatediff_models',
|
||||
'animatediff_motion_lora'
|
||||
'animatediff_motion_lora',
|
||||
'chatterbox/chatterbox',
|
||||
'chatterbox/chatterbox_turbo',
|
||||
'chatterbox/chatterbox_multilingual',
|
||||
'chatterbox/chatterbox_vc'
|
||||
] as const
|
||||
|
||||
type NodeDefStoreType = ReturnType<typeof useNodeDefStore>
|
||||
@@ -61,7 +65,11 @@ const MOCK_NODE_NAMES = [
|
||||
'AudioEncoderLoader',
|
||||
'ModelPatchLoader',
|
||||
'ADE_LoadAnimateDiffModel',
|
||||
'ADE_AnimateDiffLoRALoader'
|
||||
'ADE_AnimateDiffLoRALoader',
|
||||
'FL_ChatterboxTTS',
|
||||
'FL_ChatterboxTurboTTS',
|
||||
'FL_ChatterboxMultilingualTTS',
|
||||
'FL_ChatterboxVC'
|
||||
] as const
|
||||
|
||||
const mockNodeDefsByName = Object.fromEntries(
|
||||
@@ -135,6 +143,36 @@ describe('useModelToNodeStore', () => {
|
||||
const provider = modelToNodeStore.getNodeProvider('checkpoints')
|
||||
expect(provider).toBeDefined()
|
||||
})
|
||||
|
||||
it('should fallback to top-level folder for hierarchical model types', () => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
modelToNodeStore.registerDefaults()
|
||||
|
||||
const provider = modelToNodeStore.getNodeProvider('checkpoints/subfolder')
|
||||
expect(provider).toBeDefined()
|
||||
expect(provider?.nodeDef?.name).toBe('CheckpointLoaderSimple')
|
||||
})
|
||||
|
||||
it('should return undefined for hierarchical type with unregistered top-level', () => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
modelToNodeStore.registerDefaults()
|
||||
|
||||
expect(
|
||||
modelToNodeStore.getNodeProvider('UnknownType/subfolder')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return provider for chatterbox nodes with empty key', () => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
modelToNodeStore.registerDefaults()
|
||||
|
||||
const provider = modelToNodeStore.getNodeProvider(
|
||||
'chatterbox/chatterbox_vc'
|
||||
)
|
||||
expect(provider).toBeDefined()
|
||||
expect(provider?.nodeDef?.name).toBe('FL_ChatterboxVC')
|
||||
expect(provider?.key).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAllNodeProviders', () => {
|
||||
@@ -184,6 +222,17 @@ describe('useModelToNodeStore', () => {
|
||||
const providers = modelToNodeStore.getAllNodeProviders('checkpoints')
|
||||
expect(providers.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should fallback to top-level folder for hierarchical model types', () => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
modelToNodeStore.registerDefaults()
|
||||
|
||||
const providers = modelToNodeStore.getAllNodeProviders(
|
||||
'checkpoints/subfolder'
|
||||
)
|
||||
expect(providers).toHaveLength(2)
|
||||
expect(providers[0].nodeDef.name).toBe('CheckpointLoaderSimple')
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerNodeProvider', () => {
|
||||
@@ -491,5 +540,16 @@ describe('useModelToNodeStore', () => {
|
||||
expect(modelToNodeStore.getNodeProvider('')).toBeUndefined()
|
||||
expect(modelToNodeStore.getAllNodeProviders('')).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle invalid input types gracefully', () => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
modelToNodeStore.registerDefaults()
|
||||
|
||||
expect(modelToNodeStore.getNodeProvider(null as any)).toBeUndefined()
|
||||
expect(modelToNodeStore.getNodeProvider(undefined as any)).toBeUndefined()
|
||||
expect(modelToNodeStore.getNodeProvider(123 as any)).toBeUndefined()
|
||||
expect(modelToNodeStore.getAllNodeProviders(null as any)).toEqual([])
|
||||
expect(modelToNodeStore.getAllNodeProviders(undefined as any)).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ export class ModelNodeProvider {
|
||||
/** The node definition to use for this model. */
|
||||
public nodeDef: ComfyNodeDefImpl
|
||||
|
||||
/** The node input key for where to inside the model name. */
|
||||
/** The node input key for where to insert the model name. */
|
||||
public key: string
|
||||
|
||||
constructor(nodeDef: ComfyNodeDefImpl, key: string) {
|
||||
@@ -73,23 +73,51 @@ export const useModelToNodeStore = defineStore('modelToNode', () => {
|
||||
return nodeTypeToCategory.value[nodeType]
|
||||
}
|
||||
|
||||
/**
|
||||
* Find providers for modelType with hierarchical fallback.
|
||||
* Tries exact match first, then falls back to top-level segment (e.g., "parent/child" → "parent").
|
||||
* Note: Only falls back one level; "a/b/c" tries "a/b/c" then "a", not "a/b".
|
||||
*/
|
||||
function findProvidersWithFallback(
|
||||
modelType: string
|
||||
): ModelNodeProvider[] | undefined {
|
||||
if (!modelType || typeof modelType !== 'string') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const exactMatch = modelToNodeMap.value[modelType]
|
||||
if (exactMatch && exactMatch.length > 0) return exactMatch
|
||||
|
||||
const topLevel = modelType.split('/')[0]
|
||||
if (topLevel === modelType) return undefined
|
||||
|
||||
const fallback = modelToNodeMap.value[topLevel]
|
||||
|
||||
if (fallback && fallback.length > 0) return fallback
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the node provider for the given model type name.
|
||||
* Supports hierarchical lookups: if "parent/child" has no match, falls back to "parent".
|
||||
* @param modelType The name of the model type to get the node provider for.
|
||||
* @returns The node provider for the given model type name.
|
||||
*/
|
||||
function getNodeProvider(modelType: string): ModelNodeProvider | undefined {
|
||||
registerDefaults()
|
||||
return modelToNodeMap.value[modelType]?.[0]
|
||||
return findProvidersWithFallback(modelType)?.[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of all valid node providers for the given model type name.
|
||||
* Supports hierarchical lookups: if "parent/child" has no match, falls back to "parent".
|
||||
* @param modelType The name of the model type to get the node providers for.
|
||||
* @returns The list of all valid node providers for the given model type name.
|
||||
*/
|
||||
function getAllNodeProviders(modelType: string): ModelNodeProvider[] {
|
||||
registerDefaults()
|
||||
return modelToNodeMap.value[modelType] ?? []
|
||||
return findProvidersWithFallback(modelType) ?? []
|
||||
}
|
||||
/**
|
||||
* Register a node provider for the given model type name.
|
||||
@@ -153,6 +181,17 @@ export const useModelToNodeStore = defineStore('modelToNode', () => {
|
||||
'ADE_AnimateDiffLoRALoader',
|
||||
'name'
|
||||
)
|
||||
|
||||
// Chatterbox TTS nodes: empty key means the node auto-loads models without
|
||||
// a widget selector (createModelNodeFromAsset skips widget assignment)
|
||||
quickRegister('chatterbox/chatterbox', 'FL_ChatterboxTTS', '')
|
||||
quickRegister('chatterbox/chatterbox_turbo', 'FL_ChatterboxTurboTTS', '')
|
||||
quickRegister(
|
||||
'chatterbox/chatterbox_multilingual',
|
||||
'FL_ChatterboxMultilingualTTS',
|
||||
''
|
||||
)
|
||||
quickRegister('chatterbox/chatterbox_vc', 'FL_ChatterboxVC', '')
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -130,16 +130,8 @@
|
||||
</div>
|
||||
<NoResultsPlaceholder
|
||||
v-else-if="displayPacks.length === 0"
|
||||
:title="
|
||||
comfyManagerStore.error
|
||||
? $t('manager.errorConnecting')
|
||||
: $t('manager.noResultsFound')
|
||||
"
|
||||
:message="
|
||||
comfyManagerStore.error
|
||||
? $t('manager.tryAgainLater')
|
||||
: $t('manager.tryDifferentSearch')
|
||||
"
|
||||
:title="emptyStateTitle"
|
||||
:message="emptyStateMessage"
|
||||
/>
|
||||
<div v-else class="h-full w-full" @click="handleGridContainerClick">
|
||||
<VirtualGrid
|
||||
@@ -398,6 +390,40 @@ const isMissingTab = computed(
|
||||
() => selectedTab.value?.id === ManagerTab.Missing
|
||||
)
|
||||
|
||||
// Map of tab IDs to their empty state i18n key suffixes
|
||||
const tabEmptyStateKeys: Partial<Record<ManagerTab, string>> = {
|
||||
[ManagerTab.AllInstalled]: 'allInstalled',
|
||||
[ManagerTab.UpdateAvailable]: 'updateAvailable',
|
||||
[ManagerTab.Conflicting]: 'conflicting',
|
||||
[ManagerTab.Workflow]: 'workflow',
|
||||
[ManagerTab.Missing]: 'missing'
|
||||
}
|
||||
|
||||
// Empty state messages based on current tab and search state
|
||||
const emptyStateTitle = computed(() => {
|
||||
if (comfyManagerStore.error) return t('manager.errorConnecting')
|
||||
if (searchQuery.value) return t('manager.noResultsFound')
|
||||
|
||||
const tabId = selectedTab.value?.id as ManagerTab | undefined
|
||||
const emptyStateKey = tabId ? tabEmptyStateKeys[tabId] : undefined
|
||||
|
||||
return emptyStateKey
|
||||
? t(`manager.emptyState.${emptyStateKey}.title`)
|
||||
: t('manager.noResultsFound')
|
||||
})
|
||||
|
||||
const emptyStateMessage = computed(() => {
|
||||
if (comfyManagerStore.error) return t('manager.tryAgainLater')
|
||||
if (searchQuery.value) return t('manager.tryDifferentSearch')
|
||||
|
||||
const tabId = selectedTab.value?.id as ManagerTab | undefined
|
||||
const emptyStateKey = tabId ? tabEmptyStateKeys[tabId] : undefined
|
||||
|
||||
return emptyStateKey
|
||||
? t(`manager.emptyState.${emptyStateKey}.message`)
|
||||
: t('manager.tryDifferentSearch')
|
||||
})
|
||||
|
||||
const onClickWarningLink = () => {
|
||||
window.open(
|
||||
buildDocsUrl('/troubleshooting/custom-node-issues', {
|
||||
|
||||
Reference in New Issue
Block a user