mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-11 06:50:05 +00:00
Compare commits
4 Commits
test/cover
...
v1.44.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44f88027b6 | ||
|
|
5d07de1913 | ||
|
|
f0ae91de43 | ||
|
|
fb8025c49f |
@@ -84,6 +84,7 @@
|
||||
"typescript/no-unsafe-declaration-merging": "off",
|
||||
"typescript/no-unused-vars": "off",
|
||||
"unicorn/no-empty-file": "off",
|
||||
"vitest/require-mock-type-parameters": "off",
|
||||
"unicorn/no-new-array": "off",
|
||||
"unicorn/no-single-promise-in-promise-methods": "off",
|
||||
"unicorn/no-useless-fallback-in-spread": "off",
|
||||
|
||||
@@ -180,6 +180,48 @@ test.describe('Node Interaction', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Node Duplication', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Pin this suite to the legacy canvas path so Alt+drag exercises
|
||||
// LGraphCanvas, not the Vue node drag handler.
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test('Can duplicate a regular node via Alt+drag', async ({ comfyPage }) => {
|
||||
const before = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||
expect(
|
||||
before,
|
||||
'Expected exactly 2 CLIPTextEncode nodes in default graph'
|
||||
).toHaveLength(2)
|
||||
|
||||
const target = before[0]
|
||||
const pos = await target.getPosition()
|
||||
const src = { x: pos.x + 16, y: pos.y + 16 }
|
||||
|
||||
await comfyPage.page.mouse.move(src.x, src.y)
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
try {
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.mouse.move(src.x + 120, src.y + 80, { steps: 20 })
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
} finally {
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
}
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')).length
|
||||
)
|
||||
.toBe(3)
|
||||
expect(await target.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Edge Interaction', { tag: '@screenshot' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
|
||||
@@ -11,17 +11,22 @@ test.describe('Vue Node Moving', () => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
const getLoadCheckpointHeaderPos = async (comfyPage: ComfyPage) => {
|
||||
const loadCheckpointHeaderPos = await comfyPage.page
|
||||
.getByText('Load Checkpoint')
|
||||
const getHeaderPos = async (
|
||||
comfyPage: ComfyPage,
|
||||
title: string
|
||||
): Promise<{ x: number; y: number; width: number; height: number }> => {
|
||||
const box = await comfyPage.vueNodes
|
||||
.getNodeByTitle(title)
|
||||
.getByTestId('node-title')
|
||||
.first()
|
||||
.boundingBox()
|
||||
|
||||
if (!loadCheckpointHeaderPos)
|
||||
throw new Error('Load Checkpoint header not found')
|
||||
|
||||
return loadCheckpointHeaderPos
|
||||
if (!box) throw new Error(`${title} header not found`)
|
||||
return box
|
||||
}
|
||||
|
||||
const getLoadCheckpointHeaderPos = async (comfyPage: ComfyPage) =>
|
||||
getHeaderPos(comfyPage, 'Load Checkpoint')
|
||||
|
||||
const expectPosChanged = async (pos1: Position, pos2: Position) => {
|
||||
const diffX = Math.abs(pos2.x - pos1.x)
|
||||
const diffY = Math.abs(pos2.y - pos1.y)
|
||||
@@ -29,6 +34,16 @@ test.describe('Vue Node Moving', () => {
|
||||
expect(diffY).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
const deltaBetween = (before: Position, after: Position) => ({
|
||||
x: after.x - before.x,
|
||||
y: after.y - before.y
|
||||
})
|
||||
|
||||
const expectSameDelta = (a: Position, b: Position, tol = 2) => {
|
||||
expect(Math.abs(a.x - b.x)).toBeLessThanOrEqual(tol)
|
||||
expect(Math.abs(a.y - b.y)).toBeLessThanOrEqual(tol)
|
||||
}
|
||||
|
||||
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
|
||||
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
|
||||
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
|
||||
@@ -80,6 +95,73 @@ test.describe('Vue Node Moving', () => {
|
||||
await expectPosChanged(headerPos, afterPos)
|
||||
})
|
||||
|
||||
test('should move all selected nodes together when dragging one with Meta held', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const checkpointBefore = await getHeaderPos(comfyPage, 'Load Checkpoint')
|
||||
const ksamplerBefore = await getHeaderPos(comfyPage, 'KSampler')
|
||||
const latentBefore = await getHeaderPos(comfyPage, 'Empty Latent Image')
|
||||
|
||||
const dx = 120
|
||||
const dy = 80
|
||||
|
||||
const clickNodeTitleWithMeta = async (title: string) => {
|
||||
await comfyPage.vueNodes
|
||||
.getNodeByTitle(title)
|
||||
.locator('[data-testid="node-title"]')
|
||||
.first()
|
||||
.click({ modifiers: ['Meta'] })
|
||||
}
|
||||
|
||||
await comfyPage.page.keyboard.down('Meta')
|
||||
try {
|
||||
await clickNodeTitleWithMeta('Load Checkpoint')
|
||||
await clickNodeTitleWithMeta('KSampler')
|
||||
await clickNodeTitleWithMeta('Empty Latent Image')
|
||||
await expect(comfyPage.vueNodes.selectedNodes).toHaveCount(3)
|
||||
|
||||
// Re-fetch drag source after clicks in case the header reflowed.
|
||||
const dragSrc = await getHeaderPos(comfyPage, 'Load Checkpoint')
|
||||
const centerX = dragSrc.x + dragSrc.width / 2
|
||||
const centerY = dragSrc.y + dragSrc.height / 2
|
||||
|
||||
await comfyPage.page.mouse.move(centerX, centerY)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.page.mouse.move(centerX + dx, centerY + dy, {
|
||||
steps: 20
|
||||
})
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
} finally {
|
||||
await comfyPage.page.keyboard.up('Meta')
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
await expect(comfyPage.vueNodes.selectedNodes).toHaveCount(3)
|
||||
|
||||
const checkpointAfter = await getHeaderPos(comfyPage, 'Load Checkpoint')
|
||||
const ksamplerAfter = await getHeaderPos(comfyPage, 'KSampler')
|
||||
const latentAfter = await getHeaderPos(comfyPage, 'Empty Latent Image')
|
||||
|
||||
// All three nodes should have moved together by the same delta.
|
||||
// We don't assert the exact screen delta equals the dragged pixel delta,
|
||||
// because canvas scaling and snap-to-grid can introduce offsets.
|
||||
const checkpointDelta = deltaBetween(checkpointBefore, checkpointAfter)
|
||||
const ksamplerDelta = deltaBetween(ksamplerBefore, ksamplerAfter)
|
||||
const latentDelta = deltaBetween(latentBefore, latentAfter)
|
||||
|
||||
// Confirm an actual drag happened (not zero movement).
|
||||
expect(Math.abs(checkpointDelta.x)).toBeGreaterThan(10)
|
||||
expect(Math.abs(checkpointDelta.y)).toBeGreaterThan(10)
|
||||
|
||||
// Confirm all selected nodes moved by the same delta.
|
||||
expectSameDelta(checkpointDelta, ksamplerDelta)
|
||||
expectSameDelta(checkpointDelta, latentDelta)
|
||||
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
})
|
||||
|
||||
test(
|
||||
'@mobile should allow moving nodes by dragging on touch devices',
|
||||
{ tag: '@screenshot' },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.44.1",
|
||||
"version": "1.44.2",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ref } from 'vue'
|
||||
|
||||
import { useCoreCommands } from '@/composables/useCoreCommands'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -27,51 +26,23 @@ vi.mock('@/scripts/app', () => {
|
||||
const mockCanvas = {
|
||||
subgraph: undefined,
|
||||
selectedItems: new Set(),
|
||||
selected_nodes: null as Record<string, unknown> | null,
|
||||
copyToClipboard: vi.fn(),
|
||||
pasteFromClipboard: vi.fn(),
|
||||
selectItems: vi.fn(),
|
||||
deleteSelected: vi.fn(),
|
||||
setDirty: vi.fn(),
|
||||
fitViewToSelectionAnimated: vi.fn(),
|
||||
empty: false,
|
||||
ds: {
|
||||
scale: 1,
|
||||
element: { width: 800, height: 600 },
|
||||
changeScale: vi.fn()
|
||||
},
|
||||
state: {
|
||||
readOnly: false,
|
||||
selectionChanged: false
|
||||
},
|
||||
graph: {
|
||||
add: vi.fn(),
|
||||
convertToSubgraph: vi.fn(),
|
||||
rootGraph: {}
|
||||
},
|
||||
select: vi.fn(),
|
||||
canvas: {
|
||||
dispatchEvent: vi.fn()
|
||||
},
|
||||
setGraph: vi.fn()
|
||||
selectItems: vi.fn()
|
||||
}
|
||||
|
||||
return {
|
||||
app: {
|
||||
clean: vi.fn(() => {
|
||||
// Simulate app.clean() calling graph.clear() only when not in subgraph
|
||||
if (!mockCanvas.subgraph) {
|
||||
mockGraphClear()
|
||||
}
|
||||
}),
|
||||
canvas: mockCanvas,
|
||||
rootGraph: {
|
||||
clear: mockGraphClear,
|
||||
_nodes: []
|
||||
},
|
||||
queuePrompt: vi.fn(),
|
||||
refreshComboInNodes: vi.fn(),
|
||||
openClipspace: vi.fn(),
|
||||
ui: { loadFile: vi.fn() }
|
||||
clear: mockGraphClear
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -79,9 +50,7 @@ vi.mock('@/scripts/app', () => {
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
dispatchCustomEvent: vi.fn(),
|
||||
apiURL: vi.fn(() => 'http://localhost:8188'),
|
||||
interrupt: vi.fn(),
|
||||
freeMemory: vi.fn()
|
||||
apiURL: vi.fn(() => 'http://localhost:8188')
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -120,17 +89,12 @@ vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: vi.fn(() => ({}))
|
||||
}))
|
||||
|
||||
const mockToastStore = vi.hoisted(() => ({
|
||||
add: vi.fn()
|
||||
}))
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: vi.fn(() => mockToastStore)
|
||||
vi.mock('@/stores/toastStore', () => ({
|
||||
useToastStore: vi.fn(() => ({}))
|
||||
}))
|
||||
|
||||
const mockChangeTracker = vi.hoisted(() => ({
|
||||
checkState: vi.fn(),
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn()
|
||||
checkState: vi.fn()
|
||||
}))
|
||||
const mockWorkflowStore = vi.hoisted(() => ({
|
||||
activeWorkflow: {
|
||||
@@ -145,29 +109,22 @@ vi.mock('@/stores/subgraphStore', () => ({
|
||||
useSubgraphStore: vi.fn(() => ({}))
|
||||
}))
|
||||
|
||||
const mockCanvasStore = vi.hoisted(() => ({
|
||||
getCanvas: vi.fn(),
|
||||
canvas: null as unknown,
|
||||
linearMode: false,
|
||||
updateSelectedItems: vi.fn()
|
||||
}))
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: vi.fn(() => mockCanvasStore),
|
||||
useCanvasStore: vi.fn(() => ({
|
||||
getCanvas: () => app.canvas,
|
||||
canvas: app.canvas
|
||||
})),
|
||||
useTitleEditorStore: vi.fn(() => ({
|
||||
titleEditorTarget: null
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
|
||||
useColorPaletteStore: vi.fn(() => ({
|
||||
completedActivePalette: { id: 'dark-default', light_theme: false }
|
||||
}))
|
||||
useColorPaletteStore: vi.fn(() => ({}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useAuthActions', () => ({
|
||||
useAuthActions: vi.fn(() => ({
|
||||
logout: vi.fn()
|
||||
}))
|
||||
useAuthActions: vi.fn(() => ({}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
@@ -177,88 +134,10 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockIsActiveSubscription = vi.hoisted(() => ({ value: true }))
|
||||
const mockShowSubscriptionDialog = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: vi.fn(() => ({
|
||||
isActiveSubscription: mockIsActiveSubscription,
|
||||
showSubscriptionDialog: mockShowSubscriptionDialog
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: vi.fn(() => ({
|
||||
userEmail: ref(''),
|
||||
resolvedUserInfo: ref(null)
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockSelectedItems = vi.hoisted(() => ({
|
||||
getSelectedNodes: vi.fn((): unknown[] => []),
|
||||
toggleSelectedNodesMode: vi.fn()
|
||||
}))
|
||||
vi.mock('@/composables/canvas/useSelectedLiteGraphItems', () => ({
|
||||
useSelectedLiteGraphItems: vi.fn(() => mockSelectedItems)
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSubgraphOperations', () => ({
|
||||
useSubgraphOperations: vi.fn(() => ({
|
||||
unpackSubgraph: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useExternalLink', () => ({
|
||||
useExternalLink: vi.fn(() => ({
|
||||
staticUrls: {
|
||||
githubIssues: 'https://github.com/issues',
|
||||
discord: 'https://discord.gg/test',
|
||||
forum: 'https://forum.test.com'
|
||||
},
|
||||
buildDocsUrl: vi.fn(() => 'https://docs.test.com')
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useModelSelectorDialog', () => ({
|
||||
useModelSelectorDialog: vi.fn(() => ({
|
||||
show: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useWorkflowTemplateSelectorDialog', () => ({
|
||||
useWorkflowTemplateSelectorDialog: vi.fn(() => ({
|
||||
show: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/composables/useAssetBrowserDialog', () => ({
|
||||
useAssetBrowserDialog: vi.fn(() => ({
|
||||
browse: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/assets/utils/createModelNodeFromAsset', () => ({
|
||||
createModelNodeFromAsset: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/support/config', () => ({
|
||||
buildSupportUrl: vi.fn(() => 'https://support.test.com')
|
||||
}))
|
||||
|
||||
const mockTelemetry = vi.hoisted(() => ({
|
||||
trackWorkflowCreated: vi.fn(),
|
||||
trackRunButton: vi.fn(),
|
||||
trackWorkflowExecution: vi.fn(),
|
||||
trackHelpResourceClicked: vi.fn(),
|
||||
trackEnterLinear: vi.fn()
|
||||
}))
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => mockTelemetry)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/composables/useSettingsDialog', () => ({
|
||||
useSettingsDialog: vi.fn(() => ({
|
||||
show: vi.fn(),
|
||||
showAbout: vi.fn()
|
||||
isActiveSubscription: { value: true },
|
||||
showSubscriptionDialog: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -275,9 +154,13 @@ describe('useCoreCommands', () => {
|
||||
|
||||
const createMockSubgraph = () => {
|
||||
const mockNodes = [
|
||||
// Mock input node
|
||||
createMockNode(1, 'SubgraphInputNode'),
|
||||
// Mock output node
|
||||
createMockNode(2, 'SubgraphOutputNode'),
|
||||
// Mock user node
|
||||
createMockNode(3, 'SomeUserNode'),
|
||||
// Another mock user node
|
||||
createMockNode(4, 'AnotherUserNode')
|
||||
]
|
||||
|
||||
@@ -346,38 +229,31 @@ describe('useCoreCommands', () => {
|
||||
} satisfies ReturnType<typeof useSettingStore>
|
||||
}
|
||||
|
||||
function findCommand(id: string) {
|
||||
const cmd = useCoreCommands().find((c) => c.id === id)
|
||||
if (!cmd) throw new Error(`Command '${id}' not found`)
|
||||
return cmd
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Set up Pinia
|
||||
setActivePinia(createPinia())
|
||||
|
||||
// Reset app state
|
||||
app.canvas.subgraph = undefined
|
||||
app.canvas.selectedItems = new Set()
|
||||
app.canvas.state.readOnly = false
|
||||
app.canvas.state.selectionChanged = false
|
||||
Object.defineProperty(app.canvas, 'empty', { value: false, writable: true })
|
||||
mockCanvasStore.linearMode = false
|
||||
mockCanvasStore.getCanvas.mockReturnValue(app.canvas)
|
||||
mockIsActiveSubscription.value = true
|
||||
|
||||
// Mock settings store
|
||||
vi.mocked(useSettingStore).mockReturnValue(createMockSettingStore(false))
|
||||
|
||||
vi.stubGlobal('confirm', vi.fn().mockReturnValue(true))
|
||||
vi.stubGlobal(
|
||||
'open',
|
||||
vi.fn().mockReturnValue({ focus: vi.fn(), closed: false })
|
||||
)
|
||||
// Mock global confirm
|
||||
global.confirm = vi.fn().mockReturnValue(true)
|
||||
})
|
||||
|
||||
describe('ClearWorkflow command', () => {
|
||||
it('should clear main graph when not in subgraph', async () => {
|
||||
await findCommand('Comfy.ClearWorkflow').function()
|
||||
const commands = useCoreCommands()
|
||||
const clearCommand = commands.find(
|
||||
(cmd) => cmd.id === 'Comfy.ClearWorkflow'
|
||||
)!
|
||||
|
||||
// Execute the command
|
||||
await clearCommand.function()
|
||||
|
||||
expect(app.clean).toHaveBeenCalled()
|
||||
expect(app.rootGraph.clear).toHaveBeenCalled()
|
||||
@@ -385,29 +261,46 @@ describe('useCoreCommands', () => {
|
||||
})
|
||||
|
||||
it('should preserve input/output nodes when clearing subgraph', async () => {
|
||||
// Set up subgraph context
|
||||
app.canvas.subgraph = mockSubgraph
|
||||
|
||||
await findCommand('Comfy.ClearWorkflow').function()
|
||||
const commands = useCoreCommands()
|
||||
const clearCommand = commands.find(
|
||||
(cmd) => cmd.id === 'Comfy.ClearWorkflow'
|
||||
)!
|
||||
|
||||
// Execute the command
|
||||
await clearCommand.function()
|
||||
|
||||
expect(app.clean).toHaveBeenCalled()
|
||||
expect(app.rootGraph.clear).not.toHaveBeenCalled()
|
||||
|
||||
// Should only remove user nodes, not input/output nodes
|
||||
const subgraph = app.canvas.subgraph!
|
||||
expect(subgraph.remove).toHaveBeenCalledTimes(2)
|
||||
expect(subgraph.remove).toHaveBeenCalledWith(subgraph.nodes[2])
|
||||
expect(subgraph.remove).toHaveBeenCalledWith(subgraph.nodes[3])
|
||||
expect(subgraph.remove).not.toHaveBeenCalledWith(subgraph.nodes[0])
|
||||
expect(subgraph.remove).not.toHaveBeenCalledWith(subgraph.nodes[1])
|
||||
expect(subgraph.remove).toHaveBeenCalledWith(subgraph.nodes[2]) // user1
|
||||
expect(subgraph.remove).toHaveBeenCalledWith(subgraph.nodes[3]) // user2
|
||||
expect(subgraph.remove).not.toHaveBeenCalledWith(subgraph.nodes[0]) // input1
|
||||
expect(subgraph.remove).not.toHaveBeenCalledWith(subgraph.nodes[1]) // output1
|
||||
|
||||
expect(api.dispatchCustomEvent).toHaveBeenCalledWith('graphCleared')
|
||||
})
|
||||
|
||||
it('should respect confirmation setting', async () => {
|
||||
// Mock confirmation required
|
||||
vi.mocked(useSettingStore).mockReturnValue(createMockSettingStore(true))
|
||||
vi.stubGlobal('confirm', vi.fn().mockReturnValue(false))
|
||||
|
||||
await findCommand('Comfy.ClearWorkflow').function()
|
||||
global.confirm = vi.fn().mockReturnValue(false) // User cancels
|
||||
|
||||
const commands = useCoreCommands()
|
||||
const clearCommand = commands.find(
|
||||
(cmd) => cmd.id === 'Comfy.ClearWorkflow'
|
||||
)!
|
||||
|
||||
// Execute the command
|
||||
await clearCommand.function()
|
||||
|
||||
// Should not clear anything when user cancels
|
||||
expect(app.clean).not.toHaveBeenCalled()
|
||||
expect(app.rootGraph.clear).not.toHaveBeenCalled()
|
||||
expect(api.dispatchCustomEvent).not.toHaveBeenCalled()
|
||||
@@ -415,6 +308,17 @@ describe('useCoreCommands', () => {
|
||||
})
|
||||
|
||||
describe('Canvas clipboard commands', () => {
|
||||
function findCommand(id: string) {
|
||||
return useCoreCommands().find((cmd) => cmd.id === id)!
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
app.canvas.selectedItems = new Set()
|
||||
vi.mocked(app.canvas.copyToClipboard).mockClear()
|
||||
vi.mocked(app.canvas.pasteFromClipboard).mockClear()
|
||||
vi.mocked(app.canvas.selectItems).mockClear()
|
||||
})
|
||||
|
||||
it('should copy selected items when selection exists', async () => {
|
||||
app.canvas.selectedItems = new Set([
|
||||
{}
|
||||
@@ -437,540 +341,14 @@ describe('useCoreCommands', () => {
|
||||
expect(app.canvas.pasteFromClipboard).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('should paste with connect option', async () => {
|
||||
await findCommand('Comfy.Canvas.PasteFromClipboardWithConnect').function()
|
||||
|
||||
expect(app.canvas.pasteFromClipboard).toHaveBeenCalledWith({
|
||||
connectInputs: true
|
||||
})
|
||||
})
|
||||
|
||||
it('should select all items', async () => {
|
||||
await findCommand('Comfy.Canvas.SelectAll').function()
|
||||
|
||||
// No arguments means "select all items on canvas"
|
||||
expect(app.canvas.selectItems).toHaveBeenCalledWith()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Undo/Redo commands', () => {
|
||||
it('Undo should call changeTracker.undo', async () => {
|
||||
await findCommand('Comfy.Undo').function()
|
||||
|
||||
expect(mockChangeTracker.undo).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Redo should call changeTracker.redo', async () => {
|
||||
await findCommand('Comfy.Redo').function()
|
||||
|
||||
expect(mockChangeTracker.redo).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Zoom commands', () => {
|
||||
it('ZoomIn should increase scale and mark dirty', async () => {
|
||||
await findCommand('Comfy.Canvas.ZoomIn').function()
|
||||
|
||||
expect(app.canvas.ds.changeScale).toHaveBeenCalled()
|
||||
expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('ZoomOut should decrease scale and mark dirty', async () => {
|
||||
await findCommand('Comfy.Canvas.ZoomOut').function()
|
||||
|
||||
expect(app.canvas.ds.changeScale).toHaveBeenCalled()
|
||||
expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('ToggleLock should toggle readOnly state', async () => {
|
||||
app.canvas.state.readOnly = false
|
||||
|
||||
await findCommand('Comfy.Canvas.ToggleLock').function()
|
||||
expect(app.canvas.state.readOnly).toBe(true)
|
||||
|
||||
await findCommand('Comfy.Canvas.ToggleLock').function()
|
||||
expect(app.canvas.state.readOnly).toBe(false)
|
||||
})
|
||||
|
||||
it('Lock should set readOnly to true', async () => {
|
||||
await findCommand('Comfy.Canvas.Lock').function()
|
||||
expect(app.canvas.state.readOnly).toBe(true)
|
||||
})
|
||||
|
||||
it('Unlock should set readOnly to false', async () => {
|
||||
app.canvas.state.readOnly = true
|
||||
await findCommand('Comfy.Canvas.Unlock').function()
|
||||
expect(app.canvas.state.readOnly).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Canvas delete command', () => {
|
||||
it('should delete selected items when selection exists', async () => {
|
||||
app.canvas.selectedItems = new Set([
|
||||
{}
|
||||
]) as typeof app.canvas.selectedItems
|
||||
|
||||
await findCommand('Comfy.Canvas.DeleteSelectedItems').function()
|
||||
|
||||
expect(app.canvas.deleteSelected).toHaveBeenCalled()
|
||||
expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('should dispatch no-items-selected event when nothing selected', async () => {
|
||||
app.canvas.selectedItems = new Set()
|
||||
|
||||
await findCommand('Comfy.Canvas.DeleteSelectedItems').function()
|
||||
|
||||
expect(app.canvas.canvas.dispatchEvent).toHaveBeenCalled()
|
||||
expect(app.canvas.deleteSelected).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToggleLinkVisibility command', () => {
|
||||
it('should hide links when currently visible', async () => {
|
||||
const mockStore = createMockSettingStore(false)
|
||||
mockStore.get = vi.fn().mockReturnValue(LiteGraph.SPLINE_LINK)
|
||||
vi.mocked(useSettingStore).mockReturnValue(mockStore)
|
||||
|
||||
await findCommand('Comfy.Canvas.ToggleLinkVisibility').function()
|
||||
|
||||
expect(mockStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.LinkRenderMode',
|
||||
LiteGraph.HIDDEN_LINK
|
||||
)
|
||||
})
|
||||
|
||||
it('should restore links when currently hidden', async () => {
|
||||
const mockStore = createMockSettingStore(false)
|
||||
mockStore.get = vi.fn().mockReturnValue(LiteGraph.HIDDEN_LINK)
|
||||
vi.mocked(useSettingStore).mockReturnValue(mockStore)
|
||||
|
||||
await findCommand('Comfy.Canvas.ToggleLinkVisibility').function()
|
||||
|
||||
expect(mockStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.LinkRenderMode',
|
||||
expect.any(Number)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToggleMinimap command', () => {
|
||||
it('should toggle minimap visibility setting', async () => {
|
||||
const mockStore = createMockSettingStore(false)
|
||||
mockStore.get = vi.fn().mockReturnValue(false)
|
||||
vi.mocked(useSettingStore).mockReturnValue(mockStore)
|
||||
|
||||
await findCommand('Comfy.Canvas.ToggleMinimap').function()
|
||||
|
||||
expect(mockStore.set).toHaveBeenCalledWith('Comfy.Minimap.Visible', true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('QueuePrompt commands', () => {
|
||||
it('should show subscription dialog when not subscribed', async () => {
|
||||
mockIsActiveSubscription.value = false
|
||||
|
||||
await findCommand('Comfy.QueuePrompt').function()
|
||||
|
||||
expect(mockShowSubscriptionDialog).toHaveBeenCalled()
|
||||
expect(app.queuePrompt).not.toHaveBeenCalled()
|
||||
|
||||
mockIsActiveSubscription.value = true
|
||||
})
|
||||
|
||||
it('should queue prompt when subscribed', async () => {
|
||||
await findCommand('Comfy.QueuePrompt').function()
|
||||
|
||||
expect(app.queuePrompt).toHaveBeenCalledWith(0, 1)
|
||||
expect(mockTelemetry.trackRunButton).toHaveBeenCalled()
|
||||
expect(mockTelemetry.trackWorkflowExecution).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should queue prompt at front', async () => {
|
||||
await findCommand('Comfy.QueuePromptFront').function()
|
||||
|
||||
expect(app.queuePrompt).toHaveBeenCalledWith(-1, 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('QueueSelectedOutputNodes command', () => {
|
||||
it('should show error toast when no output nodes selected', async () => {
|
||||
await findCommand('Comfy.QueueSelectedOutputNodes').function()
|
||||
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'error' })
|
||||
)
|
||||
expect(app.queuePrompt).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MoveSelectedNodes commands', () => {
|
||||
function setupMoveTest() {
|
||||
const mockNode = createMockLGraphNode({ id: 1 })
|
||||
mockNode.pos = [100, 200] as [number, number]
|
||||
mockSelectedItems.getSelectedNodes.mockReturnValue([mockNode])
|
||||
|
||||
const mockStore = createMockSettingStore(false)
|
||||
mockStore.get = vi.fn().mockReturnValue(10)
|
||||
vi.mocked(useSettingStore).mockReturnValue(mockStore)
|
||||
|
||||
return mockNode
|
||||
}
|
||||
|
||||
it('should move nodes up by grid size', async () => {
|
||||
const mockNode = setupMoveTest()
|
||||
|
||||
await findCommand('Comfy.Canvas.MoveSelectedNodes.Up').function()
|
||||
|
||||
expect(mockNode.pos).toEqual([100, 190])
|
||||
expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('should move nodes down by grid size', async () => {
|
||||
const mockNode = setupMoveTest()
|
||||
|
||||
await findCommand('Comfy.Canvas.MoveSelectedNodes.Down').function()
|
||||
|
||||
expect(mockNode.pos).toEqual([100, 210])
|
||||
})
|
||||
|
||||
it('should move nodes left by grid size', async () => {
|
||||
const mockNode = setupMoveTest()
|
||||
|
||||
await findCommand('Comfy.Canvas.MoveSelectedNodes.Left').function()
|
||||
|
||||
expect(mockNode.pos).toEqual([90, 200])
|
||||
})
|
||||
|
||||
it('should move nodes right by grid size', async () => {
|
||||
const mockNode = setupMoveTest()
|
||||
|
||||
await findCommand('Comfy.Canvas.MoveSelectedNodes.Right').function()
|
||||
|
||||
expect(mockNode.pos).toEqual([110, 200])
|
||||
})
|
||||
|
||||
it('should not move when no nodes selected', async () => {
|
||||
mockSelectedItems.getSelectedNodes.mockReturnValue([])
|
||||
|
||||
await findCommand('Comfy.Canvas.MoveSelectedNodes.Up').function()
|
||||
|
||||
expect(app.canvas.setDirty).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToggleLinear command', () => {
|
||||
it('should toggle linear mode and track telemetry when entering', async () => {
|
||||
mockCanvasStore.linearMode = false
|
||||
|
||||
await findCommand('Comfy.ToggleLinear').function()
|
||||
|
||||
expect(mockCanvasStore.linearMode).toBe(true)
|
||||
expect(mockTelemetry.trackEnterLinear).toHaveBeenCalledWith({
|
||||
source: 'keybind'
|
||||
})
|
||||
})
|
||||
|
||||
it('should use provided source metadata', async () => {
|
||||
mockCanvasStore.linearMode = false
|
||||
|
||||
await findCommand('Comfy.ToggleLinear').function({
|
||||
source: 'menu'
|
||||
})
|
||||
|
||||
expect(mockTelemetry.trackEnterLinear).toHaveBeenCalledWith({
|
||||
source: 'menu'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToggleQPOV2 command', () => {
|
||||
it('should toggle queue panel v2 setting', async () => {
|
||||
const mockStore = createMockSettingStore(false)
|
||||
mockStore.get = vi.fn().mockReturnValue(false)
|
||||
vi.mocked(useSettingStore).mockReturnValue(mockStore)
|
||||
|
||||
await findCommand('Comfy.ToggleQPOV2').function()
|
||||
|
||||
expect(mockStore.set).toHaveBeenCalledWith('Comfy.Queue.QPOV2', true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Memory commands', () => {
|
||||
it('UnloadModels should show error when setting is disabled', async () => {
|
||||
const mockStore = createMockSettingStore(false)
|
||||
mockStore.get = vi.fn().mockReturnValue(false)
|
||||
vi.mocked(useSettingStore).mockReturnValue(mockStore)
|
||||
|
||||
await findCommand('Comfy.Memory.UnloadModels').function()
|
||||
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'error' })
|
||||
)
|
||||
expect(api.freeMemory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('UnloadModels should call api.freeMemory when setting is enabled', async () => {
|
||||
const mockStore = createMockSettingStore(false)
|
||||
mockStore.get = vi.fn().mockReturnValue(true)
|
||||
vi.mocked(useSettingStore).mockReturnValue(mockStore)
|
||||
|
||||
await findCommand('Comfy.Memory.UnloadModels').function()
|
||||
|
||||
expect(api.freeMemory).toHaveBeenCalledWith({
|
||||
freeExecutionCache: false
|
||||
})
|
||||
})
|
||||
|
||||
it('UnloadModelsAndExecutionCache should call api.freeMemory with cache flag', async () => {
|
||||
const mockStore = createMockSettingStore(false)
|
||||
mockStore.get = vi.fn().mockReturnValue(true)
|
||||
vi.mocked(useSettingStore).mockReturnValue(mockStore)
|
||||
|
||||
await findCommand('Comfy.Memory.UnloadModelsAndExecutionCache').function()
|
||||
|
||||
expect(api.freeMemory).toHaveBeenCalledWith({
|
||||
freeExecutionCache: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('FitView command', () => {
|
||||
it('should show error toast when canvas is empty', async () => {
|
||||
Object.defineProperty(app.canvas, 'empty', {
|
||||
value: true,
|
||||
writable: true
|
||||
})
|
||||
|
||||
await findCommand('Comfy.Canvas.FitView').function()
|
||||
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'error' })
|
||||
)
|
||||
expect(app.canvas.fitViewToSelectionAnimated).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fit view when canvas has content', async () => {
|
||||
Object.defineProperty(app.canvas, 'empty', {
|
||||
value: false,
|
||||
writable: true
|
||||
})
|
||||
|
||||
await findCommand('Comfy.Canvas.FitView').function()
|
||||
|
||||
expect(app.canvas.fitViewToSelectionAnimated).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interrupt command', () => {
|
||||
it('should call api.interrupt and show toast', async () => {
|
||||
await findCommand('Comfy.Interrupt').function()
|
||||
|
||||
expect(api.interrupt).toHaveBeenCalled()
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'info' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('OpenWorkflow command', () => {
|
||||
it('should call app.ui.loadFile', async () => {
|
||||
await findCommand('Comfy.OpenWorkflow').function()
|
||||
|
||||
expect(app.ui.loadFile).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RefreshNodeDefinitions command', () => {
|
||||
it('should call app.refreshComboInNodes', async () => {
|
||||
await findCommand('Comfy.RefreshNodeDefinitions').function()
|
||||
|
||||
expect(app.refreshComboInNodes).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('OpenClipspace command', () => {
|
||||
it('should call app.openClipspace', async () => {
|
||||
await findCommand('Comfy.OpenClipspace').function()
|
||||
|
||||
expect(app.openClipspace).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToggleTheme command', () => {
|
||||
it('should switch from dark to light theme', async () => {
|
||||
const mockStore = createMockSettingStore(false)
|
||||
vi.mocked(useSettingStore).mockReturnValue(mockStore)
|
||||
|
||||
await findCommand('Comfy.ToggleTheme').function()
|
||||
|
||||
expect(mockStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.ColorPalette',
|
||||
expect.any(String)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToggleSelectedNodes commands', () => {
|
||||
it('Mute should toggle selected nodes mode and mark dirty', async () => {
|
||||
await findCommand('Comfy.Canvas.ToggleSelectedNodes.Mute').function()
|
||||
|
||||
expect(mockSelectedItems.toggleSelectedNodesMode).toHaveBeenCalled()
|
||||
expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('Bypass should toggle selected nodes mode and mark dirty', async () => {
|
||||
await findCommand('Comfy.Canvas.ToggleSelectedNodes.Bypass').function()
|
||||
|
||||
expect(mockSelectedItems.toggleSelectedNodesMode).toHaveBeenCalled()
|
||||
expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('Pin should toggle pin state on each selected node', async () => {
|
||||
const mockNode = createMockLGraphNode({ id: 1 })
|
||||
Object.defineProperty(mockNode, 'pinned', {
|
||||
value: false,
|
||||
writable: true
|
||||
})
|
||||
mockNode.pin = vi.fn()
|
||||
mockSelectedItems.getSelectedNodes.mockReturnValue([mockNode])
|
||||
|
||||
await findCommand('Comfy.Canvas.ToggleSelectedNodes.Pin').function()
|
||||
|
||||
expect(mockNode.pin).toHaveBeenCalledWith(true)
|
||||
expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('Collapse should collapse each selected node', async () => {
|
||||
const mockNode = createMockLGraphNode({ id: 1 })
|
||||
mockNode.collapse = vi.fn()
|
||||
mockSelectedItems.getSelectedNodes.mockReturnValue([mockNode])
|
||||
|
||||
await findCommand('Comfy.Canvas.ToggleSelectedNodes.Collapse').function()
|
||||
|
||||
expect(mockNode.collapse).toHaveBeenCalled()
|
||||
expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('Resize should compute and set optimal size', async () => {
|
||||
const mockNode = createMockLGraphNode({ id: 1 })
|
||||
mockNode.computeSize = vi.fn().mockReturnValue([200, 100])
|
||||
mockNode.setSize = vi.fn()
|
||||
mockSelectedItems.getSelectedNodes.mockReturnValue([mockNode])
|
||||
|
||||
await findCommand('Comfy.Canvas.Resize').function()
|
||||
|
||||
expect(mockNode.computeSize).toHaveBeenCalled()
|
||||
expect(mockNode.setSize).toHaveBeenCalledWith([200, 100])
|
||||
expect(app.canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Help commands', () => {
|
||||
it('OpenComfyUIIssues should open GitHub issues and track telemetry', async () => {
|
||||
await findCommand('Comfy.Help.OpenComfyUIIssues').function()
|
||||
|
||||
expect(mockTelemetry.trackHelpResourceClicked).toHaveBeenCalledWith({
|
||||
resource_type: 'github',
|
||||
is_external: true,
|
||||
source: 'menu'
|
||||
})
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://github.com/issues',
|
||||
'_blank'
|
||||
)
|
||||
})
|
||||
|
||||
it('OpenComfyUIDocs should open docs and track telemetry', async () => {
|
||||
await findCommand('Comfy.Help.OpenComfyUIDocs').function()
|
||||
|
||||
expect(mockTelemetry.trackHelpResourceClicked).toHaveBeenCalledWith({
|
||||
resource_type: 'docs',
|
||||
is_external: true,
|
||||
source: 'menu'
|
||||
})
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://docs.test.com',
|
||||
'_blank'
|
||||
)
|
||||
})
|
||||
|
||||
it('OpenComfyOrgDiscord should open Discord and track telemetry', async () => {
|
||||
await findCommand('Comfy.Help.OpenComfyOrgDiscord').function()
|
||||
|
||||
expect(mockTelemetry.trackHelpResourceClicked).toHaveBeenCalledWith({
|
||||
resource_type: 'discord',
|
||||
is_external: true,
|
||||
source: 'menu'
|
||||
})
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://discord.gg/test',
|
||||
'_blank'
|
||||
)
|
||||
})
|
||||
|
||||
it('OpenComfyUIForum should open forum and track telemetry', async () => {
|
||||
await findCommand('Comfy.Help.OpenComfyUIForum').function()
|
||||
|
||||
expect(mockTelemetry.trackHelpResourceClicked).toHaveBeenCalledWith({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'menu'
|
||||
})
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://forum.test.com',
|
||||
'_blank'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GroupSelectedNodes command', () => {
|
||||
it('should show error toast when nothing selected', async () => {
|
||||
app.canvas.selectedItems = new Set()
|
||||
|
||||
await findCommand('Comfy.Graph.GroupSelectedNodes').function()
|
||||
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'error' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConvertToSubgraph command', () => {
|
||||
it('should show error toast when conversion fails', async () => {
|
||||
app.canvas.graph!.convertToSubgraph = vi.fn().mockReturnValue(null)
|
||||
|
||||
await findCommand('Comfy.Graph.ConvertToSubgraph').function()
|
||||
|
||||
expect(mockToastStore.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'error' })
|
||||
)
|
||||
})
|
||||
|
||||
it('should select the new subgraph node on success', async () => {
|
||||
const mockNode = createMockLGraphNode({ id: 1 })
|
||||
app.canvas.graph!.convertToSubgraph = vi
|
||||
.fn()
|
||||
.mockReturnValue({ node: mockNode })
|
||||
|
||||
await findCommand('Comfy.Graph.ConvertToSubgraph').function()
|
||||
|
||||
expect(app.canvas.select).toHaveBeenCalledWith(mockNode)
|
||||
expect(mockCanvasStore.updateSelectedItems).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ContactSupport command', () => {
|
||||
it('should open support URL in new window', async () => {
|
||||
await findCommand('Comfy.ContactSupport').function()
|
||||
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://support.test.com',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph metadata commands', () => {
|
||||
beforeEach(() => {
|
||||
mockSubgraph.extra = {}
|
||||
|
||||
138
src/composables/useReconnectingNotification.test.ts
Normal file
138
src/composables/useReconnectingNotification.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useReconnectingNotification } from '@/composables/useReconnectingNotification'
|
||||
|
||||
const mockToastAdd = vi.fn()
|
||||
const mockToastRemove = vi.fn()
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({
|
||||
add: mockToastAdd,
|
||||
remove: mockToastRemove
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}))
|
||||
|
||||
const settingMocks = vi.hoisted(() => ({
|
||||
disableToast: false
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
get: vi.fn((key: string) => {
|
||||
if (key === 'Comfy.Toast.DisableReconnectingToast')
|
||||
return settingMocks.disableToast
|
||||
return undefined
|
||||
})
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('useReconnectingNotification', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
settingMocks.disableToast = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('does not show toast immediately on reconnecting', () => {
|
||||
const { onReconnecting } = useReconnectingNotification()
|
||||
|
||||
onReconnecting()
|
||||
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows error toast after delay', () => {
|
||||
const { onReconnecting } = useReconnectingNotification()
|
||||
|
||||
onReconnecting()
|
||||
vi.advanceTimersByTime(1500)
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
summary: 'g.reconnecting'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('suppresses toast when reconnected before delay expires', () => {
|
||||
const { onReconnecting, onReconnected } = useReconnectingNotification()
|
||||
|
||||
onReconnecting()
|
||||
vi.advanceTimersByTime(500)
|
||||
onReconnected()
|
||||
vi.advanceTimersByTime(1500)
|
||||
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
expect(mockToastRemove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes toast and shows success when reconnected after delay', () => {
|
||||
const { onReconnecting, onReconnected } = useReconnectingNotification()
|
||||
|
||||
onReconnecting()
|
||||
vi.advanceTimersByTime(1500)
|
||||
mockToastAdd.mockClear()
|
||||
|
||||
onReconnected()
|
||||
|
||||
expect(mockToastRemove).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
summary: 'g.reconnecting'
|
||||
})
|
||||
)
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'success',
|
||||
summary: 'g.reconnected',
|
||||
life: 2000
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does nothing when toast is disabled via setting', () => {
|
||||
settingMocks.disableToast = true
|
||||
const { onReconnecting, onReconnected } = useReconnectingNotification()
|
||||
|
||||
onReconnecting()
|
||||
vi.advanceTimersByTime(1500)
|
||||
onReconnected()
|
||||
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
expect(mockToastRemove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when onReconnected is called without prior onReconnecting', () => {
|
||||
const { onReconnected } = useReconnectingNotification()
|
||||
|
||||
onReconnected()
|
||||
|
||||
expect(mockToastAdd).not.toHaveBeenCalled()
|
||||
expect(mockToastRemove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles multiple reconnecting events without duplicating toasts', () => {
|
||||
const { onReconnecting } = useReconnectingNotification()
|
||||
|
||||
onReconnecting()
|
||||
vi.advanceTimersByTime(1500) // first toast fires
|
||||
onReconnecting() // second reconnecting event
|
||||
vi.advanceTimersByTime(1500) // second toast fires
|
||||
|
||||
expect(mockToastAdd).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
52
src/composables/useReconnectingNotification.ts
Normal file
52
src/composables/useReconnectingNotification.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import type { ToastMessageOptions } from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const RECONNECT_TOAST_DELAY_MS = 1500
|
||||
|
||||
export function useReconnectingNotification() {
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const reconnectingMessage: ToastMessageOptions = {
|
||||
severity: 'error',
|
||||
summary: t('g.reconnecting')
|
||||
}
|
||||
|
||||
const reconnectingToastShown = ref(false)
|
||||
|
||||
const { start, stop } = useTimeoutFn(
|
||||
() => {
|
||||
toast.add(reconnectingMessage)
|
||||
reconnectingToastShown.value = true
|
||||
},
|
||||
RECONNECT_TOAST_DELAY_MS,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
function onReconnecting() {
|
||||
if (settingStore.get('Comfy.Toast.DisableReconnectingToast')) return
|
||||
start()
|
||||
}
|
||||
|
||||
function onReconnected() {
|
||||
stop()
|
||||
|
||||
if (reconnectingToastShown.value) {
|
||||
toast.remove(reconnectingMessage)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.reconnected'),
|
||||
life: 2000
|
||||
})
|
||||
reconnectingToastShown.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { onReconnecting, onReconnected }
|
||||
}
|
||||
@@ -34,8 +34,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, useIntervalFn } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { ToastMessageOptions } from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
@@ -45,7 +44,6 @@ import {
|
||||
watch,
|
||||
watchEffect
|
||||
} from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { runWhenGlobalIdle } from '@/base/common/async'
|
||||
import MenuHamburger from '@/components/MenuHamburger.vue'
|
||||
@@ -58,6 +56,7 @@ import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
|
||||
import { useCoreCommands } from '@/composables/useCoreCommands'
|
||||
import { useQueuePolling } from '@/platform/remote/comfyui/useQueuePolling'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useReconnectingNotification } from '@/composables/useReconnectingNotification'
|
||||
import { useProgressFavicon } from '@/composables/useProgressFavicon'
|
||||
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
|
||||
import type { ServerConfig, ServerConfigValue } from '@/constants/serverConfig'
|
||||
@@ -103,8 +102,6 @@ setupAutoQueueHandler()
|
||||
useProgressFavicon()
|
||||
useBrowserTabTitle()
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const settingStore = useSettingStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
@@ -250,28 +247,7 @@ const onExecutionSuccess = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const reconnectingMessage: ToastMessageOptions = {
|
||||
severity: 'error',
|
||||
summary: t('g.reconnecting')
|
||||
}
|
||||
|
||||
const onReconnecting = () => {
|
||||
if (!settingStore.get('Comfy.Toast.DisableReconnectingToast')) {
|
||||
toast.remove(reconnectingMessage)
|
||||
toast.add(reconnectingMessage)
|
||||
}
|
||||
}
|
||||
|
||||
const onReconnected = () => {
|
||||
if (!settingStore.get('Comfy.Toast.DisableReconnectingToast')) {
|
||||
toast.remove(reconnectingMessage)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.reconnected'),
|
||||
life: 2000
|
||||
})
|
||||
}
|
||||
}
|
||||
const { onReconnecting, onReconnected } = useReconnectingNotification()
|
||||
|
||||
useEventListener(api, 'status', onStatus)
|
||||
useEventListener(api, 'execution_success', onExecutionSuccess)
|
||||
|
||||
Reference in New Issue
Block a user