Compare commits

...

8 Commits

Author SHA1 Message Date
Connor Byrne
f7f237658c test: cover getCanvasCenter when app.canvas is undefined
Promotes the previously hardcoded app mock to a hoisted, mutable mockApp
object so tests can simulate the runtime case where app.canvas itself is
still uninitialized. Adds a test asserting getCanvasCenter() returns
[0, 0] without throwing in that state, exercising the optional chain on
app.canvas?.ds?.visible_area.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11397#discussion_r3176135138
2026-05-04 14:06:43 -07:00
Connor Byrne
f88492387b test: type litegraph fixtures and remove call-site as-never casts
Localizes type assertions inside the mock factories so callers receive
properly-typed LGraphNode and IBaseWidget values, eliminating the
'node as never' / 'widget as never' casts at every test call site.
Replaces .call(null as never) on context-menu callbacks with a typed
helper, and drops as-never on goToNode IDs and updatePreviews node
fixtures.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11397#discussion_r3176135135
2026-05-04 14:05:16 -07:00
Connor Byrne
c09f1d7d15 test: remove change-detector test for Symbol exports
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11397#discussion_r3176135137
2026-05-04 14:01:07 -07:00
Christian Byrne
4a40c050d9 Merge branch 'main' into test/cov-litegraphService 2026-05-04 13:27:33 -07:00
bymyself
35492bc530 fix: assert error is logged in updatePreviews error test
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11397#discussion_r3114595641
2026-04-21 18:47:47 -07:00
bymyself
c30177a749 fix: reset mock implementations in beforeEach to prevent leakage
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11397#discussion_r3114595634
2026-04-21 18:47:47 -07:00
bymyself
cb443d455e fix: use shared nodeOutputStore mock instance via vi.hoisted
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11397#discussion_r3114595624
2026-04-21 18:47:47 -07:00
bymyself
a378ebb5af test: add unit tests for litegraphService 2026-04-20 18:45:03 -07:00

View File

