Files
ComfyUI_frontend/src/scripts/app.test.ts
Alexander Brown e994e4df58 refactor: store-backed WidgetId subgraph host widgets; delete widgetValueIO layer (#12617)
## Summary

Make `WidgetId` (`graphId:nodeId:name`) the single canonical widget
identity and represent subgraph promoted host widgets as ordinary
store-backed widgets addressed by it. This deletes two whole indirection
layers — the `world/*` widget-entity-IO layer and the
`PromotedWidgetView` runtime — leaving one model: a widget's data lives
in `widgetValueStore` keyed by `WidgetId`, and a `SubgraphNode` input
references it via `input.widgetId`.

**Net +304 lines across 107 files** (5,044 added / 4,740 deleted):
production code is net **−798** (1,521 added / 2,319 deleted) while
tests are net **+1,102** (3,523 added / 2,421 deleted). 10 files deleted
outright, 14 added.

## What got deleted

The old design wrapped every promoted subgraph widget in a synthetic
`IBaseWidget` "view" object with live getters that followed the source
widget, plus a manager to keep view identities stable, plus an IO
indirection layer over the store. All of it is gone:

- `promotedWidgetView.ts` — the `PromotedWidgetView` class (draw /
pointer / DOM-sync / projection / deepest-source resolution getters)
- `PromotedWidgetViewManager.ts` — view reconciliation/caching
- `world/widgetValueIO.ts` — the IO wrapper over `widgetValueStore`
- `world/entityIds.ts` + `world/brand.ts` — the `WidgetEntityId`
branded-id layer and the `entityId` field
- `widgetNodeTypeGuard.ts` — only used by the deleted view
- the per-`SubgraphNode` view machinery (`_promotedViewManager`,
`_cacheVersion`, view-key generation, DOM position-override cleanup) and
every now-dead `isPromotedWidgetView` branch across the panel, menu,
store, and util consumers
- `domWidgetStore` position-override APIs (`setPositionOverride` /
`clearPositionOverride`), only used to render a promoted DOM widget on a
different host node

## Why it's simpler

- One source of truth. A promoted host widget is `WidgetState` in the
store, seeded from the source at promotion (`registerWidget` with a
deep-cloned snapshot) and independent thereafter. No synthetic widget
objects, no runtime source-following, no view cache to invalidate.
- Resolution is data-driven. `resolveConcretePromotedWidget` walks
`SubgraphNode` inputs (`input.widgetId` + `resolveSubgraphInputTarget`)
instead of chasing view objects through `node.widgets`. This also
**fixes two-layer nested promotion** — the previously-skipped parity
test now passes and resolves through to the deepest concrete widget.
- The right-panel Parameters tab renders a subgraph node's promoted
widgets through the **same** store-backed path as ordinary node widgets:
display reads `WidgetState` via `widget.widgetId`, and value writes go
through `widgetValueStore.setValue(widgetId)`.

## Changes

- **What**:
- `WidgetId` branded type + `widgetId()` / `parseWidgetId()` /
`isWidgetId()` and a `WidgetState` type; `widgetValueStore` is
`WidgetId`-native (`registerWidget` / `getWidget` / `setValue` /
`deleteWidget`).
- Promotion creates host `WidgetState` then an input projection
(`input.widgetId`); demotion clears it; serialization and legacy
`proxyWidgets` migration round-trip through `input.widgetId`.
- `promotedInputWidget.ts` projects a store-backed ordinary widget from
an input slot; `SubgraphNode.widgets` is now a projected getter over
inputs (kept Litegraph-shaped so the canvas renderer and extensions
still read `node.widgets`). `invalidatePromotedViews()` is retained as a
no-op for extension compatibility.
- `promotedWidgetControl.ts` applies `control_after_generate` (e.g. seed
increment) on the host node, since the interior control widget is
link-fed and its value is dead; `syncPromotedComboHostOptions` mirrors
interior combo options onto host state.
- `multilineTextarea.ts` extracts the reusable multiline DOM-widget
behavior out of `useStringWidget` and adds promoted multiline
materialization via a `createPromotedHostWidget` app-layer hook (keeping
Litegraph core free of Vue/Pinia/DOM).
- Late-bound `LiteGraph` singleton holder (`litegraphInstance.ts`) to
break a widget-init import cycle.
- **Breaking**:
  - `IBaseWidget.entityId` removed — use `widgetId`.
- `SubgraphNode.widgets` no longer exposes the old `PromotedWidgetView`
objects; promoted state lives in `widgetValueStore` keyed by
`input.widgetId` and `widgets` is a projection of inputs. The
`widget-promoted` event now carries the concrete interior widget.
Extension code reading `entityId` or relying on `PromotedWidgetView` is
affected.

## Review Focus

- Promotion / demotion / serialization round-tripping through
`input.widgetId` + `widgetValueStore`, incl. the legacy `proxyWidgets`
migration.
- Snapshot-at-promotion semantics (host widget does not follow the
source after creation), and the combo-options exception via
`syncPromotedComboHostOptions`.
- Two-layer nested resolution in `resolveConcretePromotedWidget` +
`SubgraphNode` nested-source resolution.
- The unified Parameters tab (`TabSubgraphInputs` → `SectionWidgets`):
value edit / rename / favorite / hide / reorder for promoted inputs are
wired through the store but warrant a visual/e2e pass.
- Litegraph-compat seams worth a careful read: projected
`SubgraphNode.widgets`, the canvas-edit `callback` bridge back to the
store, host-level `control_after_generate`, and the late-bound
`LiteGraph` holder / `domWidget.ts` import ordering.

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-06-16 23:19:12 +00:00

641 lines
20 KiB
TypeScript

import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import type {
ComfyApiWorkflow,
ComfyWorkflowJSON
} from '@/platform/workflow/validation/schemas/workflowSchema'
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { ComfyApp } from './app'
import { createNode } from '@/utils/litegraphUtil'
import {
pasteAudioNode,
pasteAudioNodes,
pasteImageNode,
pasteImageNodes,
pasteVideoNode,
pasteVideoNodes
} from '@/composables/usePaste'
import { getWorkflowDataFromFile } from '@/scripts/metadata/parser'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { api } from '@/scripts/api'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useExecutionStore } from '@/stores/executionStore'
import type { NodeError } from '@/schemas/apiSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import {
createTestRootGraph,
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
const {
mockApiKeyAuthStore,
mockAuthStore,
mockSettingStore,
mockToastStore,
mockExtensionService,
mockNodeOutputStore,
mockWorkspaceWorkflow,
mockRefreshMissingModelPipeline
} = vi.hoisted(() => ({
mockApiKeyAuthStore: {
getApiKey: vi.fn()
},
mockAuthStore: {
getAuthToken: vi.fn()
},
mockSettingStore: {
get: vi.fn()
},
mockToastStore: {
addAlert: vi.fn(),
add: vi.fn(),
remove: vi.fn()
},
mockExtensionService: {
invokeExtensions: vi.fn(),
invokeExtensionsAsync: vi.fn()
},
mockNodeOutputStore: {
refreshNodeOutputs: vi.fn()
},
mockWorkspaceWorkflow: {
activeWorkflow: null as ComfyWorkflow | null
},
mockRefreshMissingModelPipeline: vi.fn()
}))
vi.mock('@/utils/litegraphUtil', () => ({
createNode: vi.fn(),
isImageNode: vi.fn(),
isVideoNode: vi.fn(),
isAudioNode: vi.fn(),
executeWidgetsCallback: vi.fn(),
fixLinkInputSlots: vi.fn()
}))
vi.mock('@/stores/apiKeyAuthStore', () => ({
useApiKeyAuthStore: vi.fn(() => mockApiKeyAuthStore)
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => mockAuthStore)
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => mockSettingStore)
}))
vi.mock('@/composables/usePaste', () => ({
pasteAudioNode: vi.fn(),
pasteAudioNodes: vi.fn(),
pasteImageNode: vi.fn(),
pasteImageNodes: vi.fn(),
pasteVideoNode: vi.fn(),
pasteVideoNodes: vi.fn()
}))
vi.mock('@/scripts/metadata/parser', () => ({
getWorkflowDataFromFile: vi.fn()
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => mockToastStore)
}))
vi.mock('@/services/extensionService', () => ({
useExtensionService: vi.fn(() => mockExtensionService)
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: vi.fn(() => mockNodeOutputStore)
}))
vi.mock('@/stores/workspaceStore', () => ({
useWorkspaceStore: vi.fn(() => ({
workflow: mockWorkspaceWorkflow
}))
}))
vi.mock('@/platform/missingModel/missingModelPipeline', () => ({
refreshMissingModelPipeline: mockRefreshMissingModelPipeline,
runMissingModelPipeline: vi.fn()
}))
function createMockNode(options: { [K in keyof LGraphNode]?: any } = {}) {
return {
id: 1,
pos: [0, 0],
size: [200, 100],
type: 'LoadImage',
connect: vi.fn(),
getBounding: vi.fn(() => new Float64Array([0, 0, 200, 100])),
...options
} as LGraphNode
}
function createMockCanvas(): Partial<LGraphCanvas> {
const mockGraph: Partial<LGraph> = {
change: vi.fn()
}
return {
graph: mockGraph as LGraph,
draw: vi.fn(),
selectItems: vi.fn()
}
}
function createTestFile(name: string, type: string): File {
return new File([''], name, { type })
}
function createWorkflowGraphData(): ComfyWorkflowJSON {
return {
last_node_id: 0,
last_link_id: 0,
nodes: [],
links: [],
groups: [],
config: {},
extra: {},
version: 0.4
}
}
describe('ComfyApp', () => {
let app: ComfyApp
let mockCanvas: LGraphCanvas
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
app = new ComfyApp()
mockCanvas = createMockCanvas() as LGraphCanvas
app.canvas = mockCanvas as LGraphCanvas
mockWorkspaceWorkflow.activeWorkflow = null
mockApiKeyAuthStore.getApiKey.mockReturnValue(undefined)
mockAuthStore.getAuthToken.mockResolvedValue(undefined)
mockExtensionService.invokeExtensions.mockReturnValue([])
mockExtensionService.invokeExtensionsAsync.mockResolvedValue(undefined)
mockSettingStore.get.mockImplementation((key: string) =>
key === 'Comfy.RightSidePanel.ShowErrorsTab' ? true : undefined
)
})
describe('queuePrompt', () => {
it('shows the error overlay for successful prompt responses with node errors', async () => {
const graph = new LGraph()
const workflow = new ComfyWorkflow({
path: 'workflows/review.json',
modified: 0,
size: 0
})
const promptOutput: ComfyApiWorkflow = {
'1': {
class_type: 'PreviewAny',
inputs: {},
_meta: { title: 'PreviewAny' }
}
}
const nodeErrors: Record<string, NodeError> = {
'1': {
class_type: 'PreviewAny',
dependent_outputs: ['1'],
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing: source',
details: '',
extra_info: { input_name: 'source' }
}
]
}
}
Reflect.set(app, 'rootGraphInternal', graph)
mockWorkspaceWorkflow.activeWorkflow = workflow
vi.spyOn(app, 'graphToPrompt').mockResolvedValue({
output: promptOutput,
workflow: createWorkflowGraphData()
})
vi.spyOn(api, 'dispatchCustomEvent').mockImplementation(() => true)
vi.spyOn(api, 'queuePrompt').mockResolvedValue({
prompt_id: 'job-1',
node_errors: nodeErrors,
error: ''
})
await expect(app.queuePrompt(0)).resolves.toBe(false)
const errorStore = useExecutionErrorStore()
const executionStore = useExecutionStore()
expect(errorStore.lastNodeErrors).toEqual(nodeErrors)
expect(errorStore.isErrorOverlayOpen).toBe(true)
expect(executionStore.queuedJobs['job-1']?.nodes).toEqual({ '1': false })
expect(executionStore.jobIdToSessionWorkflowPath.get('job-1')).toBe(
'workflows/review.json'
)
expect(mockCanvas.draw).toHaveBeenCalledWith(true, true)
})
})
describe('refreshComboInNodes', () => {
it('shows success toast and removes the pending toast after node defs reload', async () => {
app.vueAppReady = true
vi.spyOn(app, 'reloadNodeDefs').mockResolvedValue()
await app.refreshComboInNodes()
expect(mockToastStore.add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'info' })
)
expect(mockToastStore.add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
expect(mockToastStore.remove).toHaveBeenCalledWith(
mockToastStore.add.mock.calls[0][0]
)
})
it('shows failure toast, removes the pending toast, and rethrows reload failures', async () => {
app.vueAppReady = true
const error = new Error('object_info failed')
vi.spyOn(app, 'reloadNodeDefs').mockRejectedValue(error)
await expect(app.refreshComboInNodes()).rejects.toThrow(error)
expect(mockToastStore.add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
expect(mockToastStore.remove).toHaveBeenCalledWith(
mockToastStore.add.mock.calls[0][0]
)
})
})
describe('reloadNodeDefs', () => {
it('syncs refreshed combo options into promoted combo host state', async () => {
const initialOptions = ['missing.safetensors']
const refreshedOptions = ['missing.safetensors', 'present.safetensors']
const rootGraph = createTestRootGraph()
const subgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'ckpt_name', type: '*' }]
})
const interiorNode = new LGraphNode(
'CheckpointLoaderSimple',
'CheckpointLoaderSimple'
)
const interiorInput = interiorNode.addInput('ckpt_name', '*')
interiorInput.widget = { name: 'ckpt_name' }
const interiorWidget = interiorNode.addWidget(
'combo',
'ckpt_name',
'missing.safetensors',
() => {},
{ values: initialOptions }
)
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const host = createTestSubgraphNode(subgraph)
rootGraph.add(host)
const hostWidgetId = host.inputs[0].widgetId
if (!hostWidgetId) throw new Error('Expected a promoted host widgetId')
const widgetValueStore = useWidgetValueStore()
expect(widgetValueStore.getWidget(hostWidgetId)?.options).toEqual({
values: initialOptions
})
const defs: Record<string, ComfyNodeDef> = {
CheckpointLoaderSimple: {
name: 'CheckpointLoaderSimple',
display_name: 'CheckpointLoaderSimple',
category: 'loaders',
python_module: 'nodes',
description: '',
input: {
required: {
ckpt_name: [refreshedOptions, {}]
},
optional: {}
},
output: [],
output_name: [],
output_tooltips: [],
output_node: false,
deprecated: false,
experimental: false
}
}
Reflect.set(app, 'rootGraphInternal', rootGraph)
vi.spyOn(app, 'getNodeDefs').mockResolvedValue(defs)
vi.spyOn(app, 'registerNodeDef').mockResolvedValue(undefined)
await app.reloadNodeDefs()
expect(interiorWidget.options.values).toEqual(refreshedOptions)
expect(widgetValueStore.getWidget(hostWidgetId)?.options.values).toEqual(
refreshedOptions
)
expect(mockExtensionService.invokeExtensionsAsync).toHaveBeenCalledWith(
'refreshComboInNodes',
defs
)
})
})
describe('refreshMissingModels', () => {
it('delegates to the app-independent missing model refresh pipeline', async () => {
const graph = {
nodes: [],
serialize: vi.fn(() => createWorkflowGraphData())
}
const result = {
missingModels: [],
confirmedCandidates: []
}
Reflect.set(app, 'rootGraphInternal', graph)
vi.spyOn(app, 'reloadNodeDefs').mockResolvedValue()
mockRefreshMissingModelPipeline.mockResolvedValue(result)
await expect(app.refreshMissingModels({ silent: false })).resolves.toBe(
result
)
expect(mockRefreshMissingModelPipeline).toHaveBeenCalledWith({
graph,
reloadNodeDefs: expect.any(Function),
missingModelStore: useMissingModelStore(),
silent: false
})
await mockRefreshMissingModelPipeline.mock.calls[0][0].reloadNodeDefs()
expect(app.reloadNodeDefs).toHaveBeenCalled()
})
})
describe('handleFileList', () => {
it('should create image nodes for each file in the list', async () => {
const mockNode1 = createMockNode({ id: 1 })
const mockNode2 = createMockNode({ id: 2 })
const mockBatchNode = createMockNode({ id: 3, type: 'BatchImagesNode' })
vi.mocked(pasteImageNodes).mockResolvedValue([mockNode1, mockNode2])
vi.mocked(createNode).mockResolvedValue(mockBatchNode)
const file1 = createTestFile('test1.png', 'image/png')
const file2 = createTestFile('test2.jpg', 'image/jpeg')
const files = [file1, file2]
await app.handleFileList(files)
expect(pasteImageNodes).toHaveBeenCalledWith(mockCanvas, files)
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'BatchImagesNode')
expect(mockCanvas.selectItems).toHaveBeenCalledWith([
mockNode1,
mockNode2,
mockBatchNode
])
expect(mockNode1.connect).toHaveBeenCalledWith(0, mockBatchNode, 0)
expect(mockNode2.connect).toHaveBeenCalledWith(0, mockBatchNode, 1)
})
it('should select single image node without batch node', async () => {
const mockNode1 = createMockNode({ id: 1 })
vi.mocked(pasteImageNodes).mockResolvedValue([mockNode1])
const file = createTestFile('test.png', 'image/png')
await app.handleFileList([file])
expect(createNode).not.toHaveBeenCalled()
expect(mockCanvas.selectItems).toHaveBeenCalledWith([mockNode1])
expect(mockNode1.connect).not.toHaveBeenCalled()
})
it('should handle empty file list', async () => {
await app.handleFileList([])
expect(pasteImageNodes).not.toHaveBeenCalled()
expect(createNode).not.toHaveBeenCalled()
})
it('should not process unsupported file types', async () => {
const invalidFile = createTestFile('test.pdf', 'application/pdf')
await app.handleFileList([invalidFile])
expect(pasteImageNodes).not.toHaveBeenCalled()
expect(createNode).not.toHaveBeenCalled()
})
})
describe('handleAudioFileList', () => {
it('should create audio nodes and select them', async () => {
const mockNode1 = createMockNode({ id: 1, type: 'LoadAudio' })
const mockNode2 = createMockNode({ id: 2, type: 'LoadAudio' })
vi.mocked(pasteAudioNodes).mockResolvedValue([mockNode1, mockNode2])
const file1 = createTestFile('test1.mp3', 'audio/mpeg')
const file2 = createTestFile('test2.wav', 'audio/wav')
await app.handleAudioFileList([file1, file2])
expect(pasteAudioNodes).toHaveBeenCalledWith(mockCanvas, [file1, file2])
expect(mockCanvas.selectItems).toHaveBeenCalledWith([
mockNode1,
mockNode2
])
})
it('should not select when no nodes created', async () => {
vi.mocked(pasteAudioNodes).mockResolvedValue([])
await app.handleAudioFileList([createTestFile('test.mp3', 'audio/mpeg')])
expect(mockCanvas.selectItems).not.toHaveBeenCalled()
})
})
describe('handleVideoFileList', () => {
it('should create video nodes and select them', async () => {
const mockNode1 = createMockNode({ id: 1, type: 'LoadVideo' })
const mockNode2 = createMockNode({ id: 2, type: 'LoadVideo' })
vi.mocked(pasteVideoNodes).mockResolvedValue([mockNode1, mockNode2])
const file1 = createTestFile('test1.mp4', 'video/mp4')
const file2 = createTestFile('test2.webm', 'video/webm')
await app.handleVideoFileList([file1, file2])
expect(pasteVideoNodes).toHaveBeenCalledWith(mockCanvas, [file1, file2])
expect(mockCanvas.selectItems).toHaveBeenCalledWith([
mockNode1,
mockNode2
])
})
it('should not select when no nodes created', async () => {
vi.mocked(pasteVideoNodes).mockResolvedValue([])
await app.handleVideoFileList([createTestFile('test.mp4', 'video/mp4')])
expect(mockCanvas.selectItems).not.toHaveBeenCalled()
})
})
describe('positionBatchNodes', () => {
it('should position batch node to the right of first node', () => {
const mockNode1 = createMockNode({
pos: [100, 200],
getBounding: vi.fn(() => new Float64Array([100, 200, 300, 400]))
})
const mockBatchNode = createMockNode({ pos: [0, 0] })
app.positionBatchNodes([mockNode1], mockBatchNode)
expect(mockBatchNode.pos).toEqual([500, 230])
})
it('should stack multiple image nodes vertically', () => {
const mockNode1 = createMockNode({
pos: [100, 200],
type: 'LoadImage',
getBounding: vi.fn(() => new Float64Array([100, 200, 300, 400]))
})
const mockNode2 = createMockNode({ pos: [0, 0], type: 'LoadImage' })
const mockNode3 = createMockNode({ pos: [0, 0], type: 'LoadImage' })
const mockBatchNode = createMockNode({ pos: [0, 0] })
app.positionBatchNodes([mockNode1, mockNode2, mockNode3], mockBatchNode)
expect(mockNode1.pos).toEqual([100, 200])
expect(mockNode2.pos).toEqual([100, 594])
expect(mockNode3.pos).toEqual([100, 963])
})
it('should call graph change once for all nodes', () => {
const mockNode1 = createMockNode({
getBounding: vi.fn(() => new Float64Array([100, 200, 300, 400]))
})
const mockBatchNode = createMockNode()
app.positionBatchNodes([mockNode1], mockBatchNode)
expect(mockCanvas.graph?.change).toHaveBeenCalledTimes(1)
})
})
describe('handleFile', () => {
it('should handle image files by creating LoadImage node', async () => {
vi.mocked(getWorkflowDataFromFile).mockResolvedValue({})
const mockNode = createMockNode()
vi.mocked(createNode).mockResolvedValue(mockNode)
const imageFile = createTestFile('test.png', 'image/png')
await app.handleFile(imageFile)
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadImage')
expect(pasteImageNode).toHaveBeenCalledWith(
mockCanvas,
expect.any(DataTransferItemList),
mockNode
)
})
it('should handle audio files by creating LoadAudio node', async () => {
vi.mocked(getWorkflowDataFromFile).mockResolvedValue({})
const mockNode = createMockNode({ type: 'LoadAudio' })
vi.mocked(createNode).mockResolvedValue(mockNode)
const audioFile = createTestFile('test.mp3', 'audio/mpeg')
await app.handleFile(audioFile)
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadAudio')
expect(pasteAudioNode).toHaveBeenCalledWith(
mockCanvas,
expect.any(DataTransferItemList),
mockNode
)
})
it('should handle video files by creating LoadVideo node', async () => {
vi.mocked(getWorkflowDataFromFile).mockResolvedValue({})
const mockNode = createMockNode({ type: 'LoadVideo' })
vi.mocked(createNode).mockResolvedValue(mockNode)
const videoFile = createTestFile('test.mp4', 'video/mp4')
await app.handleFile(videoFile)
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadVideo')
expect(pasteVideoNode).toHaveBeenCalledWith(
mockCanvas,
expect.any(DataTransferItemList),
mockNode
)
})
it('should handle image files with non-workflow metadata by creating LoadImage node', async () => {
vi.mocked(getWorkflowDataFromFile).mockResolvedValue({
Software: 'gnome-screenshot'
})
const mockNode = createMockNode()
vi.mocked(createNode).mockResolvedValue(mockNode)
const imageFile = createTestFile('screenshot.png', 'image/png')
await app.handleFile(imageFile)
expect(createNode).toHaveBeenCalledWith(mockCanvas, 'LoadImage')
expect(pasteImageNode).toHaveBeenCalledWith(
mockCanvas,
expect.any(DataTransferItemList),
mockNode
)
})
})
describe('drop handler', () => {
it('syncs graph_mouse from the drop event before downstream handlers run', async () => {
// graph_mouse is only updated on mousemove, so when files are dragged in
// from another window the canvas-space cursor is stale. The drop handler
// must derive the position from the drop event itself.
const graphMouse: [number, number] = [-999, -999]
const adjustMouseEvent = vi.fn((e: DragEvent) => {
;(e as DragEvent & { canvasX: number; canvasY: number }).canvasX = 123
;(e as DragEvent & { canvasX: number; canvasY: number }).canvasY = 456
})
app.canvas = {
...mockCanvas,
graph_mouse: graphMouse,
adjustMouseEvent
} as unknown as LGraphCanvas
;(app as unknown as { addDropHandler(): void }).addDropHandler()
document.dispatchEvent(new DragEvent('drop'))
await Promise.resolve()
expect(adjustMouseEvent).toHaveBeenCalledTimes(1)
expect(graphMouse).toEqual([123, 456])
})
})
})