@@ -1,43 +1,597 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/scripts/app', () => ({
app: { canvas: undefined },
ComfyApp: class {}
import type {
IContextMenuValue,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import type { ContextMenuDivElement } from '@/lib/litegraph/src/interfaces'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { getExtraOptionsForWidget } from '@/services/litegraphService'
async function invokeMenuCallback(option: IContextMenuValue): Promise<void> {
// Production callbacks under test do not reference `this`; ContextMenuDivElement
// is a DOM element decorated with extra fields, not realistic to construct in tests.
await option.callback?.call({} as ContextMenuDivElement)
}
const mockPrompt = vi.fn()
const mockCanvas = vi.hoisted(() => ({
setDirty: vi.fn(),
graph_mouse: [100, 200],
ds: {
scale: 1,
offset: [0, 0] as [number, number],
visible_area: [0, 0, 800, 600] as
| [number, number, number, number]
| undefined,
fitToBounds: vi.fn()
},
graph: {
nodes: [] as unknown[],
getNodeById: vi.fn(),
add: vi.fn(),
setDirtyCanvas: vi.fn(),
isRootGraph: true
},
animateToBounds: vi.fn(),
_deserializeItems: vi.fn()
}))
import { app } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
const mockApp = vi.hoisted(() => ({
canvas: undefined as unknown,
graph: undefined as unknown,
dragOverNode: null,
lastExecutionError: null,
rootGraph: {}
}))
describe('useLitegraphService().getCanvasCenter', () => {
const mockFavoritedWidgetsStore = vi.hoisted(() => ({
isFavorited: vi.fn().mockReturnValue(false),
toggleFavorite: vi.fn()
}))
vi.mock('@/stores/workspace/favoritedWidgetsStore', () => ({
useFavoritedWidgetsStore: () => mockFavoritedWidgetsStore
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
prompt: mockPrompt
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: mockCanvas,
getCanvas: () => mockCanvas
})
}))
vi.mock('@/core/graph/subgraph/promotionUtils', () => ({
addWidgetPromotionOptions: vi.fn(),
isPreviewPseudoWidget: vi.fn()
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key,
st: (_key: string, fallback: string) => fallback
}))
vi.mock('@/utils/formatUtil', () => ({
normalizeI18nKey: (key: string) => key
}))
vi.mock('@/scripts/app', () => ({
app: mockApp,
ComfyApp: {
clipspace: null,
clipspace_return_node: null,
copyToClipspace: vi.fn(),
pasteFromClipspace: vi.fn()
}
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ addAlert: vi.fn() })
}))
vi.mock('@/stores/widgetStore', () => ({
useWidgetStore: () => ({ widgets: new Map() })
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => ({
nodeLocationProgressStates: {}
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeSubgraph: null,
nodeIdToNodeLocatorId: (id: string) => id
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn().mockReturnValue(false)
})
}))
vi.mock('@/composables/canvas/useSelectedLiteGraphItems', () => ({
useSelectedLiteGraphItems: () => ({
toggleSelectedNodesMode: vi.fn()
})
}))
vi.mock('@/services/extensionService', () => ({
useExtensionService: () => ({
invokeExtensionsAsync: vi.fn()
})
}))
vi.mock('@/stores/subgraphStore', () => ({
useSubgraphStore: () => ({
typePrefix: 'Subgraph::',
getBlueprint: vi.fn()
})
}))
const mockNodeOutputStore = vi.hoisted(() => ({
getNodeOutputs: vi.fn(),
getNodePreviews: vi.fn()
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => mockNodeOutputStore
}))
vi.mock('@/composables/node/useNodeAnimatedImage', () => ({
useNodeAnimatedImage: () => ({
showAnimatedPreview: vi.fn(),
removeAnimatedPreview: vi.fn()
})
}))
vi.mock('@/composables/node/useNodeCanvasImagePreview', () => ({
useNodeCanvasImagePreview: () => ({
showCanvasImagePreview: vi.fn(),
removeCanvasImagePreview: vi.fn()
})
}))
vi.mock('@/composables/node/useNodeImage', () => ({
useNodeImage: () => ({ showPreview: vi.fn() }),
useNodeVideo: () => ({ showPreview: vi.fn() })
}))
vi.mock('@/composables/graph/useSubgraphOperations', () => ({
useSubgraphOperations: () => ({ unpackSubgraph: vi.fn() })
}))
vi.mock('@/composables/maskeditor/useMaskEditor', () => ({
useMaskEditor: () => ({ openMaskEditor: vi.fn() })
}))
vi.mock('@/stores/domWidgetStore', () => ({
useDomWidgetStore: () => ({
widgetStates: new Map(),
registerWidget: vi.fn(),
unregisterWidget: vi.fn()
})
}))
vi.mock('@/stores/promotionStore', () => ({
usePromotionStore: () => ({
getPromotionsRef: vi.fn().mockReturnValue([])
})
}))
vi.mock('@/services/subgraphPseudoWidgetCache', () => ({
resolveSubgraphPseudoWidgetCache: vi.fn().mockReturnValue({
cache: { promotions: [], entries: [], nodes: [] },
nodes: []
})
}))
vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
useRightSidePanelStore: () => ({ openPanel: vi.fn() })
}))
vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: vi.fn(),
openFileInNewTab: vi.fn()
}))
vi.mock('@/scripts/domWidget', () => ({
isComponentWidget: vi.fn().mockReturnValue(false),
isDOMWidget: vi.fn().mockReturnValue(false)
}))
const mockCreateBounds = vi.hoisted(() => vi.fn())
vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as object),
createBounds: mockCreateBounds
}
})
vi.mock('@/scripts/ui', () => ({
$el: vi.fn()
}))
vi.mock('@/utils/litegraphUtil', () => ({
isAnimatedOutput: vi.fn().mockReturnValue(false),
isImageNode: vi.fn().mockReturnValue(false),
isVideoNode: vi.fn().mockReturnValue(false),
isVideoOutput: vi.fn().mockReturnValue(false),
migrateWidgetsValues: vi.fn().mockReturnValue([])
}))
vi.mock('@/core/graph/widgets/dynamicWidgets', () => ({
applyDynamicInputs: vi.fn().mockReturnValue(false)
}))
vi.mock('@/schemas/nodeDef/migration', () => ({
transformInputSpecV2ToV1: vi.fn().mockReturnValue([])
}))
vi.mock('@/workbench/utils/nodeDefOrderingUtil', () => ({
getOrderedInputSpecs: vi.fn().mockReturnValue([])
}))
vi.mock('@/stores/nodeDefStore', () => ({
ComfyNodeDefImpl: vi.fn().mockImplementation((def: unknown) => def)
}))
function createMockNode(overrides: Record<string, unknown> = {}): LGraphNode {
return {
id: 1,
inputs: [],
graph: null,
constructor: { nodeData: { name: 'TestNode' } },
getWidgetOnPos: vi.fn(),
...overrides
} as unknown as LGraphNode
}
function createMockWidget(
overrides: Record<string, unknown> = {}
): IBaseWidget {
return {
name: 'test_widget',
label: undefined,
value: 42,
callback: vi.fn(),
options: {},
...overrides
} as unknown as IBaseWidget
}
describe('litegraphService', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
mockFavoritedWidgetsStore.isFavorited.mockReturnValue(false)
mockPrompt.mockReset()
mockCreateBounds.mockReset()
mockCanvas.graph.getNodeById.mockReset()
mockCanvas.ds.scale = 1
mockCanvas.ds.offset = [0, 0]
mockCanvas.ds.visible_area = [0, 0, 800, 600]
mockCanvas.graph.nodes = []
mockApp.canvas = mockCanvas
mockApp.graph = mockCanvas.graph
})
it('returns origin when canvas is not yet initialised', () => {
Reflect.set(app, 'canvas', undefined)
describe('getExtraOptionsForWidget', () => {
it('adds favorite option when widget is not favorited', () => {
const node = createMockNode()
const widget = createMockWidget()
mockFavoritedWidgetsStore.isFavorited.mockReturnValue(false)
const center = useLitegraphService().getCanvasCenter()
const options = getExtraOptionsForWidget(node, widget)
expect(center).toEqual([0, 0])
})
it('returns origin when canvas exists but ds.visible_area is missing', () => {
Reflect.set(app, 'canvas', { ds: {} })
const center = useLitegraphService().getCanvasCenter()
expect(center).toEqual([0, 0])
})
it('returns the visible-area centre once the canvas is ready', () => {
Reflect.set(app, 'canvas', {
ds: { visible_area: [10, 20, 200, 100] }
expect(options).toHaveLength(1)
expect(options[0].content).toContain('contextMenu.FavoriteWidget')
expect(options[0].content).toContain('test_widget')
})
const center = useLitegraphService().getCanvasCenter()
it('adds unfavorite option when widget is already favorited', () => {
const node = createMockNode()
const widget = createMockWidget()
mockFavoritedWidgetsStore.isFavorited.mockReturnValue(true)
expect(center).toEqual([110, 70])
const options = getExtraOptionsForWidget(node, widget)
expect(options[0].content).toContain('contextMenu.UnfavoriteWidget')
})
it('uses widget label when available', () => {
const node = createMockNode()
const widget = createMockWidget({ label: 'My Label' })
mockFavoritedWidgetsStore.isFavorited.mockReturnValue(false)
const options = getExtraOptionsForWidget(node, widget)
expect(options[0].content).toContain('My Label')
})
it('calls toggleFavorite when favorite option callback is invoked', () => {
const node = createMockNode()
const widget = createMockWidget()
const options = getExtraOptionsForWidget(node, widget)
void invokeMenuCallback(options[0])
expect(mockFavoritedWidgetsStore.toggleFavorite).toHaveBeenCalledWith(
node,
'test_widget'
)
})
it('adds rename option when input matches widget', () => {
const widget = createMockWidget({ name: 'seed' })
const node = createMockNode({
inputs: [{ widget: { name: 'seed' } }]
})
const options = getExtraOptionsForWidget(node, widget)
// rename is unshifted first, then favorite is unshifted (ends up first)
expect(options).toHaveLength(2)
const renameOption = options.find((o: IContextMenuValue) =>
o.content?.includes('contextMenu.RenameWidget')
)
expect(renameOption).toBeDefined()
expect(renameOption!.content).toContain('seed')
})
it('rename callback updates widget and input labels', async () => {
const widget = createMockWidget({ name: 'seed' })
const input = { widget: { name: 'seed' }, label: undefined as unknown }
const node = createMockNode({ inputs: [input] })
mockPrompt.mockResolvedValue('New Name')
const options = getExtraOptionsForWidget(node, widget)
const renameOption = options.find((o: IContextMenuValue) =>
o.content?.includes('contextMenu.RenameWidget')
)
await invokeMenuCallback(renameOption!)
expect(widget.label).toBe('New Name')
expect(input.label).toBe('New Name')
expect(widget.callback).toHaveBeenCalledWith(42)
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true)
})
it('rename callback clears label when empty string is returned', async () => {
const widget = createMockWidget({ name: 'seed', label: 'Old' })
const input = {
widget: { name: 'seed' },
label: 'Old' as string | undefined
}
const node = createMockNode({ inputs: [input] })
mockPrompt.mockResolvedValue('')
const options = getExtraOptionsForWidget(node, widget)
const renameOption = options.find((o: IContextMenuValue) =>
o.content?.includes('contextMenu.RenameWidget')
)
await invokeMenuCallback(renameOption!)
expect(widget.label).toBeUndefined()
expect(input.label).toBeUndefined()
})
it('rename callback does nothing when prompt is cancelled', async () => {
const widget = createMockWidget({ name: 'seed', label: 'Original' })
const input = { widget: { name: 'seed' }, label: 'Original' }
const node = createMockNode({ inputs: [input] })
mockPrompt.mockResolvedValue(null)
const options = getExtraOptionsForWidget(node, widget)
const renameOption = options.find((o: IContextMenuValue) =>
o.content?.includes('contextMenu.RenameWidget')
)
await invokeMenuCallback(renameOption!)
expect(widget.label).toBe('Original')
expect(input.label).toBe('Original')
})
it('adds promotion options when node is in a subgraph', async () => {
const { addWidgetPromotionOptions } = vi.mocked(
await import('@/core/graph/subgraph/promotionUtils')
)
const node = createMockNode({
graph: { isRootGraph: false }
})
const widget = createMockWidget()
getExtraOptionsForWidget(node, widget)
expect(addWidgetPromotionOptions).toHaveBeenCalled()
})
it('does not add promotion options on root graph', async () => {
const { addWidgetPromotionOptions } = vi.mocked(
await import('@/core/graph/subgraph/promotionUtils')
)
const node = createMockNode({ graph: null })
const widget = createMockWidget()
getExtraOptionsForWidget(node, widget)
expect(addWidgetPromotionOptions).not.toHaveBeenCalled()
})
})
describe('useLitegraphService', () => {
// Lazily import to ensure mocks are in place
async function getService() {
const { useLitegraphService } =
await import('@/services/litegraphService')
return useLitegraphService()
}
describe('getCanvasCenter', () => {
it('returns center of visible area', async () => {
const service = await getService()
// visible_area = [0, 0, 800, 600], dpi = 1
const center = service.getCanvasCenter()
expect(center).toEqual([400, 300])
})
it('accounts for visible area offset', async () => {
const saved = mockCanvas.ds.visible_area
mockCanvas.ds.visible_area = [10, 20, 200, 100]
const service = await getService()
const center = service.getCanvasCenter()
expect(center).toEqual([110, 70])
mockCanvas.ds.visible_area = saved
})
it('returns [0, 0] when no visible area', async () => {
const savedVisibleArea = mockCanvas.ds.visible_area
mockCanvas.ds.visible_area = undefined
const service = await getService()
const center = service.getCanvasCenter()
expect(center).toEqual([0, 0])
mockCanvas.ds.visible_area = savedVisibleArea
})
it('returns [0, 0] without throwing when app.canvas is undefined', async () => {
mockApp.canvas = undefined
const service = await getService()
expect(() => service.getCanvasCenter()).not.toThrow()
expect(service.getCanvasCenter()).toEqual([0, 0])
})
})
describe('resetView', () => {
it('resets canvas scale and offset', async () => {
mockCanvas.ds.scale = 2.5
mockCanvas.ds.offset = [100, 200]
const service = await getService()
service.resetView()
expect(mockCanvas.ds.scale).toBe(1)
expect(mockCanvas.ds.offset).toEqual([0, 0])
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
})
describe('goToNode', () => {
it('animates to node bounds when node exists', async () => {
const bounds = [10, 20, 100, 50]
const graphNode = { boundingRect: bounds }
mockCanvas.graph.getNodeById.mockReturnValue(graphNode)
const service = await getService()
service.goToNode(42)
expect(mockCanvas.animateToBounds).toHaveBeenCalledWith(bounds)
})
it('does nothing when node does not exist', async () => {
mockCanvas.graph.getNodeById.mockReturnValue(null)
const service = await getService()
service.goToNode(999)
expect(mockCanvas.animateToBounds).not.toHaveBeenCalled()
})
})
describe('fitView', () => {
it('calls fitToBounds and setDirty', async () => {
const mockBounds = [0, 0, 500, 400]
mockCreateBounds.mockReturnValue(mockBounds)
const nodeObj = {
boundingRect: [0, 0, 100, 50],
updateArea: vi.fn()
}
mockCanvas.graph.nodes = [nodeObj]
const service = await getService()
service.fitView()
expect(mockCanvas.ds.fitToBounds).toHaveBeenCalledWith(mockBounds)
expect(mockCanvas.setDirty).toHaveBeenCalledWith(true, true)
})
it('calls updateArea for nodes with zero bounds', async () => {
mockCreateBounds.mockReturnValue([0, 0, 100, 100])
const nodeObj = {
boundingRect: [0, 0, 0, 0],
updateArea: vi.fn()
}
mockCanvas.graph.nodes = [nodeObj]
const service = await getService()
service.fitView()
expect(nodeObj.updateArea).toHaveBeenCalled()
})
it('does nothing when createBounds returns null', async () => {
mockCreateBounds.mockReturnValue(null)
mockCanvas.graph.nodes = []
const service = await getService()
service.fitView()
expect(mockCanvas.ds.fitToBounds).not.toHaveBeenCalled()
})
})
describe('updatePreviews', () => {
it('catches errors and logs them', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
mockNodeOutputStore.getNodeOutputs.mockImplementation(() => {
throw new Error('test error')
})
const service = await getService()
const badNode = createMockNode({ flags: { collapsed: false } })
expect(() => service.updatePreviews(badNode)).not.toThrow()
expect(consoleSpy).toHaveBeenCalledWith(
'Error drawing node background',
expect.any(Error)
)
consoleSpy.mockRestore()
})
it('skips collapsed nodes', async () => {
const service = await getService()
const node = createMockNode({
flags: { collapsed: true },
imgs: undefined,
images: undefined,
preview: undefined
})
service.updatePreviews(node)
expect(mockNodeOutputStore.getNodeOutputs).not.toHaveBeenCalled()
})
})
})
})