diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 95c33709026..5463b2ad46f 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -15,7 +15,7 @@ import '@/assets/css/style.css' const ComfyUIPreset = definePreset(Aura, { semantic: { - // @ts-expect-error fix me + // @ts-expect-error PrimeVue type issue primary: Aura['primitive'].blue } }) diff --git a/AGENTS.md b/AGENTS.md index 743572be3c3..a60cb37892b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -200,6 +200,7 @@ See @docs/testing/*.md for detailed patterns. ## External Resources - Vue: +- @docs/typescript/type-safety.md - Tailwind: - VueUse: - shadcn/vue: diff --git a/apps/desktop-ui/.storybook/preview.ts b/apps/desktop-ui/.storybook/preview.ts index a0ead30cc11..0f7e8de18fa 100644 --- a/apps/desktop-ui/.storybook/preview.ts +++ b/apps/desktop-ui/.storybook/preview.ts @@ -14,7 +14,7 @@ import { i18n } from '@/i18n' const ComfyUIPreset = definePreset(Aura, { semantic: { - // @ts-expect-error prime type quirk + // @ts-expect-error PrimeVue type issue primary: Aura['primitive'].blue } }) diff --git a/apps/desktop-ui/src/main.ts b/apps/desktop-ui/src/main.ts index a04ff8f8109..bcee9405670 100644 --- a/apps/desktop-ui/src/main.ts +++ b/apps/desktop-ui/src/main.ts @@ -15,7 +15,7 @@ import router from './router' const ComfyUIPreset = definePreset(Aura, { semantic: { - // @ts-expect-error fixme ts strict error + // @ts-expect-error PrimeVue type issue primary: Aura['primitive'].blue } }) diff --git a/browser_tests/fixtures/ComfyPage.ts b/browser_tests/fixtures/ComfyPage.ts index 08cdd5ccea3..aa812cd7bf0 100644 --- a/browser_tests/fixtures/ComfyPage.ts +++ b/browser_tests/fixtures/ComfyPage.ts @@ -81,11 +81,14 @@ class ComfyMenu { await this.themeToggleButton.click() await this.page.evaluate(() => { return new Promise((resolve) => { - window['app'].ui.settings.addEventListener( - 'Comfy.ColorPalette.change', - resolve, - { once: true } - ) + const app = window.app + if (!app) { + resolve(undefined) + return + } + app.ui.settings.addEventListener('Comfy.ColorPalette.change', resolve, { + once: true + }) setTimeout(resolve, 5000) }) @@ -94,9 +97,9 @@ class ComfyMenu { async getThemeId() { return await this.page.evaluate(async () => { - return await window['app'].ui.settings.getSettingValue( - 'Comfy.ColorPalette' - ) + const app = window.app + if (!app) return undefined + return await app.ui.settings.getSettingValue('Comfy.ColorPalette') }) } } @@ -138,7 +141,14 @@ class ConfirmDialog { // Wait for workflow service to finish if it's busy await this.page.waitForFunction( - () => window['app']?.extensionManager?.workflow?.isBusy === false, + () => { + const app = window.app + if (!app) return true + const extMgr = app.extensionManager as { + workflow?: { isBusy?: boolean } + } + return extMgr.workflow?.isBusy === false + }, undefined, { timeout: 3000 } ) @@ -256,7 +266,12 @@ export class ComfyPage { } await this.page.evaluate(async () => { - await window['app'].extensionManager.workflow.syncWorkflows() + const app = window.app + if (!app) return + const extMgr = app.extensionManager as { + workflow?: { syncWorkflows: () => Promise } + } + if (extMgr.workflow) await extMgr.workflow.syncWorkflows() }) // Wait for Vue to re-render the workflow list @@ -360,7 +375,9 @@ export class ComfyPage { async executeCommand(commandId: string) { await this.page.evaluate((id: string) => { - return window['app'].extensionManager.command.execute(id) + const app = window.app + if (!app) return + return app.extensionManager.command.execute(id) }, commandId) } @@ -370,7 +387,8 @@ export class ComfyPage { ) { await this.page.evaluate( ({ commandId, commandStr }) => { - const app = window['app'] + const app = window.app + if (!app) return const randomSuffix = Math.random().toString(36).substring(2, 8) const extensionName = `TestExtension_${randomSuffix}` @@ -391,7 +409,8 @@ export class ComfyPage { async registerKeybinding(keyCombo: KeyCombo, command: () => void) { await this.page.evaluate( ({ keyCombo, commandStr }) => { - const app = window['app'] + const app = window.app + if (!app) return const randomSuffix = Math.random().toString(36).substring(2, 8) const extensionName = `TestExtension_${randomSuffix}` const commandId = `TestCommand_${randomSuffix}` @@ -419,7 +438,9 @@ export class ComfyPage { async setSetting(settingId: string, settingValue: any) { return await this.page.evaluate( async ({ id, value }) => { - await window['app'].extensionManager.setting.set(id, value) + const app = window.app + if (!app) return + await app.extensionManager.setting.set(id, value) }, { id: settingId, value: settingValue } ) @@ -427,7 +448,9 @@ export class ComfyPage { async getSetting(settingId: string) { return await this.page.evaluate(async (id) => { - return await window['app'].extensionManager.setting.get(id) + const app = window.app + if (!app) return undefined + return await app.extensionManager.setting.get(id) }, settingId) } @@ -873,8 +896,10 @@ export class ComfyPage { const foundSlot = await this.page.evaluate( async (params) => { const { slotType, action, targetSlotName } = params - const app = window['app'] + const app = window.app + if (!app) throw new Error('App not initialized') const currentGraph = app.canvas.graph + if (!currentGraph) throw new Error('No graph available') // Check if we're in a subgraph if (currentGraph.constructor.name !== 'Subgraph') { @@ -883,13 +908,24 @@ export class ComfyPage { ) } - // Get the appropriate node and slots + // Get the appropriate node and slots (these are Subgraph-specific properties) + const subgraph = currentGraph as { + inputNode?: { onPointerDown?: (...args: unknown[]) => void } + outputNode?: { onPointerDown?: (...args: unknown[]) => void } + inputs?: Array<{ + name: string + pos?: number[] + boundingRect?: number[] + }> + outputs?: Array<{ + name: string + pos?: number[] + boundingRect?: number[] + }> + } const node = - slotType === 'input' - ? currentGraph.inputNode - : currentGraph.outputNode - const slots = - slotType === 'input' ? currentGraph.inputs : currentGraph.outputs + slotType === 'input' ? subgraph.inputNode : subgraph.outputNode + const slots = slotType === 'input' ? subgraph.inputs : subgraph.outputs if (!node) { throw new Error(`No ${slotType} node found in subgraph`) @@ -970,6 +1006,7 @@ export class ComfyPage { } if (node.onPointerDown) { + // Call onPointerDown for test simulation node.onPointerDown( event, app.canvas.pointer, @@ -977,8 +1014,11 @@ export class ComfyPage { ) // Trigger double-click - if (app.canvas.pointer.onDoubleClick) { - app.canvas.pointer.onDoubleClick(event) + const onDoubleClick = app.canvas.pointer.onDoubleClick as + | ((e: unknown) => void) + | undefined + if (onDoubleClick) { + onDoubleClick(event) } } @@ -1574,7 +1614,9 @@ export class ComfyPage { async convertOffsetToCanvas(pos: [number, number]) { return this.page.evaluate((pos) => { - return window['app'].canvas.ds.convertOffsetToCanvas(pos) + const app = window.app + if (!app) return pos + return app.canvas.ds.convertOffsetToCanvas(pos) }, pos) } @@ -1588,14 +1630,18 @@ export class ComfyPage { } async getNodes(): Promise { return await this.page.evaluate(() => { - return window['app'].graph.nodes + const app = window.app + if (!app?.graph?.nodes) return [] + return app.graph.nodes }) } async getNodeRefsByType(type: string): Promise { return Promise.all( ( await this.page.evaluate((type) => { - return window['app'].graph.nodes + const app = window.app + if (!app?.graph?.nodes) return [] + return app.graph.nodes .filter((n: LGraphNode) => n.type === type) .map((n: LGraphNode) => n.id) }, type) @@ -1606,7 +1652,9 @@ export class ComfyPage { return Promise.all( ( await this.page.evaluate((title) => { - return window['app'].graph.nodes + const app = window.app + if (!app?.graph?.nodes) return [] + return app.graph.nodes .filter((n: LGraphNode) => n.title === title) .map((n: LGraphNode) => n.id) }, title) @@ -1616,7 +1664,9 @@ export class ComfyPage { async getFirstNodeRef(): Promise { const id = await this.page.evaluate(() => { - return window['app'].graph.nodes[0]?.id + const app = window.app + if (!app?.graph?.nodes) return undefined + return app.graph.nodes[0]?.id }) if (!id) return null return this.getNodeRefById(id) @@ -1626,32 +1676,41 @@ export class ComfyPage { } async getUndoQueueSize() { return this.page.evaluate(() => { - const workflow = (window['app'].extensionManager as WorkspaceStore) - .workflow.activeWorkflow - return workflow?.changeTracker.undoQueue.length + const app = window.app + if (!app) return 0 + const extMgr = app.extensionManager as WorkspaceStore + return extMgr.workflow.activeWorkflow?.changeTracker.undoQueue.length ?? 0 }) } async getRedoQueueSize() { return this.page.evaluate(() => { - const workflow = (window['app'].extensionManager as WorkspaceStore) - .workflow.activeWorkflow - return workflow?.changeTracker.redoQueue.length + const app = window.app + if (!app) return 0 + const extMgr = app.extensionManager as WorkspaceStore + return extMgr.workflow.activeWorkflow?.changeTracker.redoQueue.length ?? 0 }) } async isCurrentWorkflowModified() { return this.page.evaluate(() => { - return (window['app'].extensionManager as WorkspaceStore).workflow - .activeWorkflow?.isModified + const app = window.app + if (!app) return false + const extMgr = app.extensionManager as WorkspaceStore + return extMgr.workflow.activeWorkflow?.isModified ?? false }) } async getExportedWorkflow({ api = false }: { api?: boolean } = {}) { return this.page.evaluate(async (api) => { - return (await window['app'].graphToPrompt())[api ? 'output' : 'workflow'] + const app = window.app + if (!app) return undefined + return (await app.graphToPrompt())[api ? 'output' : 'workflow'] }, api) } async setFocusMode(focusMode: boolean) { await this.page.evaluate((focusMode) => { - window['app'].extensionManager.focusMode = focusMode + const app = window.app + if (!app) return + const extMgr = app.extensionManager as { focusMode?: boolean } + extMgr.focusMode = focusMode }, focusMode) await this.nextFrame() } @@ -1664,7 +1723,9 @@ export class ComfyPage { */ async getGroupPosition(title: string): Promise { const pos = await this.page.evaluate((title) => { - const groups = window['app'].graph.groups + const app = window.app + if (!app?.graph?.groups) return null + const groups = app.graph.groups const group = groups.find((g: { title: string }) => g.title === title) if (!group) return null return { x: group.pos[0], y: group.pos[1] } @@ -1686,7 +1747,8 @@ export class ComfyPage { }): Promise { const { name, deltaX, deltaY } = options const screenPos = await this.page.evaluate((title) => { - const app = window['app'] + const app = window.app + if (!app?.graph?.groups) return null const groups = app.graph.groups const group = groups.find((g: { title: string }) => g.title === title) if (!group) return null @@ -1754,11 +1816,16 @@ export const comfyPageFixture = base.extend<{ } }) +interface MatcherContext { + isNot: boolean +} + const makeMatcher = function ( getValue: (node: NodeReference) => Promise | T, type: string ) { return async function ( + this: MatcherContext, node: NodeReference, options?: { timeout?: number; intervals?: number[] } ) { @@ -1784,7 +1851,11 @@ export const comfyExpect = expect.extend({ toBePinned: makeMatcher((n) => n.isPinned(), 'pinned'), toBeBypassed: makeMatcher((n) => n.isBypassed(), 'bypassed'), toBeCollapsed: makeMatcher((n) => n.isCollapsed(), 'collapsed'), - async toHaveFocus(locator: Locator, options = { timeout: 256 }) { + async toHaveFocus( + this: MatcherContext, + locator: Locator, + options = { timeout: 256 } + ) { const isFocused = await locator.evaluate( (el) => el === document.activeElement ) diff --git a/browser_tests/fixtures/components/SidebarTab.ts b/browser_tests/fixtures/components/SidebarTab.ts index 3254e27c84a..18231591164 100644 --- a/browser_tests/fixtures/components/SidebarTab.ts +++ b/browser_tests/fixtures/components/SidebarTab.ts @@ -31,7 +31,7 @@ class SidebarTab { } export class NodeLibrarySidebarTab extends SidebarTab { - constructor(public readonly page: Page) { + constructor(public override readonly page: Page) { super(page, 'node-library') } @@ -55,12 +55,12 @@ export class NodeLibrarySidebarTab extends SidebarTab { return this.tabContainer.locator('.new-folder-button') } - async open() { + override async open() { await super.open() await this.nodeLibraryTree.waitFor({ state: 'visible' }) } - async close() { + override async close() { if (!this.tabButton.isVisible()) { return } @@ -87,7 +87,7 @@ export class NodeLibrarySidebarTab extends SidebarTab { } export class WorkflowsSidebarTab extends SidebarTab { - constructor(public readonly page: Page) { + constructor(public override readonly page: Page) { super(page, 'workflows') } @@ -140,7 +140,14 @@ export class WorkflowsSidebarTab extends SidebarTab { // Wait for workflow service to finish renaming await this.page.waitForFunction( - () => !window['app']?.extensionManager?.workflow?.isBusy, + () => { + const app = window.app + if (!app) return true + const extMgr = app.extensionManager as { + workflow?: { isBusy?: boolean } + } + return !extMgr.workflow?.isBusy + }, undefined, { timeout: 3000 } ) diff --git a/browser_tests/fixtures/components/Topbar.ts b/browser_tests/fixtures/components/Topbar.ts index c5e7c81550b..d809c4292b1 100644 --- a/browser_tests/fixtures/components/Topbar.ts +++ b/browser_tests/fixtures/components/Topbar.ts @@ -86,7 +86,14 @@ export class Topbar { // Wait for workflow service to finish saving await this.page.waitForFunction( - () => !window['app'].extensionManager.workflow.isBusy, + () => { + const app = window.app + if (!app) return true + const extMgr = app.extensionManager as { + workflow?: { isBusy?: boolean } + } + return !extMgr.workflow?.isBusy + }, undefined, { timeout: 3000 } ) diff --git a/browser_tests/fixtures/utils/litegraphUtils.ts b/browser_tests/fixtures/utils/litegraphUtils.ts index 832db632498..2dbf0ba5479 100644 --- a/browser_tests/fixtures/utils/litegraphUtils.ts +++ b/browser_tests/fixtures/utils/litegraphUtils.ts @@ -22,7 +22,10 @@ export class SubgraphSlotReference { async getPosition(): Promise { const pos: [number, number] = await this.comfyPage.page.evaluate( ([type, slotName]) => { - const currentGraph = window['app'].canvas.graph + const app = window.app + if (!app) throw new Error('App not initialized') + const currentGraph = app.canvas.graph + if (!currentGraph) throw new Error('No graph available') // Check if we're in a subgraph if (currentGraph.constructor.name !== 'Subgraph') { @@ -31,15 +34,18 @@ export class SubgraphSlotReference { ) } - const slots = - type === 'input' ? currentGraph.inputs : currentGraph.outputs + const subgraph = currentGraph as { + inputs?: Array<{ name: string; pos?: number[] }> + outputs?: Array<{ name: string; pos?: number[] }> + } + const slots = type === 'input' ? subgraph.inputs : subgraph.outputs if (!slots || slots.length === 0) { throw new Error(`No ${type} slots found in subgraph`) } // Find the specific slot or use the first one if no name specified const slot = slotName - ? slots.find((s) => s.name === slotName) + ? slots.find((s: { name: string }) => s.name === slotName) : slots[0] if (!slot) { @@ -51,7 +57,7 @@ export class SubgraphSlotReference { } // Convert from offset to canvas coordinates - const canvasPos = window['app'].canvas.ds.convertOffsetToCanvas([ + const canvasPos = app.canvas.ds.convertOffsetToCanvas([ slot.pos[0], slot.pos[1] ]) @@ -69,7 +75,10 @@ export class SubgraphSlotReference { async getOpenSlotPosition(): Promise { const pos: [number, number] = await this.comfyPage.page.evaluate( ([type]) => { - const currentGraph = window['app'].canvas.graph + const app = window.app + if (!app) throw new Error('App not initialized') + const currentGraph = app.canvas.graph + if (!currentGraph) throw new Error('No graph available') if (currentGraph.constructor.name !== 'Subgraph') { throw new Error( @@ -77,35 +86,35 @@ export class SubgraphSlotReference { ) } - const node = - type === 'input' ? currentGraph.inputNode : currentGraph.outputNode - const slots = - type === 'input' ? currentGraph.inputs : currentGraph.outputs + const subgraph = currentGraph as { + inputNode?: { pos: number[]; size: number[] } + outputNode?: { pos: number[]; size: number[] } + inputs?: Array<{ pos?: number[] }> + outputs?: Array<{ pos?: number[] }> + slotAnchorX?: number + } + const node = type === 'input' ? subgraph.inputNode : subgraph.outputNode + const slots = type === 'input' ? subgraph.inputs : subgraph.outputs if (!node) { throw new Error(`No ${type} node found in subgraph`) } - // Calculate position for next available slot - // const nextSlotIndex = slots?.length || 0 - // const slotHeight = 20 - // const slotY = node.pos[1] + 30 + nextSlotIndex * slotHeight - // Find last slot position - const lastSlot = slots.at(-1) + const lastSlot = slots?.at(-1) let slotX: number let slotY: number - if (lastSlot) { + if (lastSlot?.pos) { // If there are existing slots, position the new one below the last one const gapHeight = 20 slotX = lastSlot.pos[0] slotY = lastSlot.pos[1] + gapHeight } else { // No existing slots - use slotAnchorX if available, otherwise calculate from node position - if (currentGraph.slotAnchorX !== undefined) { + if (subgraph.slotAnchorX !== undefined) { // The actual slot X position seems to be slotAnchorX - 10 - slotX = currentGraph.slotAnchorX - 10 + slotX = subgraph.slotAnchorX - 10 } else { // Fallback: calculate from node edge slotX = @@ -118,10 +127,7 @@ export class SubgraphSlotReference { } // Convert from offset to canvas coordinates - const canvasPos = window['app'].canvas.ds.convertOffsetToCanvas([ - slotX, - slotY - ]) + const canvasPos = app.canvas.ds.convertOffsetToCanvas([slotX, slotY]) return canvasPos }, [this.type] as const @@ -143,26 +149,15 @@ class NodeSlotReference { async getPosition() { const pos: [number, number] = await this.node.comfyPage.page.evaluate( ([type, id, index]) => { + const app = window.app + if (!app?.canvas?.graph) throw new Error('App not initialized') + const graph = app.canvas.graph // Use canvas.graph to get the current graph (works in both main graph and subgraphs) - const node = window['app'].canvas.graph.getNodeById(id) + const node = graph.getNodeById(id) if (!node) throw new Error(`Node ${id} not found.`) const rawPos = node.getConnectionPos(type === 'input', index) - const convertedPos = - window['app'].canvas.ds.convertOffsetToCanvas(rawPos) - - // Debug logging - convert Float64Arrays to regular arrays for visibility - // eslint-disable-next-line no-console - console.log( - `NodeSlotReference debug for ${type} slot ${index} on node ${id}:`, - { - nodePos: [node.pos[0], node.pos[1]], - nodeSize: [node.size[0], node.size[1]], - rawConnectionPos: [rawPos[0], rawPos[1]], - convertedPos: [convertedPos[0], convertedPos[1]], - currentGraphType: window['app'].canvas.graph.constructor.name - } - ) + const convertedPos = app.canvas.ds.convertOffsetToCanvas(rawPos) return convertedPos }, @@ -176,7 +171,9 @@ class NodeSlotReference { async getLinkCount() { return await this.node.comfyPage.page.evaluate( ([type, id, index]) => { - const node = window['app'].canvas.graph.getNodeById(id) + const app = window.app + if (!app?.canvas?.graph) throw new Error('App not initialized') + const node = app.canvas.graph.getNodeById(id) if (!node) throw new Error(`Node ${id} not found.`) if (type === 'input') { return node.inputs[index].link == null ? 0 : 1 @@ -189,7 +186,9 @@ class NodeSlotReference { async removeLinks() { await this.node.comfyPage.page.evaluate( ([type, id, index]) => { - const node = window['app'].canvas.graph.getNodeById(id) + const app = window.app + if (!app?.canvas?.graph) throw new Error('App not initialized') + const node = app.canvas.graph.getNodeById(id) if (!node) throw new Error(`Node ${id} not found.`) if (type === 'input') { node.disconnectInput(index) @@ -214,15 +213,19 @@ class NodeWidgetReference { async getPosition(): Promise { const pos: [number, number] = await this.node.comfyPage.page.evaluate( ([id, index]) => { - const node = window['app'].canvas.graph.getNodeById(id) + const app = window.app + if (!app?.canvas?.graph) throw new Error('App not initialized') + const node = app.canvas.graph.getNodeById(id) if (!node) throw new Error(`Node ${id} not found.`) + if (!node.widgets) throw new Error(`Node ${id} has no widgets.`) const widget = node.widgets[index] if (!widget) throw new Error(`Widget ${index} not found.`) - const [x, y, w, h] = node.getBounding() - return window['app'].canvasPosToClientPos([ + const [x, y, w] = node.getBounding() + const titleHeight = window.LiteGraph?.NODE_TITLE_HEIGHT ?? 20 + return app.canvasPosToClientPos([ x + w / 2, - y + window['LiteGraph']['NODE_TITLE_HEIGHT'] + widget.last_y + 1 + y + titleHeight + (widget.last_y ?? 0) + 1 ]) }, [this.node.id, this.index] as const @@ -239,8 +242,11 @@ class NodeWidgetReference { async getSocketPosition(): Promise { const pos: [number, number] = await this.node.comfyPage.page.evaluate( ([id, index]) => { - const node = window['app'].graph.getNodeById(id) + const app = window.app + if (!app?.canvas?.graph) throw new Error('App not initialized') + const node = app.canvas.graph.getNodeById(id) if (!node) throw new Error(`Node ${id} not found.`) + if (!node.widgets) throw new Error(`Node ${id} has no widgets.`) const widget = node.widgets[index] if (!widget) throw new Error(`Widget ${index} not found.`) @@ -248,11 +254,13 @@ class NodeWidgetReference { (slot) => slot.widget?.name === widget.name ) if (!slot) throw new Error(`Socket ${widget.name} not found.`) + if (!slot.pos) throw new Error(`Socket ${widget.name} has no position.`) const [x, y] = node.getBounding() - return window['app'].canvasPosToClientPos([ + const titleHeight = window.LiteGraph?.NODE_TITLE_HEIGHT ?? 20 + return app.canvasPosToClientPos([ x + slot.pos[0], - y + slot.pos[1] + window['LiteGraph']['NODE_TITLE_HEIGHT'] + y + slot.pos[1] + titleHeight ]) }, [this.node.id, this.index] as const @@ -288,8 +296,11 @@ class NodeWidgetReference { async getValue() { return await this.node.comfyPage.page.evaluate( ([id, index]) => { - const node = window['app'].graph.getNodeById(id) + const app = window.app + if (!app?.canvas?.graph) throw new Error('App not initialized') + const node = app.canvas.graph.getNodeById(id) if (!node) throw new Error(`Node ${id} not found.`) + if (!node.widgets) throw new Error(`Node ${id} has no widgets.`) const widget = node.widgets[index] if (!widget) throw new Error(`Widget ${index} not found.`) return widget.value @@ -305,7 +316,9 @@ export class NodeReference { ) {} async exists(): Promise { return await this.comfyPage.page.evaluate((id) => { - const node = window['app'].canvas.graph.getNodeById(id) + const app = window.app + if (!app?.canvas?.graph) return false + const node = app.canvas.graph.getNodeById(id) return !!node }, this.id) } @@ -322,17 +335,19 @@ export class NodeReference { } } async getBounding(): Promise { - const [x, y, width, height]: [number, number, number, number] = - await this.comfyPage.page.evaluate((id) => { - const node = window['app'].canvas.graph.getNodeById(id) - if (!node) throw new Error('Node not found') - return node.getBounding() - }, this.id) + const bounding = await this.comfyPage.page.evaluate((id) => { + const app = window.app + if (!app?.canvas?.graph) throw new Error('App not initialized') + const node = app.canvas.graph.getNodeById(id) + if (!node) throw new Error('Node not found') + const b = node.getBounding() + return [b[0], b[1], b[2], b[3]] as [number, number, number, number] + }, this.id) return { - x, - y, - width, - height + x: bounding[0], + y: bounding[1], + width: bounding[2], + height: bounding[3] } } async getSize(): Promise { @@ -355,14 +370,16 @@ export class NodeReference { return (await this.getProperty('mode')) === 4 } async getProperty(prop: string): Promise { - return await this.comfyPage.page.evaluate( + return (await this.comfyPage.page.evaluate( ([id, prop]) => { - const node = window['app'].canvas.graph.getNodeById(id) + const app = window.app + if (!app?.canvas?.graph) throw new Error('App not initialized') + const node = app.canvas.graph.getNodeById(id) if (!node) throw new Error('Node not found') - return node[prop] + return (node as unknown as Record)[prop] }, [this.id, prop] as const - ) + )) as T } async getOutput(index: number) { return new NodeSlotReference('output', index, this) @@ -480,7 +497,7 @@ export class NodeReference { } async navigateIntoSubgraph() { const titleHeight = await this.comfyPage.page.evaluate(() => { - return window['LiteGraph']['NODE_TITLE_HEIGHT'] + return window.LiteGraph?.NODE_TITLE_HEIGHT ?? 20 }) const nodePos = await this.getPosition() const nodeSize = await this.getSize() @@ -513,7 +530,9 @@ export class NodeReference { // Check if we successfully entered the subgraph isInSubgraph = await this.comfyPage.page.evaluate(() => { - const graph = window['app'].canvas.graph + const app = window.app + if (!app) return false + const graph = app.canvas.graph return graph?.constructor?.name === 'Subgraph' }) diff --git a/browser_tests/fixtures/utils/subgraphUtils.ts b/browser_tests/fixtures/utils/subgraphUtils.ts new file mode 100644 index 00000000000..876fc1c8e70 --- /dev/null +++ b/browser_tests/fixtures/utils/subgraphUtils.ts @@ -0,0 +1,67 @@ +/** + * Represents a subgraph's graph object with inputs and outputs slots. + */ +export interface SubgraphGraph { + inputs: SubgraphSlot[] + outputs: SubgraphSlot[] +} + +export interface SubgraphSlot { + label?: string + name?: string + displayName?: string + labelPos?: [number, number] +} + +export interface SubgraphInputNode { + onPointerDown?: (e: unknown, pointer: unknown, linkConnector: unknown) => void +} + +export interface SubgraphGraphWithNodes extends SubgraphGraph { + inputNode?: SubgraphInputNode +} + +/** + * Type guard to check if a graph object is a subgraph. + */ +export function isSubgraph(graph: unknown): graph is SubgraphGraph { + return ( + graph !== null && + typeof graph === 'object' && + 'inputs' in graph && + 'outputs' in graph && + Array.isArray((graph as SubgraphGraph).inputs) && + Array.isArray((graph as SubgraphGraph).outputs) + ) +} + +/** + * Assertion function that throws if the graph is not a subgraph. + */ +export function assertSubgraph( + graph: unknown, + message = 'Not in subgraph' +): asserts graph is SubgraphGraph { + if (!isSubgraph(graph)) { + throw new Error(message) + } +} + +/** + * Inline assertion for use inside page.evaluate() browser context. + * Returns a string that can be used with Function constructor or eval. + */ +export const SUBGRAPH_ASSERT_INLINE = ` + const assertSubgraph = (graph) => { + if ( + graph === null || + typeof graph !== 'object' || + !('inputs' in graph) || + !('outputs' in graph) || + !Array.isArray(graph.inputs) || + !Array.isArray(graph.outputs) + ) { + throw new Error('Not in subgraph'); + } + }; +` diff --git a/browser_tests/fixtures/utils/taskHistory.ts b/browser_tests/fixtures/utils/taskHistory.ts index 01dfb1a4aa5..7b60211b3f3 100644 --- a/browser_tests/fixtures/utils/taskHistory.ts +++ b/browser_tests/fixtures/utils/taskHistory.ts @@ -120,7 +120,7 @@ export default class TaskHistory { filenames: string[], filetype: OutputFileType ): TaskOutput { - return filenames.reduce((outputs, filename, i) => { + return filenames.reduce((outputs, filename, i) => { const nodeId = `${i + 1}` outputs[nodeId] = { [filetype]: [{ filename, subfolder: '', type: 'output' }] diff --git a/browser_tests/fixtures/utils/workflowUtils.ts b/browser_tests/fixtures/utils/workflowUtils.ts new file mode 100644 index 00000000000..dc89bf60960 --- /dev/null +++ b/browser_tests/fixtures/utils/workflowUtils.ts @@ -0,0 +1,22 @@ +interface ExtensionManagerWorkflow { + workflow?: { + activeWorkflow?: { + filename?: string + delete?: () => Promise + } + } +} + +interface AppWithExtensionManager { + extensionManager: ExtensionManagerWorkflow +} + +export function getActiveWorkflowFilename(app: unknown): string | undefined { + const extMgr = (app as AppWithExtensionManager).extensionManager + return extMgr.workflow?.activeWorkflow?.filename +} + +export async function deleteActiveWorkflow(app: unknown): Promise { + const extMgr = (app as AppWithExtensionManager).extensionManager + await extMgr.workflow?.activeWorkflow?.delete?.() +} diff --git a/browser_tests/globalSetup.ts b/browser_tests/globalSetup.ts index 881ef11c439..b43b77c6a6a 100644 --- a/browser_tests/globalSetup.ts +++ b/browser_tests/globalSetup.ts @@ -5,7 +5,7 @@ import { backupPath } from './utils/backupUtils' dotenv.config() -export default function globalSetup(config: FullConfig) { +export default function globalSetup(_config: FullConfig) { if (!process.env.CI) { if (process.env.TEST_COMFYUI_DIR) { backupPath([process.env.TEST_COMFYUI_DIR, 'user']) diff --git a/browser_tests/globalTeardown.ts b/browser_tests/globalTeardown.ts index aeed77294c2..c69f563df69 100644 --- a/browser_tests/globalTeardown.ts +++ b/browser_tests/globalTeardown.ts @@ -5,7 +5,7 @@ import { restorePath } from './utils/backupUtils' dotenv.config() -export default function globalTeardown(config: FullConfig) { +export default function globalTeardown(_config: FullConfig) { if (!process.env.CI && process.env.TEST_COMFYUI_DIR) { restorePath([process.env.TEST_COMFYUI_DIR, 'user']) restorePath([process.env.TEST_COMFYUI_DIR, 'models']) diff --git a/browser_tests/helpers/actionbar.ts b/browser_tests/helpers/actionbar.ts index 6c368c4d650..783853f36aa 100644 --- a/browser_tests/helpers/actionbar.ts +++ b/browser_tests/helpers/actionbar.ts @@ -42,13 +42,26 @@ class ComfyQueueButtonOptions { public async setMode(mode: AutoQueueMode) { await this.page.evaluate((mode) => { - window['app'].extensionManager.queueSettings.mode = mode + const app = window['app'] + if (!app) throw new Error('App not initialized') + const extMgr = app.extensionManager as { + queueSettings?: { mode: string } + } + if (!extMgr.queueSettings) { + throw new Error('queueSettings not initialized') + } + extMgr.queueSettings.mode = mode }, mode) } public async getMode() { return await this.page.evaluate(() => { - return window['app'].extensionManager.queueSettings.mode + const app = window['app'] + if (!app) throw new Error('App not initialized') + const extMgr = app.extensionManager as { + queueSettings?: { mode: string } + } + return extMgr.queueSettings?.mode }) } } diff --git a/browser_tests/helpers/templates.ts b/browser_tests/helpers/templates.ts index ca74096ad2f..9fe72223afc 100644 --- a/browser_tests/helpers/templates.ts +++ b/browser_tests/helpers/templates.ts @@ -32,9 +32,11 @@ export class ComfyTemplates { } async getAllTemplates(): Promise { - const templates: WorkflowTemplates[] = await this.page.evaluate(() => - window['app'].api.getCoreWorkflowTemplates() - ) + const templates: WorkflowTemplates[] = await this.page.evaluate(() => { + const app = window['app'] + if (!app) throw new Error('App not initialized') + return app.api.getCoreWorkflowTemplates() + }) return templates.flatMap((t) => t.templates) } diff --git a/browser_tests/tests/actionbar.spec.ts b/browser_tests/tests/actionbar.spec.ts index bd086b46102..8c4b77ca3f4 100644 --- a/browser_tests/tests/actionbar.spec.ts +++ b/browser_tests/tests/actionbar.spec.ts @@ -1,9 +1,9 @@ import type { Response } from '@playwright/test' import { expect, mergeTests } from '@playwright/test' -import type { StatusWsMessage } from '../../src/schemas/apiSchema.ts' -import { comfyPageFixture } from '../fixtures/ComfyPage.ts' -import { webSocketFixture } from '../fixtures/ws.ts' +import type { StatusWsMessage } from '../../src/schemas/apiSchema' +import { comfyPageFixture } from '../fixtures/ComfyPage' +import { webSocketFixture } from '../fixtures/ws' const test = mergeTests(comfyPageFixture, webSocketFixture) @@ -49,13 +49,19 @@ test.describe('Actionbar', () => { // Find and set the width on the latent node const triggerChange = async (value: number) => { return await comfyPage.page.evaluate((value) => { - const node = window['app'].graph._nodes.find( - (n) => n.type === 'EmptyLatentImage' + const app = window['app'] + if (!app?.graph) throw new Error('App not initialized') + const node = app.graph._nodes.find( + (n: { type: string }) => n.type === 'EmptyLatentImage' ) + if (!node?.widgets?.[0]) throw new Error('Node or widget not found') node.widgets[0].value = value - window[ - 'app' - ].extensionManager.workflow.activeWorkflow.changeTracker.checkState() + const extMgr = app.extensionManager as { + workflow?: { + activeWorkflow?: { changeTracker?: { checkState?: () => void } } + } + } + extMgr.workflow?.activeWorkflow?.changeTracker?.checkState?.() }, value) } diff --git a/browser_tests/tests/browserTabTitle.spec.ts b/browser_tests/tests/browserTabTitle.spec.ts index fd855ea4af3..45bd461d1c1 100644 --- a/browser_tests/tests/browserTabTitle.spec.ts +++ b/browser_tests/tests/browserTabTitle.spec.ts @@ -1,6 +1,10 @@ import { expect } from '@playwright/test' import { comfyPageFixture as test } from '../fixtures/ComfyPage' +import { + deleteActiveWorkflow, + getActiveWorkflowFilename +} from '../fixtures/utils/workflowUtils' test.describe('Browser tab title', () => { test.describe('Beta Menu', () => { @@ -9,9 +13,11 @@ test.describe('Browser tab title', () => { }) test('Can display workflow name', async ({ comfyPage }) => { - const workflowName = await comfyPage.page.evaluate(async () => { - return window['app'].extensionManager.workflow.activeWorkflow.filename - }) + const workflowName = await comfyPage.page.evaluate(() => { + const app = window['app'] + if (!app) throw new Error('App not initialized') + return getActiveWorkflowFilename(app) + }, getActiveWorkflowFilename) expect(await comfyPage.page.title()).toBe(`*${workflowName} - ComfyUI`) }) @@ -20,9 +26,11 @@ test.describe('Browser tab title', () => { test.skip('Can display workflow name with unsaved changes', async ({ comfyPage }) => { - const workflowName = await comfyPage.page.evaluate(async () => { - return window['app'].extensionManager.workflow.activeWorkflow.filename - }) + const workflowName = await comfyPage.page.evaluate(() => { + const app = window['app'] + if (!app) throw new Error('App not initialized') + return getActiveWorkflowFilename(app) + }, getActiveWorkflowFilename) expect(await comfyPage.page.title()).toBe(`${workflowName} - ComfyUI`) await comfyPage.menu.topbar.saveWorkflow('test') @@ -35,8 +43,10 @@ test.describe('Browser tab title', () => { // Delete the saved workflow for cleanup. await comfyPage.page.evaluate(async () => { - return window['app'].extensionManager.workflow.activeWorkflow.delete() - }) + const app = window['app'] + if (!app) throw new Error('App not initialized') + await deleteActiveWorkflow(app) + }, deleteActiveWorkflow) }) }) diff --git a/browser_tests/tests/changeTracker.spec.ts b/browser_tests/tests/changeTracker.spec.ts index 8e39154f158..0ab11c34344 100644 --- a/browser_tests/tests/changeTracker.spec.ts +++ b/browser_tests/tests/changeTracker.spec.ts @@ -6,12 +6,16 @@ import { async function beforeChange(comfyPage: ComfyPage) { await comfyPage.page.evaluate(() => { - window['app'].canvas.emitBeforeChange() + const app = window['app'] + if (!app) throw new Error('App not initialized') + app.canvas.emitBeforeChange() }) } async function afterChange(comfyPage: ComfyPage) { await comfyPage.page.evaluate(() => { - window['app'].canvas.emitAfterChange() + const app = window['app'] + if (!app) throw new Error('App not initialized') + app.canvas.emitAfterChange() }) } @@ -156,7 +160,9 @@ test.describe('Change Tracker', () => { test('Can detect changes in workflow.extra', async ({ comfyPage }) => { expect(await comfyPage.getUndoQueueSize()).toBe(0) await comfyPage.page.evaluate(() => { - window['app'].graph.extra.foo = 'bar' + const app = window['app'] + if (!app?.graph?.extra) throw new Error('App graph not initialized') + app.graph.extra.foo = 'bar' }) // Click empty space to trigger a change detection. await comfyPage.clickEmptySpace() diff --git a/browser_tests/tests/colorPalette.spec.ts b/browser_tests/tests/colorPalette.spec.ts index ce372ddfa69..b49ce68aa1b 100644 --- a/browser_tests/tests/colorPalette.spec.ts +++ b/browser_tests/tests/colorPalette.spec.ts @@ -7,7 +7,15 @@ test.beforeEach(async ({ comfyPage }) => { await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') }) -const customColorPalettes: Record = { +type TestPalette = Omit & { + colors: { + node_slot: Record + litegraph_base: Partial + comfy_base: Partial + } +} + +const customColorPalettes: Record = { obsidian: { version: 102, id: 'obsidian', @@ -176,7 +184,20 @@ test.describe('Color Palette', () => { test('Can add custom color palette', async ({ comfyPage }) => { await comfyPage.page.evaluate((p) => { - window['app'].extensionManager.colorPalette.addCustomColorPalette(p) + const app = window['app'] + if (!app) throw new Error('App not initialized') + const extMgr = app.extensionManager as { + colorPalette?: { addCustomColorPalette?: (p: unknown) => void } + } + if (!extMgr.colorPalette) { + throw new Error('colorPalette extension not found on extensionManager') + } + if (!extMgr.colorPalette.addCustomColorPalette) { + throw new Error( + 'addCustomColorPalette method not found on colorPalette extension' + ) + } + extMgr.colorPalette.addCustomColorPalette(p) }, customColorPalettes.obsidian_dark) expect(await comfyPage.getToastErrorCount()).toBe(0) @@ -232,7 +253,6 @@ test.describe('Node Color Adjustments', () => { }) => { await comfyPage.setSetting('Comfy.Node.Opacity', 0.5) await comfyPage.setSetting('Comfy.ColorPalette', 'light') - const saveWorkflowInterval = 1000 const workflow = await comfyPage.page.evaluate(() => { return localStorage.getItem('workflow') }) diff --git a/browser_tests/tests/commands.spec.ts b/browser_tests/tests/commands.spec.ts index e271f2e15ca..78f2899541a 100644 --- a/browser_tests/tests/commands.spec.ts +++ b/browser_tests/tests/commands.spec.ts @@ -9,25 +9,25 @@ test.beforeEach(async ({ comfyPage }) => { test.describe('Keybindings', () => { test('Should execute command', async ({ comfyPage }) => { await comfyPage.registerCommand('TestCommand', () => { - window['foo'] = true + window.foo = true }) await comfyPage.executeCommand('TestCommand') - expect(await comfyPage.page.evaluate(() => window['foo'])).toBe(true) + expect(await comfyPage.page.evaluate(() => window.foo)).toBe(true) }) test('Should execute async command', async ({ comfyPage }) => { await comfyPage.registerCommand('TestCommand', async () => { await new Promise((resolve) => setTimeout(() => { - window['foo'] = true + window.foo = true resolve() }, 5) ) }) await comfyPage.executeCommand('TestCommand') - expect(await comfyPage.page.evaluate(() => window['foo'])).toBe(true) + expect(await comfyPage.page.evaluate(() => window.foo)).toBe(true) }) test('Should handle command errors', async ({ comfyPage }) => { @@ -41,7 +41,7 @@ test.describe('Keybindings', () => { test('Should handle async command errors', async ({ comfyPage }) => { await comfyPage.registerCommand('TestCommand', async () => { - await new Promise((resolve, reject) => + await new Promise((_resolve, reject) => setTimeout(() => { reject(new Error('Test error')) }, 5) diff --git a/browser_tests/tests/dialog.spec.ts b/browser_tests/tests/dialog.spec.ts index 081f4727530..8d4ba7981ac 100644 --- a/browser_tests/tests/dialog.spec.ts +++ b/browser_tests/tests/dialog.spec.ts @@ -331,7 +331,8 @@ test.describe('Error dialog', () => { comfyPage }) => { await comfyPage.page.evaluate(() => { - const graph = window['graph'] + const graph = window['graph'] as { configure?: () => void } | undefined + if (!graph) throw new Error('Graph not initialized') graph.configure = () => { throw new Error('Error on configure!') } @@ -348,6 +349,7 @@ test.describe('Error dialog', () => { }) => { await comfyPage.page.evaluate(async () => { const app = window['app'] + if (!app) throw new Error('App not initialized') app.api.queuePrompt = () => { throw new Error('Error on queuePrompt!') } @@ -373,7 +375,9 @@ test.describe('Signin dialog', () => { await textBox.press('Control+c') await comfyPage.page.evaluate(() => { - void window['app'].extensionManager.dialog.showSignInDialog() + const app = window['app'] + if (!app) throw new Error('App not initialized') + void app.extensionManager.dialog.showSignInDialog() }) const input = comfyPage.page.locator('#comfy-org-sign-in-password') diff --git a/browser_tests/tests/extensionAPI.spec.ts b/browser_tests/tests/extensionAPI.spec.ts index 38f4a6c1de0..beef8c4b49d 100644 --- a/browser_tests/tests/extensionAPI.spec.ts +++ b/browser_tests/tests/extensionAPI.spec.ts @@ -10,14 +10,16 @@ test.describe('Topbar commands', () => { test('Should allow registering topbar commands', async ({ comfyPage }) => { await comfyPage.page.evaluate(() => { - window['app'].registerExtension({ + const app = window['app'] + if (!app) throw new Error('App not initialized') + app.registerExtension({ name: 'TestExtension1', commands: [ { id: 'foo', label: 'foo-command', function: () => { - window['foo'] = true + window.foo = true } } ], @@ -31,7 +33,7 @@ test.describe('Topbar commands', () => { }) await comfyPage.menu.topbar.triggerTopbarCommand(['ext', 'foo-command']) - expect(await comfyPage.page.evaluate(() => window['foo'])).toBe(true) + expect(await comfyPage.page.evaluate(() => window.foo)).toBe(true) }) test('Should not allow register command defined in other extension', async ({ @@ -39,7 +41,9 @@ test.describe('Topbar commands', () => { }) => { await comfyPage.registerCommand('foo', () => alert(1)) await comfyPage.page.evaluate(() => { - window['app'].registerExtension({ + const app = window['app'] + if (!app) throw new Error('App not initialized') + app.registerExtension({ name: 'TestExtension1', menuCommands: [ { @@ -57,13 +61,14 @@ test.describe('Topbar commands', () => { test('Should allow registering keybindings', async ({ comfyPage }) => { await comfyPage.page.evaluate(() => { const app = window['app'] + if (!app) throw new Error('App not initialized') app.registerExtension({ name: 'TestExtension1', commands: [ { id: 'TestCommand', function: () => { - window['TestCommand'] = true + window.TestCommand = true } } ], @@ -77,15 +82,15 @@ test.describe('Topbar commands', () => { }) await comfyPage.page.keyboard.press('k') - expect(await comfyPage.page.evaluate(() => window['TestCommand'])).toBe( - true - ) + expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe(true) }) test.describe('Settings', () => { test('Should allow adding settings', async ({ comfyPage }) => { await comfyPage.page.evaluate(() => { - window['app'].registerExtension({ + const app = window['app'] + if (!app) throw new Error('App not initialized') + app.registerExtension({ name: 'TestExtension1', settings: [ { @@ -94,24 +99,30 @@ test.describe('Topbar commands', () => { type: 'text', defaultValue: 'Hello, world!', onChange: () => { - window['changeCount'] = (window['changeCount'] ?? 0) + 1 + window.changeCount = (window.changeCount ?? 0) + 1 } - } + } as unknown as SettingParams ] }) }) // onChange is called when the setting is first added - expect(await comfyPage.page.evaluate(() => window['changeCount'])).toBe(1) - expect(await comfyPage.getSetting('TestSetting')).toBe('Hello, world!') + expect(await comfyPage.page.evaluate(() => window.changeCount)).toBe(1) + expect(await comfyPage.getSetting('TestSetting' as string)).toBe( + 'Hello, world!' + ) - await comfyPage.setSetting('TestSetting', 'Hello, universe!') - expect(await comfyPage.getSetting('TestSetting')).toBe('Hello, universe!') - expect(await comfyPage.page.evaluate(() => window['changeCount'])).toBe(2) + await comfyPage.setSetting('TestSetting' as string, 'Hello, universe!') + expect(await comfyPage.getSetting('TestSetting' as string)).toBe( + 'Hello, universe!' + ) + expect(await comfyPage.page.evaluate(() => window.changeCount)).toBe(2) }) test('Should allow setting boolean settings', async ({ comfyPage }) => { await comfyPage.page.evaluate(() => { - window['app'].registerExtension({ + const app = window['app'] + if (!app) throw new Error('App not initialized') + app.registerExtension({ name: 'TestExtension1', settings: [ { @@ -120,20 +131,26 @@ test.describe('Topbar commands', () => { type: 'boolean', defaultValue: false, onChange: () => { - window['changeCount'] = (window['changeCount'] ?? 0) + 1 + window.changeCount = (window.changeCount ?? 0) + 1 } - } + } as unknown as SettingParams ] }) }) - expect(await comfyPage.getSetting('Comfy.TestSetting')).toBe(false) - expect(await comfyPage.page.evaluate(() => window['changeCount'])).toBe(1) + expect(await comfyPage.getSetting('Comfy.TestSetting' as string)).toBe( + false + ) + expect(await comfyPage.page.evaluate(() => window.changeCount)).toBe(1) await comfyPage.settingDialog.open() - await comfyPage.settingDialog.toggleBooleanSetting('Comfy.TestSetting') - expect(await comfyPage.getSetting('Comfy.TestSetting')).toBe(true) - expect(await comfyPage.page.evaluate(() => window['changeCount'])).toBe(2) + await comfyPage.settingDialog.toggleBooleanSetting( + 'Comfy.TestSetting' as string + ) + expect(await comfyPage.getSetting('Comfy.TestSetting' as string)).toBe( + true + ) + expect(await comfyPage.page.evaluate(() => window.changeCount)).toBe(2) }) test.describe('Passing through attrs to setting components', () => { @@ -191,7 +208,9 @@ test.describe('Topbar commands', () => { comfyPage }) => { await comfyPage.page.evaluate((config) => { - window['app'].registerExtension({ + const app = window['app'] + if (!app) throw new Error('App not initialized') + app.registerExtension({ name: 'TestExtension1', settings: [ { @@ -200,7 +219,7 @@ test.describe('Topbar commands', () => { // The `disabled` attr is common to all settings components attrs: { disabled: true }, ...config - } + } as SettingParams ] }) }, config) @@ -224,7 +243,9 @@ test.describe('Topbar commands', () => { test.describe('About panel', () => { test('Should allow adding badges', async ({ comfyPage }) => { await comfyPage.page.evaluate(() => { - window['app'].registerExtension({ + const app = window['app'] + if (!app) throw new Error('App not initialized') + app.registerExtension({ name: 'TestExtension1', aboutPageBadges: [ { @@ -247,18 +268,20 @@ test.describe('Topbar commands', () => { test.describe('Dialog', () => { test('Should allow showing a prompt dialog', async ({ comfyPage }) => { await comfyPage.page.evaluate(() => { - void window['app'].extensionManager.dialog + const app = window['app'] + if (!app) throw new Error('App not initialized') + void app.extensionManager.dialog .prompt({ title: 'Test Prompt', message: 'Test Prompt Message' }) - .then((value: string) => { - window['value'] = value + .then((value: string | null) => { + window.value = value }) }) await comfyPage.fillPromptDialog('Hello, world!') - expect(await comfyPage.page.evaluate(() => window['value'])).toBe( + expect(await comfyPage.page.evaluate(() => window.value)).toBe( 'Hello, world!' ) }) @@ -267,35 +290,39 @@ test.describe('Topbar commands', () => { comfyPage }) => { await comfyPage.page.evaluate(() => { - void window['app'].extensionManager.dialog + const app = window['app'] + if (!app) throw new Error('App not initialized') + void app.extensionManager.dialog .confirm({ title: 'Test Confirm', message: 'Test Confirm Message' }) - .then((value: boolean) => { - window['value'] = value + .then((value: boolean | null) => { + window.value = value }) }) await comfyPage.confirmDialog.click('confirm') - expect(await comfyPage.page.evaluate(() => window['value'])).toBe(true) + expect(await comfyPage.page.evaluate(() => window.value)).toBe(true) }) test('Should allow dismissing a dialog', async ({ comfyPage }) => { await comfyPage.page.evaluate(() => { - window['value'] = 'foo' - void window['app'].extensionManager.dialog + const app = window['app'] + if (!app) throw new Error('App not initialized') + window.value = 'foo' + void app.extensionManager.dialog .confirm({ title: 'Test Confirm', message: 'Test Confirm Message' }) - .then((value: boolean) => { - window['value'] = value + .then((value: boolean | null) => { + window.value = value }) }) await comfyPage.confirmDialog.click('reject') - expect(await comfyPage.page.evaluate(() => window['value'])).toBeNull() + expect(await comfyPage.page.evaluate(() => window.value)).toBeNull() }) }) @@ -309,7 +336,9 @@ test.describe('Topbar commands', () => { }) => { // Register an extension with a selection toolbox command await comfyPage.page.evaluate(() => { - window['app'].registerExtension({ + const app = window['app'] + if (!app) throw new Error('App not initialized') + app.registerExtension({ name: 'TestExtension1', commands: [ { @@ -317,7 +346,7 @@ test.describe('Topbar commands', () => { label: 'Test Command', icon: 'pi pi-star', function: () => { - window['selectionCommandExecuted'] = true + window.selectionCommandExecuted = true } } ], @@ -335,7 +364,7 @@ test.describe('Topbar commands', () => { // Verify the command was executed expect( - await comfyPage.page.evaluate(() => window['selectionCommandExecuted']) + await comfyPage.page.evaluate(() => window.selectionCommandExecuted) ).toBe(true) }) }) diff --git a/browser_tests/tests/featureFlags.spec.ts b/browser_tests/tests/featureFlags.spec.ts index 7079768291d..c3ee28fdc68 100644 --- a/browser_tests/tests/featureFlags.spec.ts +++ b/browser_tests/tests/featureFlags.spec.ts @@ -2,6 +2,19 @@ import { expect } from '@playwright/test' import { comfyPageFixture as test } from '../fixtures/ComfyPage' +declare const window: Window & + typeof globalThis & { + __capturedMessages?: { + clientFeatureFlags: unknown + serverFeatureFlags: unknown + } + __appReadiness?: { + featureFlagsReceived: boolean + apiInitialized: boolean + appInitialized: boolean + } + } + test.beforeEach(async ({ comfyPage }) => { await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') }) @@ -15,8 +28,17 @@ test.describe('Feature Flags', () => { // Set up monitoring before navigation await newPage.addInitScript(() => { + type WindowWithMessages = Window & + typeof globalThis & { + __capturedMessages: { + clientFeatureFlags: unknown + serverFeatureFlags: unknown + } + app?: { api?: { serverFeatureFlags?: Record } } + } + const win = window as WindowWithMessages // This runs before any page scripts - window.__capturedMessages = { + win.__capturedMessages = { clientFeatureFlags: null, serverFeatureFlags: null } @@ -25,11 +47,13 @@ test.describe('Feature Flags', () => { const originalSend = WebSocket.prototype.send WebSocket.prototype.send = function (data) { try { - const parsed = JSON.parse(data) - if (parsed.type === 'feature_flags') { - window.__capturedMessages.clientFeatureFlags = parsed + if (typeof data === 'string') { + const parsed = JSON.parse(data) + if (parsed.type === 'feature_flags') { + win.__capturedMessages.clientFeatureFlags = parsed + } } - } catch (e) { + } catch { // Not JSON, ignore } return originalSend.call(this, data) @@ -37,12 +61,9 @@ test.describe('Feature Flags', () => { // Monitor for server feature flags const checkInterval = setInterval(() => { - if ( - window['app']?.api?.serverFeatureFlags && - Object.keys(window['app'].api.serverFeatureFlags).length > 0 - ) { - window.__capturedMessages.serverFeatureFlags = - window['app'].api.serverFeatureFlags + const serverFlags = win.app?.api?.serverFeatureFlags + if (serverFlags && Object.keys(serverFlags).length > 0) { + win.__capturedMessages.serverFeatureFlags = serverFlags clearInterval(checkInterval) } }, 100) @@ -56,37 +77,58 @@ test.describe('Feature Flags', () => { // Wait for both client and server feature flags await newPage.waitForFunction( - () => - window.__capturedMessages.clientFeatureFlags !== null && - window.__capturedMessages.serverFeatureFlags !== null, + () => { + type WindowWithMessages = Window & + typeof globalThis & { + __capturedMessages?: { + clientFeatureFlags: unknown + serverFeatureFlags: unknown + } + } + const win = window as WindowWithMessages + return ( + win.__capturedMessages?.clientFeatureFlags !== null && + win.__capturedMessages?.serverFeatureFlags !== null + ) + }, { timeout: 10000 } ) // Get the captured messages - const messages = await newPage.evaluate(() => window.__capturedMessages) + const messages = await newPage.evaluate(() => { + type WindowWithMessages = Window & + typeof globalThis & { + __capturedMessages?: { + clientFeatureFlags: { type: string; data: Record } + serverFeatureFlags: Record + } + } + return (window as WindowWithMessages).__capturedMessages + }) // Verify client sent feature flags - expect(messages.clientFeatureFlags).toBeTruthy() - expect(messages.clientFeatureFlags).toHaveProperty('type', 'feature_flags') - expect(messages.clientFeatureFlags).toHaveProperty('data') - expect(messages.clientFeatureFlags.data).toHaveProperty( + expect(messages).toBeTruthy() + expect(messages!.clientFeatureFlags).toBeTruthy() + expect(messages!.clientFeatureFlags).toHaveProperty('type', 'feature_flags') + expect(messages!.clientFeatureFlags).toHaveProperty('data') + expect(messages!.clientFeatureFlags.data).toHaveProperty( 'supports_preview_metadata' ) expect( - typeof messages.clientFeatureFlags.data.supports_preview_metadata + typeof messages!.clientFeatureFlags.data.supports_preview_metadata ).toBe('boolean') // Verify server sent feature flags back - expect(messages.serverFeatureFlags).toBeTruthy() - expect(messages.serverFeatureFlags).toHaveProperty( + expect(messages!.serverFeatureFlags).toBeTruthy() + expect(messages!.serverFeatureFlags).toHaveProperty( 'supports_preview_metadata' ) - expect(typeof messages.serverFeatureFlags.supports_preview_metadata).toBe( + expect(typeof messages!.serverFeatureFlags.supports_preview_metadata).toBe( 'boolean' ) - expect(messages.serverFeatureFlags).toHaveProperty('max_upload_size') - expect(typeof messages.serverFeatureFlags.max_upload_size).toBe('number') - expect(Object.keys(messages.serverFeatureFlags).length).toBeGreaterThan(0) + expect(messages!.serverFeatureFlags).toHaveProperty('max_upload_size') + expect(typeof messages!.serverFeatureFlags.max_upload_size).toBe('number') + expect(Object.keys(messages!.serverFeatureFlags).length).toBeGreaterThan(0) await newPage.close() }) @@ -96,7 +138,9 @@ test.describe('Feature Flags', () => { }) => { // Get the actual server feature flags from the backend const serverFlags = await comfyPage.page.evaluate(() => { - return window['app'].api.serverFeatureFlags + const app = window['app'] + if (!app) throw new Error('App not initialized') + return app.api.serverFeatureFlags }) // Verify we received real feature flags from the backend @@ -115,24 +159,28 @@ test.describe('Feature Flags', () => { }) => { // Test serverSupportsFeature with real backend flags const supportsPreviewMetadata = await comfyPage.page.evaluate(() => { - return window['app'].api.serverSupportsFeature( - 'supports_preview_metadata' - ) + const app = window['app'] + if (!app) throw new Error('App not initialized') + return app.api.serverSupportsFeature('supports_preview_metadata') }) // The method should return a boolean based on the backend's value expect(typeof supportsPreviewMetadata).toBe('boolean') // Test non-existent feature - should always return false const supportsNonExistent = await comfyPage.page.evaluate(() => { - return window['app'].api.serverSupportsFeature('non_existent_feature_xyz') + const app = window['app'] + if (!app) throw new Error('App not initialized') + return app.api.serverSupportsFeature('non_existent_feature_xyz') }) expect(supportsNonExistent).toBe(false) // Test that the method only returns true for boolean true values const testResults = await comfyPage.page.evaluate(() => { + const app = window['app'] + if (!app) throw new Error('App not initialized') // Temporarily modify serverFeatureFlags to test behavior - const original = window['app'].api.serverFeatureFlags - window['app'].api.serverFeatureFlags = { + const original = app.api.serverFeatureFlags + app.api.serverFeatureFlags = { bool_true: true, bool_false: false, string_value: 'yes', @@ -141,15 +189,15 @@ test.describe('Feature Flags', () => { } const results = { - bool_true: window['app'].api.serverSupportsFeature('bool_true'), - bool_false: window['app'].api.serverSupportsFeature('bool_false'), - string_value: window['app'].api.serverSupportsFeature('string_value'), - number_value: window['app'].api.serverSupportsFeature('number_value'), - null_value: window['app'].api.serverSupportsFeature('null_value') + bool_true: app.api.serverSupportsFeature('bool_true'), + bool_false: app.api.serverSupportsFeature('bool_false'), + string_value: app.api.serverSupportsFeature('string_value'), + number_value: app.api.serverSupportsFeature('number_value'), + null_value: app.api.serverSupportsFeature('null_value') } // Restore original - window['app'].api.serverFeatureFlags = original + app.api.serverFeatureFlags = original return results }) @@ -166,23 +214,26 @@ test.describe('Feature Flags', () => { }) => { // Test getServerFeature method const previewMetadataValue = await comfyPage.page.evaluate(() => { - return window['app'].api.getServerFeature('supports_preview_metadata') + const app = window['app'] + if (!app) throw new Error('App not initialized') + return app.api.getServerFeature('supports_preview_metadata') }) expect(typeof previewMetadataValue).toBe('boolean') // Test getting max_upload_size const maxUploadSize = await comfyPage.page.evaluate(() => { - return window['app'].api.getServerFeature('max_upload_size') + const app = window['app'] + if (!app) throw new Error('App not initialized') + return app.api.getServerFeature('max_upload_size') }) expect(typeof maxUploadSize).toBe('number') expect(maxUploadSize).toBeGreaterThan(0) // Test getServerFeature with default value for non-existent feature const defaultValue = await comfyPage.page.evaluate(() => { - return window['app'].api.getServerFeature( - 'non_existent_feature_xyz', - 'default' - ) + const app = window['app'] + if (!app) throw new Error('App not initialized') + return app.api.getServerFeature('non_existent_feature_xyz', 'default') }) expect(defaultValue).toBe('default') }) @@ -192,7 +243,9 @@ test.describe('Feature Flags', () => { }) => { // Test getServerFeatures returns all flags const allFeatures = await comfyPage.page.evaluate(() => { - return window['app'].api.getServerFeatures() + const app = window['app'] + if (!app) throw new Error('App not initialized') + return app.api.getServerFeatures() }) expect(allFeatures).toBeTruthy() @@ -205,14 +258,16 @@ test.describe('Feature Flags', () => { test('Client feature flags are immutable', async ({ comfyPage }) => { // Test that getClientFeatureFlags returns a copy const immutabilityTest = await comfyPage.page.evaluate(() => { - const flags1 = window['app'].api.getClientFeatureFlags() - const flags2 = window['app'].api.getClientFeatureFlags() + const app = window['app'] + if (!app) throw new Error('App not initialized') + const flags1 = app.api.getClientFeatureFlags() + const flags2 = app.api.getClientFeatureFlags() // Modify the first object flags1.test_modification = true // Get flags again to check if original was modified - const flags3 = window['app'].api.getClientFeatureFlags() + const flags3 = app.api.getClientFeatureFlags() return { areEqual: flags1 === flags2, @@ -237,15 +292,17 @@ test.describe('Feature Flags', () => { comfyPage }) => { const immutabilityTest = await comfyPage.page.evaluate(() => { + const app = window['app'] + if (!app) throw new Error('App not initialized') // Get a copy of server features - const features1 = window['app'].api.getServerFeatures() + const features1 = app.api.getServerFeatures() // Try to modify it features1.supports_preview_metadata = false features1.new_feature = 'added' // Get another copy - const features2 = window['app'].api.getServerFeatures() + const features2 = app.api.getServerFeatures() return { modifiedValue: features1.supports_preview_metadata, @@ -330,9 +387,11 @@ test.describe('Feature Flags', () => { // Get readiness state const readiness = await newPage.evaluate(() => { + const app = window['app'] + if (!app) throw new Error('App not initialized') return { ...(window as any).__appReadiness, - currentFlags: window['app'].api.serverFeatureFlags + currentFlags: app.api.serverFeatureFlags } }) diff --git a/browser_tests/tests/graph.spec.ts b/browser_tests/tests/graph.spec.ts index 0c021b5f2be..ebabf289232 100644 --- a/browser_tests/tests/graph.spec.ts +++ b/browser_tests/tests/graph.spec.ts @@ -13,7 +13,9 @@ test.describe('Graph', () => { await comfyPage.loadWorkflow('inputs/input_order_swap') expect( await comfyPage.page.evaluate(() => { - return window['app'].graph.links.get(1)?.target_slot + const app = window['app'] + if (!app?.graph) throw new Error('App not initialized') + return app.graph.links.get(1)?.target_slot }) ).toBe(1) }) diff --git a/browser_tests/tests/graphCanvasMenu.spec.ts b/browser_tests/tests/graphCanvasMenu.spec.ts index fc8385e497c..84c9477d6fe 100644 --- a/browser_tests/tests/graphCanvasMenu.spec.ts +++ b/browser_tests/tests/graphCanvasMenu.spec.ts @@ -23,7 +23,9 @@ test.describe('Graph Canvas Menu', () => { 'canvas-with-hidden-links.png' ) const hiddenLinkRenderMode = await comfyPage.page.evaluate(() => { - return window['LiteGraph'].HIDDEN_LINK + const LiteGraph = window['LiteGraph'] + if (!LiteGraph) throw new Error('LiteGraph not initialized') + return LiteGraph.HIDDEN_LINK }) expect(await comfyPage.getSetting('Comfy.LinkRenderMode')).toBe( hiddenLinkRenderMode diff --git a/browser_tests/tests/groupNode.spec.ts b/browser_tests/tests/groupNode.spec.ts index 9d67a0951a5..4dad05254e8 100644 --- a/browser_tests/tests/groupNode.spec.ts +++ b/browser_tests/tests/groupNode.spec.ts @@ -2,6 +2,7 @@ import { expect } from '@playwright/test' import type { ComfyPage } from '../fixtures/ComfyPage' import { comfyPageFixture as test } from '../fixtures/ComfyPage' +import type { NodeLibrarySidebarTab } from '../fixtures/components/SidebarTab' import type { NodeReference } from '../fixtures/utils/litegraphUtils' test.beforeEach(async ({ comfyPage }) => { @@ -13,7 +14,7 @@ test.describe('Group Node', () => { const groupNodeName = 'DefautWorkflowGroupNode' const groupNodeCategory = 'group nodes>workflow' const groupNodeBookmarkName = `workflow>${groupNodeName}` - let libraryTab + let libraryTab: NodeLibrarySidebarTab test.beforeEach(async ({ comfyPage }) => { await comfyPage.setSetting('Comfy.UseNewMenu', 'Top') @@ -22,7 +23,7 @@ test.describe('Group Node', () => { await libraryTab.open() }) - test('Is added to node library sidebar', async ({ comfyPage }) => { + test('Is added to node library sidebar', async () => { expect(await libraryTab.getFolder('group nodes').count()).toBe(1) }) @@ -110,7 +111,7 @@ test.describe('Group Node', () => { test('Manage group opens with the correct group selected', async ({ comfyPage }) => { - const makeGroup = async (name, type1, type2) => { + const makeGroup = async (name: string, type1: string, type2: string) => { const node1 = (await comfyPage.getNodeRefsByType(type1))[0] const node2 = (await comfyPage.getNodeRefsByType(type2))[0] await node1.click('title') @@ -149,17 +150,27 @@ test.describe('Group Node', () => { const groupNodeName = 'two_VAE_decode' const totalInputCount = await comfyPage.page.evaluate((nodeName) => { - const { - extra: { groupNodes } - } = window['app'].graph + const app = window['app'] + if (!app) throw new Error('App not initialized') + const graph = app.graph + if (!graph?.extra) throw new Error('Graph extra not initialized') + const groupNodes = graph.extra.groupNodes as + | Record }> + | undefined + if (!groupNodes?.[nodeName]) throw new Error('Group node not found') const { nodes } = groupNodes[nodeName] - return nodes.reduce((acc: number, node) => { - return acc + node.inputs.length - }, 0) + return nodes.reduce( + (acc: number, node: { inputs?: unknown[] }) => + acc + (node.inputs?.length ?? 0), + 0 + ) }, groupNodeName) const visibleInputCount = await comfyPage.page.evaluate((id) => { - const node = window['app'].graph.getNodeById(id) + const app = window['app'] + if (!app) throw new Error('App not initialized') + const node = app.graph?.getNodeById(id) + if (!node) throw new Error('Node not found') return node.inputs.length }, groupNodeId) @@ -226,7 +237,9 @@ test.describe('Group Node', () => { const isRegisteredLitegraph = async (comfyPage: ComfyPage) => { return await comfyPage.page.evaluate((nodeType: string) => { - return !!window['LiteGraph'].registered_node_types[nodeType] + const lg = window['LiteGraph'] + if (!lg) throw new Error('LiteGraph not initialized') + return !!lg.registered_node_types[nodeType] }, GROUP_NODE_TYPE) } @@ -299,15 +312,20 @@ test.describe('Group Node', () => { }) => { await comfyPage.menu.topbar.triggerTopbarCommand(['New']) await comfyPage.ctrlV() - const currentGraphState = await comfyPage.page.evaluate(() => - window['app'].graph.serialize() - ) + const currentGraphState = await comfyPage.page.evaluate(() => { + const app = window['app'] + if (!app?.graph) throw new Error('App or graph not initialized') + return app.graph.serialize() + }) await test.step('Load workflow containing a group node pasted from a different workflow', async () => { - await comfyPage.page.evaluate( - (workflow) => window['app'].loadGraphData(workflow), - currentGraphState - ) + await comfyPage.page.evaluate((workflow) => { + const app = window['app'] + if (!app) throw new Error('App not initialized') + return app.loadGraphData( + workflow as Parameters[0] + ) + }, currentGraphState) await comfyPage.nextFrame() await verifyNodeLoaded(comfyPage, 1) }) diff --git a/browser_tests/tests/keybindings.spec.ts b/browser_tests/tests/keybindings.spec.ts index f4244ae669a..dd03ca5909b 100644 --- a/browser_tests/tests/keybindings.spec.ts +++ b/browser_tests/tests/keybindings.spec.ts @@ -11,14 +11,14 @@ test.describe('Keybindings', () => { comfyPage }) => { await comfyPage.registerKeybinding({ key: 'k' }, () => { - window['TestCommand'] = true + window.TestCommand = true }) const textBox = comfyPage.widgetTextBox await textBox.click() await textBox.fill('k') await expect(textBox).toHaveValue('k') - expect(await comfyPage.page.evaluate(() => window['TestCommand'])).toBe( + expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe( undefined ) }) @@ -27,7 +27,7 @@ test.describe('Keybindings', () => { comfyPage }) => { await comfyPage.registerKeybinding({ key: 'k', ctrl: true }, () => { - window['TestCommand'] = true + window.TestCommand = true }) const textBox = comfyPage.widgetTextBox @@ -35,23 +35,21 @@ test.describe('Keybindings', () => { await textBox.fill('q') await textBox.press('Control+k') await expect(textBox).toHaveValue('q') - expect(await comfyPage.page.evaluate(() => window['TestCommand'])).toBe( - true - ) + expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe(true) }) test('Should not trigger keybinding reserved by text input when typing in input fields', async ({ comfyPage }) => { await comfyPage.registerKeybinding({ key: 'Ctrl+v' }, () => { - window['TestCommand'] = true + window.TestCommand = true }) const textBox = comfyPage.widgetTextBox await textBox.click() await textBox.press('Control+v') await expect(textBox).toBeFocused() - expect(await comfyPage.page.evaluate(() => window['TestCommand'])).toBe( + expect(await comfyPage.page.evaluate(() => window.TestCommand)).toBe( undefined ) }) diff --git a/browser_tests/tests/lodThreshold.spec.ts b/browser_tests/tests/lodThreshold.spec.ts index 154ac3c16f6..2c3778f0ef2 100644 --- a/browser_tests/tests/lodThreshold.spec.ts +++ b/browser_tests/tests/lodThreshold.spec.ts @@ -15,7 +15,9 @@ test.describe('LOD Threshold', () => { // Get initial LOD state and settings const initialState = await comfyPage.page.evaluate(() => { - const canvas = window['app'].canvas + const app = window['app'] + if (!app) throw new Error('App not initialized') + const canvas = app.canvas return { lowQuality: canvas.low_quality, scale: canvas.ds.scale, @@ -36,7 +38,9 @@ test.describe('LOD Threshold', () => { await comfyPage.nextFrame() const aboveThresholdState = await comfyPage.page.evaluate(() => { - const canvas = window['app'].canvas + const app = window['app'] + if (!app) throw new Error('App not initialized') + const canvas = app.canvas return { lowQuality: canvas.low_quality, scale: canvas.ds.scale @@ -54,7 +58,9 @@ test.describe('LOD Threshold', () => { // Check that LOD is now active const zoomedOutState = await comfyPage.page.evaluate(() => { - const canvas = window['app'].canvas + const app = window['app'] + if (!app) throw new Error('App not initialized') + const canvas = app.canvas return { lowQuality: canvas.low_quality, scale: canvas.ds.scale @@ -70,7 +76,9 @@ test.describe('LOD Threshold', () => { // Check that LOD is now inactive const zoomedInState = await comfyPage.page.evaluate(() => { - const canvas = window['app'].canvas + const app = window['app'] + if (!app) throw new Error('App not initialized') + const canvas = app.canvas return { lowQuality: canvas.low_quality, scale: canvas.ds.scale @@ -91,7 +99,9 @@ test.describe('LOD Threshold', () => { // Check that font size updated const newState = await comfyPage.page.evaluate(() => { - const canvas = window['app'].canvas + const app = window['app'] + if (!app) throw new Error('App not initialized') + const canvas = app.canvas return { minFontSize: canvas.min_font_size_for_lod } @@ -102,7 +112,9 @@ test.describe('LOD Threshold', () => { // At default zoom, LOD should still be inactive (scale is exactly 1.0, not less than) const lodState = await comfyPage.page.evaluate(() => { - return window['app'].canvas.low_quality + const app = window['app'] + if (!app) throw new Error('App not initialized') + return app.canvas.low_quality }) expect(lodState).toBe(false) @@ -111,7 +123,9 @@ test.describe('LOD Threshold', () => { await comfyPage.nextFrame() const afterZoom = await comfyPage.page.evaluate(() => { - const canvas = window['app'].canvas + const app = window['app'] + if (!app) throw new Error('App not initialized') + const canvas = app.canvas return { lowQuality: canvas.low_quality, scale: canvas.ds.scale @@ -136,7 +150,9 @@ test.describe('LOD Threshold', () => { // LOD should remain disabled even at very low zoom const state = await comfyPage.page.evaluate(() => { - const canvas = window['app'].canvas + const app = window['app'] + if (!app) throw new Error('App not initialized') + const canvas = app.canvas return { lowQuality: canvas.low_quality, scale: canvas.ds.scale, @@ -160,8 +176,10 @@ test.describe('LOD Threshold', () => { // Zoom to target level await comfyPage.page.evaluate((zoom) => { - window['app'].canvas.ds.scale = zoom - window['app'].canvas.setDirty(true, true) + const app = window['app'] + if (!app) throw new Error('App not initialized') + app.canvas.ds.scale = zoom + app.canvas.setDirty(true, true) }, targetZoom) await comfyPage.nextFrame() @@ -171,7 +189,9 @@ test.describe('LOD Threshold', () => { ) const lowQualityState = await comfyPage.page.evaluate(() => { - const canvas = window['app'].canvas + const app = window['app'] + if (!app) throw new Error('App not initialized') + const canvas = app.canvas return { lowQuality: canvas.low_quality, scale: canvas.ds.scale @@ -189,7 +209,9 @@ test.describe('LOD Threshold', () => { ) const highQualityState = await comfyPage.page.evaluate(() => { - const canvas = window['app'].canvas + const app = window['app'] + if (!app) throw new Error('App not initialized') + const canvas = app.canvas return { lowQuality: canvas.low_quality, scale: canvas.ds.scale diff --git a/browser_tests/tests/menu.spec.ts b/browser_tests/tests/menu.spec.ts index 1ba1e55240d..0fc07c6f824 100644 --- a/browser_tests/tests/menu.spec.ts +++ b/browser_tests/tests/menu.spec.ts @@ -11,7 +11,9 @@ test.describe('Menu', () => { const initialChildrenCount = await comfyPage.menu.buttons.count() await comfyPage.page.evaluate(async () => { - window['app'].extensionManager.registerSidebarTab({ + const app = window['app'] + if (!app) throw new Error('App not initialized') + app.extensionManager.registerSidebarTab({ id: 'search', icon: 'pi pi-search', title: 'search', @@ -155,7 +157,9 @@ test.describe('Menu', () => { test('Can catch error when executing command', async ({ comfyPage }) => { await comfyPage.page.evaluate(() => { - window['app'].registerExtension({ + const app = window['app'] + if (!app) throw new Error('App not initialized') + app.registerExtension({ name: 'TestExtension1', commands: [ { diff --git a/browser_tests/tests/nodeBadge.spec.ts b/browser_tests/tests/nodeBadge.spec.ts index 111efe29cf8..76d4896404e 100644 --- a/browser_tests/tests/nodeBadge.spec.ts +++ b/browser_tests/tests/nodeBadge.spec.ts @@ -12,7 +12,9 @@ test.describe('Node Badge', () => { test('Can add badge', async ({ comfyPage }) => { await comfyPage.page.evaluate(() => { const LGraphBadge = window['LGraphBadge'] + if (!LGraphBadge) throw new Error('LGraphBadge not initialized') const app = window['app'] as ComfyApp + if (!app?.graph) throw new Error('App not initialized') const graph = app.graph const nodes = graph.nodes @@ -29,7 +31,9 @@ test.describe('Node Badge', () => { test('Can add multiple badges', async ({ comfyPage }) => { await comfyPage.page.evaluate(() => { const LGraphBadge = window['LGraphBadge'] + if (!LGraphBadge) throw new Error('LGraphBadge not initialized') const app = window['app'] as ComfyApp + if (!app?.graph) throw new Error('App not initialized') const graph = app.graph const nodes = graph.nodes @@ -49,7 +53,9 @@ test.describe('Node Badge', () => { test('Can add badge left-side', async ({ comfyPage }) => { await comfyPage.page.evaluate(() => { const LGraphBadge = window['LGraphBadge'] + if (!LGraphBadge) throw new Error('LGraphBadge not initialized') const app = window['app'] as ComfyApp + if (!app?.graph) throw new Error('App not initialized') const graph = app.graph const nodes = graph.nodes diff --git a/browser_tests/tests/nodeDisplay.spec.ts b/browser_tests/tests/nodeDisplay.spec.ts index fdaae14bcb1..dec3a5c6bbf 100644 --- a/browser_tests/tests/nodeDisplay.spec.ts +++ b/browser_tests/tests/nodeDisplay.spec.ts @@ -1,5 +1,7 @@ import { expect } from '@playwright/test' +import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces' + import { comfyPageFixture as test } from '../fixtures/ComfyPage' test.beforeEach(async ({ comfyPage }) => { @@ -49,20 +51,24 @@ test.describe('Optional input', () => { test('Old workflow with converted input', async ({ comfyPage }) => { await comfyPage.loadWorkflow('inputs/old_workflow_converted_input') const node = await comfyPage.getNodeRefById('1') - const inputs = await node.getProperty('inputs') - const vaeInput = inputs.find((w) => w.name === 'vae') - const convertedInput = inputs.find((w) => w.name === 'strength') + const inputs = await node.getProperty('inputs') + const vaeInput = inputs.find((w: INodeInputSlot) => w.name === 'vae') + const convertedInput = inputs.find( + (w: INodeInputSlot) => w.name === 'strength' + ) expect(vaeInput).toBeDefined() expect(convertedInput).toBeDefined() - expect(vaeInput.link).toBeNull() - expect(convertedInput.link).not.toBeNull() + expect(vaeInput!.link).toBeNull() + expect(convertedInput!.link).not.toBeNull() }) test('Renamed converted input', async ({ comfyPage }) => { await comfyPage.loadWorkflow('inputs/renamed_converted_widget') const node = await comfyPage.getNodeRefById('3') - const inputs = await node.getProperty('inputs') - const renamedInput = inputs.find((w) => w.name === 'breadth') + const inputs = await node.getProperty('inputs') + const renamedInput = inputs.find( + (w: INodeInputSlot) => w.name === 'breadth' + ) expect(renamedInput).toBeUndefined() }) test('slider', async ({ comfyPage }) => { diff --git a/browser_tests/tests/nodeHelp.spec.ts b/browser_tests/tests/nodeHelp.spec.ts index cfb04bc4635..370f08f0e06 100644 --- a/browser_tests/tests/nodeHelp.spec.ts +++ b/browser_tests/tests/nodeHelp.spec.ts @@ -9,8 +9,9 @@ import { fitToViewInstant } from '../helpers/fitToView' async function selectNodeWithPan(comfyPage: any, nodeRef: any) { const nodePos = await nodeRef.getPosition() - await comfyPage.page.evaluate((pos) => { + await comfyPage.page.evaluate((pos: { x: number; y: number }) => { const app = window['app'] + if (!app) throw new Error('App not initialized') const canvas = app.canvas canvas.ds.offset[0] = -pos.x + canvas.canvas.width / 2 canvas.ds.offset[1] = -pos.y + canvas.canvas.height / 2 + 100 @@ -345,7 +346,10 @@ This is documentation for a custom node. // Find and select a custom/group node const nodeRefs = await comfyPage.page.evaluate(() => { - return window['app'].graph.nodes.map((n: any) => n.id) + const app = window['app'] + if (!app) throw new Error('App not initialized') + if (!app.graph) throw new Error('Graph not initialized') + return app.graph.nodes.map((n: any) => n.id) }) if (nodeRefs.length > 0) { const firstNode = await comfyPage.getNodeRefById(nodeRefs[0]) diff --git a/browser_tests/tests/nodeSearchBox.spec.ts b/browser_tests/tests/nodeSearchBox.spec.ts index 2eea2232909..4395636db4d 100644 --- a/browser_tests/tests/nodeSearchBox.spec.ts +++ b/browser_tests/tests/nodeSearchBox.spec.ts @@ -1,3 +1,4 @@ +import type { ComfyPage } from '../fixtures/ComfyPage' import { comfyExpect as expect, comfyPageFixture as test @@ -126,7 +127,10 @@ test.describe('Node search box', () => { }) test.describe('Filtering', () => { - const expectFilterChips = async (comfyPage, expectedTexts: string[]) => { + const expectFilterChips = async ( + comfyPage: ComfyPage, + expectedTexts: string[] + ) => { const chips = comfyPage.searchBox.filterChips // Check that the number of chips matches the expected count diff --git a/browser_tests/tests/remoteWidgets.spec.ts b/browser_tests/tests/remoteWidgets.spec.ts index 18cc77f4459..6da21501ada 100644 --- a/browser_tests/tests/remoteWidgets.spec.ts +++ b/browser_tests/tests/remoteWidgets.spec.ts @@ -25,24 +25,33 @@ test.describe('Remote COMBO Widget', () => { comfyPage: ComfyPage, nodeName: string ): Promise => { - return await comfyPage.page.evaluate((name) => { - const node = window['app'].graph.nodes.find((node) => node.title === name) - return node.widgets[0].options.values + return await comfyPage.page.evaluate((name): string[] | undefined => { + const app = window['app'] + if (!app?.graph) throw new Error('App not initialized') + const node = app.graph.nodes.find((node) => node.title === name) + if (!node?.widgets) throw new Error('Node or widgets not found') + return node.widgets[0].options.values as string[] | undefined }, nodeName) } const getWidgetValue = async (comfyPage: ComfyPage, nodeName: string) => { return await comfyPage.page.evaluate((name) => { - const node = window['app'].graph.nodes.find((node) => node.title === name) + const app = window['app'] + if (!app?.graph) throw new Error('App not initialized') + const node = app.graph.nodes.find((node) => node.title === name) + if (!node?.widgets) throw new Error('Node or widgets not found') return node.widgets[0].value }, nodeName) } const clickRefreshButton = (comfyPage: ComfyPage, nodeName: string) => { return comfyPage.page.evaluate((name) => { - const node = window['app'].graph.nodes.find((node) => node.title === name) + const app = window['app'] + if (!app?.graph) throw new Error('App not initialized') + const node = app.graph.nodes.find((node) => node.title === name) + if (!node?.widgets) throw new Error('Node or widgets not found') const buttonWidget = node.widgets.find((w) => w.name === 'refresh') - return buttonWidget?.callback() + buttonWidget?.callback?.(buttonWidget.value, undefined, node) }, nodeName) } @@ -92,7 +101,9 @@ test.describe('Remote COMBO Widget', () => { await comfyPage.loadWorkflow('inputs/remote_widget') const node = await comfyPage.page.evaluate((name) => { - return window['app'].graph.nodes.find((node) => node.title === name) + const app = window['app'] + if (!app?.graph) throw new Error('App not initialized') + return app.graph.nodes.find((node) => node.title === name) }, nodeName) expect(node).toBeDefined() @@ -196,7 +207,7 @@ test.describe('Remote COMBO Widget', () => { // Fulfill each request with a unique timestamp await comfyPage.page.route( '**/api/models/checkpoints**', - async (route, request) => { + async (route, _request) => { await route.fulfill({ body: JSON.stringify([Date.now()]), status: 200 diff --git a/browser_tests/tests/sidebar/nodeLibrary.spec.ts b/browser_tests/tests/sidebar/nodeLibrary.spec.ts index 4f476376ac1..b4fcc3eea82 100644 --- a/browser_tests/tests/sidebar/nodeLibrary.spec.ts +++ b/browser_tests/tests/sidebar/nodeLibrary.spec.ts @@ -265,13 +265,14 @@ test.describe('Node library sidebar', () => { await comfyPage.nextFrame() // Verify the color selection is saved - const setting = await comfyPage.getSetting( + const setting = (await comfyPage.getSetting( 'Comfy.NodeLibrary.BookmarksCustomization' - ) - await expect(setting).toHaveProperty(['foo/', 'color']) - await expect(setting['foo/'].color).not.toBeNull() - await expect(setting['foo/'].color).not.toBeUndefined() - await expect(setting['foo/'].color).not.toBe('') + )) as Record | undefined + expect(setting).toHaveProperty(['foo/', 'color']) + expect(setting?.['foo/']).toBeDefined() + expect(setting?.['foo/']?.color).not.toBeNull() + expect(setting?.['foo/']?.color).not.toBeUndefined() + expect(setting?.['foo/']?.color).not.toBe('') }) test('Can rename customized bookmark folder', async ({ comfyPage }) => { diff --git a/browser_tests/tests/sidebar/workflows.spec.ts b/browser_tests/tests/sidebar/workflows.spec.ts index 86f37b23db8..09c9bc53efc 100644 --- a/browser_tests/tests/sidebar/workflows.spec.ts +++ b/browser_tests/tests/sidebar/workflows.spec.ts @@ -139,12 +139,15 @@ test.describe('Workflows sidebar', () => { api: false }) expect(exportedWorkflow).toBeDefined() - for (const node of exportedWorkflow.nodes) { - for (const slot of node.inputs) { + if (!exportedWorkflow) return + const nodes = exportedWorkflow.nodes + if (!Array.isArray(nodes)) return + for (const node of nodes) { + for (const slot of node.inputs ?? []) { expect(slot.localized_name).toBeUndefined() expect(slot.label).toBeUndefined() } - for (const slot of node.outputs) { + for (const slot of node.outputs ?? []) { expect(slot.localized_name).toBeUndefined() expect(slot.label).toBeUndefined() } @@ -177,9 +180,13 @@ test.describe('Workflows sidebar', () => { }) // Compare the exported workflow with the original + expect(downloadedContent).toBeDefined() + expect(downloadedContentZh).toBeDefined() + if (!downloadedContent || !downloadedContentZh) { + throw new Error('Downloaded workflow content is undefined') + } delete downloadedContent.id delete downloadedContentZh.id - expect(downloadedContent).toBeDefined() expect(downloadedContent).toEqual(downloadedContentZh) }) diff --git a/browser_tests/tests/subgraph-rename-dialog.spec.ts b/browser_tests/tests/subgraph-rename-dialog.spec.ts index 0bdb9766dff..fc57a981016 100644 --- a/browser_tests/tests/subgraph-rename-dialog.spec.ts +++ b/browser_tests/tests/subgraph-rename-dialog.spec.ts @@ -3,7 +3,6 @@ import { expect } from '@playwright/test' import { comfyPageFixture as test } from '../fixtures/ComfyPage' // Constants -const INITIAL_NAME = 'initial_slot_name' const RENAMED_NAME = 'renamed_slot_name' const SECOND_RENAMED_NAME = 'second_renamed_name' @@ -12,6 +11,39 @@ const SELECTORS = { promptDialog: '.graphdialog input' } as const +interface SubgraphSlot { + label?: string + name?: string + displayName?: string +} + +interface SubgraphGraph { + inputs: SubgraphSlot[] + outputs: SubgraphSlot[] +} + +function getSubgraph(): SubgraphGraph { + const assertSubgraph = (graph: unknown): asserts graph is SubgraphGraph => { + if ( + graph === null || + typeof graph !== 'object' || + !('inputs' in graph) || + !('outputs' in graph) || + !Array.isArray((graph as { inputs: unknown }).inputs) || + !Array.isArray((graph as { outputs: unknown }).outputs) + ) { + throw new Error('Not in subgraph') + } + } + const app = window['app'] + if (!app) throw new Error('App not available') + const canvas = app.canvas + if (!canvas) throw new Error('Canvas not available') + const graph = canvas.graph + assertSubgraph(graph) + return graph +} + test.describe('Subgraph Slot Rename Dialog', () => { test.beforeEach(async ({ comfyPage }) => { await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') @@ -26,13 +58,13 @@ test.describe('Subgraph Slot Rename Dialog', () => { await subgraphNode.navigateIntoSubgraph() // Get initial slot label - const initialInputLabel = await comfyPage.page.evaluate(() => { - const graph = window['app'].canvas.graph - return graph.inputs?.[0]?.label || graph.inputs?.[0]?.name || null - }) + const initialInputLabel = await comfyPage.page.evaluate((getSubgraph) => { + const graph = getSubgraph() + return graph.inputs[0]?.label || graph.inputs[0]?.name || null + }, getSubgraph) // First rename - await comfyPage.rightClickSubgraphInputSlot(initialInputLabel) + await comfyPage.rightClickSubgraphInputSlot(initialInputLabel ?? undefined) await comfyPage.clickLitegraphContextMenuItem('Rename Slot') await comfyPage.page.waitForSelector(SELECTORS.promptDialog, { @@ -54,15 +86,15 @@ test.describe('Subgraph Slot Rename Dialog', () => { await comfyPage.nextFrame() // Verify the rename worked - const afterFirstRename = await comfyPage.page.evaluate(() => { - const graph = window['app'].canvas.graph - const slot = graph.inputs?.[0] + const afterFirstRename = await comfyPage.page.evaluate((getSubgraph) => { + const graph = getSubgraph() + const slot = graph.inputs[0] return { label: slot?.label || null, name: slot?.name || null, displayName: slot?.displayName || slot?.label || slot?.name || null } - }) + }, getSubgraph) expect(afterFirstRename.label).toBe(RENAMED_NAME) // Now rename again - this is where the bug would show @@ -96,10 +128,10 @@ test.describe('Subgraph Slot Rename Dialog', () => { await comfyPage.nextFrame() // Verify the second rename worked - const afterSecondRename = await comfyPage.page.evaluate(() => { - const graph = window['app'].canvas.graph - return graph.inputs?.[0]?.label || null - }) + const afterSecondRename = await comfyPage.page.evaluate((getSubgraph) => { + const graph = getSubgraph() + return graph.inputs[0]?.label || null + }, getSubgraph) expect(afterSecondRename).toBe(SECOND_RENAMED_NAME) }) @@ -112,13 +144,15 @@ test.describe('Subgraph Slot Rename Dialog', () => { await subgraphNode.navigateIntoSubgraph() // Get initial output slot label - const initialOutputLabel = await comfyPage.page.evaluate(() => { - const graph = window['app'].canvas.graph - return graph.outputs?.[0]?.label || graph.outputs?.[0]?.name || null - }) + const initialOutputLabel = await comfyPage.page.evaluate((getSubgraph) => { + const graph = getSubgraph() + return graph.outputs[0]?.label || graph.outputs[0]?.name || null + }, getSubgraph) // First rename - await comfyPage.rightClickSubgraphOutputSlot(initialOutputLabel) + await comfyPage.rightClickSubgraphOutputSlot( + initialOutputLabel ?? undefined + ) await comfyPage.clickLitegraphContextMenuItem('Rename Slot') await comfyPage.page.waitForSelector(SELECTORS.promptDialog, { diff --git a/browser_tests/tests/subgraph.spec.ts b/browser_tests/tests/subgraph.spec.ts index 6359156211c..8ae3580a78f 100644 --- a/browser_tests/tests/subgraph.spec.ts +++ b/browser_tests/tests/subgraph.spec.ts @@ -1,6 +1,7 @@ import { expect } from '@playwright/test' import { comfyPageFixture as test } from '../fixtures/ComfyPage' +import type { SubgraphGraphWithNodes } from '../fixtures/utils/subgraphUtils' // Constants const RENAMED_INPUT_NAME = 'renamed_input' @@ -26,8 +27,14 @@ test.describe('Subgraph Operations', () => { comfyPage: typeof test.prototype.comfyPage, type: 'inputs' | 'outputs' ): Promise { - return await comfyPage.page.evaluate((slotType) => { - return window['app'].canvas.graph[slotType]?.length || 0 + return await comfyPage.page.evaluate((slotType: 'inputs' | 'outputs') => { + const app = window['app'] + if (!app) throw new Error('App not initialized') + const graph = app.canvas.graph + if (!graph || !(slotType in graph)) return 0 + return ( + (graph as unknown as Record)[slotType]?.length || 0 + ) }, type) } @@ -36,7 +43,11 @@ test.describe('Subgraph Operations', () => { comfyPage: typeof test.prototype.comfyPage ): Promise { return await comfyPage.page.evaluate(() => { - return window['app'].canvas.graph.nodes?.length || 0 + const app = window['app'] + if (!app) throw new Error('App not initialized') + const graph = app.canvas.graph + if (!graph) return 0 + return graph.nodes?.length || 0 }) } @@ -45,7 +56,9 @@ test.describe('Subgraph Operations', () => { comfyPage: typeof test.prototype.comfyPage ): Promise { return await comfyPage.page.evaluate(() => { - const graph = window['app'].canvas.graph + const app = window['app'] + if (!app) throw new Error('App not initialized') + const graph = app.canvas.graph return graph?.constructor?.name === 'Subgraph' }) } @@ -130,11 +143,16 @@ test.describe('Subgraph Operations', () => { await subgraphNode.navigateIntoSubgraph() const initialInputLabel = await comfyPage.page.evaluate(() => { - const graph = window['app'].canvas.graph - return graph.inputs?.[0]?.label || null + const app = window['app'] + if (!app) throw new Error('App not initialized') + const graph = app.canvas.graph + if (!graph || !('inputs' in graph)) return null + return (graph.inputs as { label?: string }[])?.[0]?.label ?? null }) - await comfyPage.rightClickSubgraphInputSlot(initialInputLabel) + await comfyPage.rightClickSubgraphInputSlot( + initialInputLabel ?? undefined + ) await comfyPage.clickLitegraphContextMenuItem('Rename Slot') await comfyPage.page.waitForSelector(SELECTORS.promptDialog, { @@ -148,8 +166,11 @@ test.describe('Subgraph Operations', () => { await comfyPage.nextFrame() const newInputName = await comfyPage.page.evaluate(() => { - const graph = window['app'].canvas.graph - return graph.inputs?.[0]?.label || null + const app = window['app'] + if (!app) throw new Error('App not initialized') + const graph = app.canvas.graph + if (!graph || !('inputs' in graph)) return null + return (graph.inputs as { label?: string }[])?.[0]?.label ?? null }) expect(newInputName).toBe(RENAMED_INPUT_NAME) @@ -163,11 +184,16 @@ test.describe('Subgraph Operations', () => { await subgraphNode.navigateIntoSubgraph() const initialInputLabel = await comfyPage.page.evaluate(() => { - const graph = window['app'].canvas.graph - return graph.inputs?.[0]?.label || null + const app = window['app'] + if (!app) throw new Error('App not initialized') + const graph = app.canvas.graph + if (!graph || !('inputs' in graph)) return null + return (graph.inputs as { label?: string }[])?.[0]?.label ?? null }) - await comfyPage.doubleClickSubgraphInputSlot(initialInputLabel) + await comfyPage.doubleClickSubgraphInputSlot( + initialInputLabel ?? undefined + ) await comfyPage.page.waitForSelector(SELECTORS.promptDialog, { state: 'visible' @@ -180,8 +206,11 @@ test.describe('Subgraph Operations', () => { await comfyPage.nextFrame() const newInputName = await comfyPage.page.evaluate(() => { - const graph = window['app'].canvas.graph - return graph.inputs?.[0]?.label || null + const app = window['app'] + if (!app) throw new Error('App not initialized') + const graph = app.canvas.graph + if (!graph || !('inputs' in graph)) return null + return (graph.inputs as { label?: string }[])?.[0]?.label ?? null }) expect(newInputName).toBe(RENAMED_INPUT_NAME) @@ -195,11 +224,16 @@ test.describe('Subgraph Operations', () => { await subgraphNode.navigateIntoSubgraph() const initialOutputLabel = await comfyPage.page.evaluate(() => { - const graph = window['app'].canvas.graph - return graph.outputs?.[0]?.label || null + const app = window['app'] + if (!app) throw new Error('App not initialized') + const graph = app.canvas.graph + if (!graph || !('outputs' in graph)) return null + return (graph.outputs as { label?: string }[])?.[0]?.label ?? null }) - await comfyPage.doubleClickSubgraphOutputSlot(initialOutputLabel) + await comfyPage.doubleClickSubgraphOutputSlot( + initialOutputLabel ?? undefined + ) await comfyPage.page.waitForSelector(SELECTORS.promptDialog, { state: 'visible' @@ -213,8 +247,11 @@ test.describe('Subgraph Operations', () => { await comfyPage.nextFrame() const newOutputName = await comfyPage.page.evaluate(() => { - const graph = window['app'].canvas.graph - return graph.outputs?.[0]?.label || null + const app = window['app'] + if (!app) throw new Error('App not initialized') + const graph = app.canvas.graph + if (!graph || !('outputs' in graph)) return null + return (graph.outputs as { label?: string }[])?.[0]?.label ?? null }) expect(newOutputName).toBe(renamedOutputName) @@ -230,12 +267,17 @@ test.describe('Subgraph Operations', () => { await subgraphNode.navigateIntoSubgraph() const initialInputLabel = await comfyPage.page.evaluate(() => { - const graph = window['app'].canvas.graph - return graph.inputs?.[0]?.label || null + const app = window['app'] + if (!app) throw new Error('App not initialized') + const graph = app.canvas.graph + if (!graph || !('inputs' in graph)) return null + return (graph.inputs as { label?: string }[])?.[0]?.label ?? null }) // Test that right-click still works for renaming - await comfyPage.rightClickSubgraphInputSlot(initialInputLabel) + await comfyPage.rightClickSubgraphInputSlot( + initialInputLabel ?? undefined + ) await comfyPage.clickLitegraphContextMenuItem('Rename Slot') await comfyPage.page.waitForSelector(SELECTORS.promptDialog, { @@ -250,8 +292,11 @@ test.describe('Subgraph Operations', () => { await comfyPage.nextFrame() const newInputName = await comfyPage.page.evaluate(() => { - const graph = window['app'].canvas.graph - return graph.inputs?.[0]?.label || null + const app = window['app'] + if (!app) throw new Error('App not initialized') + const graph = app.canvas.graph + if (!graph || !('inputs' in graph)) return null + return (graph.inputs as { label?: string }[])?.[0]?.label ?? null }) expect(newInputName).toBe(rightClickRenamedName) @@ -267,14 +312,21 @@ test.describe('Subgraph Operations', () => { await subgraphNode.navigateIntoSubgraph() const initialInputLabel = await comfyPage.page.evaluate(() => { - const graph = window['app'].canvas.graph - return graph.inputs?.[0]?.label || null + const app = window['app'] + if (!app) throw new Error('App not initialized') + const graph = app.canvas.graph + if (!graph || !('inputs' in graph)) return null + return (graph.inputs as { label?: string }[])?.[0]?.label ?? null }) // Use direct pointer event approach to double-click on label await comfyPage.page.evaluate(() => { const app = window['app'] - const graph = app.canvas.graph + if (!app) throw new Error('App not initialized') + const graph = app.canvas.graph as SubgraphGraphWithNodes | null + if (!graph || !('inputs' in graph)) { + throw new Error('Not in a subgraph') + } const input = graph.inputs?.[0] if (!input?.labelPos) { @@ -302,8 +354,11 @@ test.describe('Subgraph Operations', () => { ) // Trigger double-click if pointer has the handler - if (app.canvas.pointer.onDoubleClick) { - app.canvas.pointer.onDoubleClick(leftClickEvent) + const pointer = app.canvas.pointer as { + onDoubleClick?: (e: unknown) => void + } + if (pointer.onDoubleClick) { + pointer.onDoubleClick(leftClickEvent) } } }) @@ -322,8 +377,11 @@ test.describe('Subgraph Operations', () => { await comfyPage.nextFrame() const newInputName = await comfyPage.page.evaluate(() => { - const graph = window['app'].canvas.graph - return graph.inputs?.[0]?.label || null + const app = window['app'] + if (!app) throw new Error('App not initialized') + const graph = app.canvas.graph + if (!graph || !('inputs' in graph)) return null + return (graph.inputs as { label?: string }[])?.[0]?.label ?? null }) expect(newInputName).toBe(labelClickRenamedName) @@ -334,7 +392,11 @@ test.describe('Subgraph Operations', () => { }) => { await comfyPage.loadWorkflow('subgraphs/subgraph-compressed-target-slot') const step = await comfyPage.page.evaluate(() => { - return window['app'].graph.nodes[0].widgets[0].options.step + const app = window['app'] + if (!app) throw new Error('App not initialized') + const graph = app.graph + if (!graph?.nodes?.[0]) return undefined + return graph.nodes[0].widgets?.[0]?.options?.step }) expect(step).toBe(10) }) @@ -344,8 +406,6 @@ test.describe('Subgraph Operations', () => { test('Can create subgraph from selected nodes', async ({ comfyPage }) => { await comfyPage.loadWorkflow('default') - const initialNodeCount = await getGraphNodeCount(comfyPage) - await comfyPage.ctrlA() await comfyPage.nextFrame() @@ -453,8 +513,12 @@ test.describe('Subgraph Operations', () => { const initialNodeCount = await getGraphNodeCount(comfyPage) const nodesInSubgraph = await comfyPage.page.evaluate(() => { - const nodes = window['app'].canvas.graph.nodes - return nodes?.[0]?.id || null + const app = window['app'] + if (!app) throw new Error('App not initialized') + const graph = app.canvas.graph + if (!graph) return null + const nodes = graph.nodes + return nodes?.[0]?.id ?? null }) expect(nodesInSubgraph).not.toBeNull() @@ -682,7 +746,11 @@ test.describe('Subgraph Operations', () => { // Check that the subgraph node has no widgets after removing the text slot const widgetCount = await comfyPage.page.evaluate(() => { - return window['app'].canvas.graph.nodes[0].widgets?.length || 0 + const app = window['app'] + if (!app) throw new Error('App not initialized') + const graph = app.canvas.graph + if (!graph || !graph.nodes?.[0]) return 0 + return graph.nodes[0].widgets?.length || 0 }) expect(widgetCount).toBe(0) diff --git a/browser_tests/tests/useSettingSearch.spec.ts b/browser_tests/tests/useSettingSearch.spec.ts index f022bb4bd26..5baeec69f8f 100644 --- a/browser_tests/tests/useSettingSearch.spec.ts +++ b/browser_tests/tests/useSettingSearch.spec.ts @@ -1,5 +1,6 @@ import { expect } from '@playwright/test' +import type { SettingParams } from '../../src/platform/settings/types' import { comfyPageFixture as test } from '../fixtures/ComfyPage' test.beforeEach(async ({ comfyPage }) => { @@ -10,7 +11,7 @@ test.describe('Settings Search functionality', () => { test.beforeEach(async ({ comfyPage }) => { // Register test settings to verify hidden/deprecated filtering await comfyPage.page.evaluate(() => { - window['app'].registerExtension({ + window['app']?.registerExtension({ name: 'TestSettingsExtension', settings: [ { @@ -19,7 +20,7 @@ test.describe('Settings Search functionality', () => { type: 'hidden', defaultValue: 'hidden_value', category: ['Test', 'Hidden'] - }, + } as unknown as SettingParams, { id: 'TestDeprecatedSetting', name: 'Test Deprecated Setting', @@ -27,14 +28,14 @@ test.describe('Settings Search functionality', () => { defaultValue: 'deprecated_value', deprecated: true, category: ['Test', 'Deprecated'] - }, + } as unknown as SettingParams, { id: 'TestVisibleSetting', name: 'Test Visible Setting', type: 'text', defaultValue: 'visible_value', category: ['Test', 'Visible'] - } + } as unknown as SettingParams ] }) }) diff --git a/browser_tests/tests/vueNodes/widgets/widgetReactivity.spec.ts b/browser_tests/tests/vueNodes/widgets/widgetReactivity.spec.ts index 6f3701c1276..c0c1655ec8b 100644 --- a/browser_tests/tests/vueNodes/widgets/widgetReactivity.spec.ts +++ b/browser_tests/tests/vueNodes/widgets/widgetReactivity.spec.ts @@ -3,6 +3,10 @@ import { comfyPageFixture as test } from '../../../fixtures/ComfyPage' +interface GraphWithNodes { + _nodes_by_id: Record +} + test.describe('Vue Widget Reactivity', () => { test.beforeEach(async ({ comfyPage }) => { await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) @@ -13,17 +17,20 @@ test.describe('Vue Widget Reactivity', () => { 'css=[data-testid="node-body-4"] > .lg-node-widgets > div' ) await comfyPage.page.evaluate(() => { - const node = window['graph']._nodes_by_id['4'] + const graph = window['graph'] as GraphWithNodes + const node = graph._nodes_by_id['4'] node.widgets.push(node.widgets[0]) }) await expect(loadCheckpointNode).toHaveCount(2) await comfyPage.page.evaluate(() => { - const node = window['graph']._nodes_by_id['4'] + const graph = window['graph'] as GraphWithNodes + const node = graph._nodes_by_id['4'] node.widgets[2] = node.widgets[0] }) await expect(loadCheckpointNode).toHaveCount(3) await comfyPage.page.evaluate(() => { - const node = window['graph']._nodes_by_id['4'] + const graph = window['graph'] as GraphWithNodes + const node = graph._nodes_by_id['4'] node.widgets.splice(0, 0, node.widgets[0]) }) await expect(loadCheckpointNode).toHaveCount(4) @@ -33,17 +40,20 @@ test.describe('Vue Widget Reactivity', () => { 'css=[data-testid="node-body-3"] > .lg-node-widgets > div' ) await comfyPage.page.evaluate(() => { - const node = window['graph']._nodes_by_id['3'] + const graph = window['graph'] as GraphWithNodes + const node = graph._nodes_by_id['3'] node.widgets.pop() }) await expect(loadCheckpointNode).toHaveCount(5) await comfyPage.page.evaluate(() => { - const node = window['graph']._nodes_by_id['3'] + const graph = window['graph'] as GraphWithNodes + const node = graph._nodes_by_id['3'] node.widgets.length-- }) await expect(loadCheckpointNode).toHaveCount(4) await comfyPage.page.evaluate(() => { - const node = window['graph']._nodes_by_id['3'] + const graph = window['graph'] as GraphWithNodes + const node = graph._nodes_by_id['3'] node.widgets.splice(0, 1) }) await expect(loadCheckpointNode).toHaveCount(3) diff --git a/browser_tests/tests/widget.spec.ts b/browser_tests/tests/widget.spec.ts index c8448f36c54..84dce7bc3c6 100644 --- a/browser_tests/tests/widget.spec.ts +++ b/browser_tests/tests/widget.spec.ts @@ -36,10 +36,15 @@ test.describe('Combo text widget', () => { }) => { const getComboValues = async () => comfyPage.page.evaluate(() => { - return window['app'].graph.nodes - .find((node) => node.title === 'Node With Optional Combo Input') - .widgets.find((widget) => widget.name === 'optional_combo_input') - .options.values + const app = window['app'] + if (!app?.graph) throw new Error('app or graph not found') + const node = app.graph.nodes.find( + (n: { title: string }) => n.title === 'Node With Optional Combo Input' + ) as { widgets: Array<{ name: string; options: { values: unknown } }> } + const widget = node?.widgets?.find( + (w) => w.name === 'optional_combo_input' + ) + return widget?.options?.values }) await comfyPage.loadWorkflow('inputs/optional_combo_input') @@ -71,9 +76,13 @@ test.describe('Combo text widget', () => { await comfyPage.nextFrame() // get the combo widget's values const comboValues = await comfyPage.page.evaluate(() => { - return window['app'].graph.nodes - .find((node) => node.title === 'Node With V2 Combo Input') - .widgets.find((widget) => widget.name === 'combo_input').options.values + const app = window['app'] + if (!app?.graph) throw new Error('app or graph not found') + const node = app.graph.nodes.find( + (n: { title: string }) => n.title === 'Node With V2 Combo Input' + ) as { widgets: Array<{ name: string; options: { values: unknown } }> } + const widget = node?.widgets?.find((w) => w.name === 'combo_input') + return widget?.options?.values }) expect(comboValues).toEqual(['A', 'B']) }) @@ -99,16 +108,20 @@ test.describe('Slider widget', () => { const widget = await node.getWidget(0) await comfyPage.page.evaluate(() => { - const widget = window['app'].graph.nodes[0].widgets[0] + const app = window['app'] + if (!app?.graph?.nodes?.[0]?.widgets?.[0]) { + throw new Error('widget not found') + } + const widget = app.graph.nodes[0].widgets[0] widget.callback = (value: number) => { - window['widgetValue'] = value + window.widgetValue = value } }) await widget.dragHorizontal(50) await expect(comfyPage.canvas).toHaveScreenshot('slider_widget_dragged.png') expect( - await comfyPage.page.evaluate(() => window['widgetValue']) + await comfyPage.page.evaluate(() => window.widgetValue) ).toBeDefined() }) }) @@ -120,16 +133,18 @@ test.describe('Number widget', () => { const node = (await comfyPage.getFirstNodeRef())! const widget = await node.getWidget(0) await comfyPage.page.evaluate(() => { - const widget = window['app'].graph.nodes[0].widgets[0] + const app = window['app'] + if (!app?.graph?.nodes?.[0]?.widgets?.[0]) return + const widget = app.graph.nodes[0].widgets[0] widget.callback = (value: number) => { - window['widgetValue'] = value + window.widgetValue = value } }) await widget.dragHorizontal(50) await expect(comfyPage.canvas).toHaveScreenshot('seed_widget_dragged.png') expect( - await comfyPage.page.evaluate(() => window['widgetValue']) + await comfyPage.page.evaluate(() => window.widgetValue) ).toBeDefined() }) }) @@ -141,8 +156,16 @@ test.describe('Dynamic widget manipulation', () => { await comfyPage.loadWorkflow('nodes/single_ksampler') await comfyPage.page.evaluate(() => { - window['graph'].nodes[0].addWidget('number', 'new_widget', 10) - window['graph'].setDirtyCanvas(true, true) + type GraphWithNodes = { + nodes: Array<{ + addWidget: (type: string, name: string, value: number) => void + }> + setDirtyCanvas: (fg: boolean, bg: boolean) => void + } + const graph = window['graph'] as GraphWithNodes | undefined + if (!graph?.nodes?.[0]) return + graph.nodes[0].addWidget('number', 'new_widget', 10) + graph.setDirtyCanvas(true, true) }) await expect(comfyPage.canvas).toHaveScreenshot('ksampler_widget_added.png') @@ -209,6 +232,23 @@ test.describe('Image widget', () => { comfyPage }) => { const [x, y] = await comfyPage.page.evaluate(() => { + type TestNode = { + pos: [number, number] + size: [number, number] + widgets: Array<{ last_y: number }> + imgs?: HTMLImageElement[] + imageIndex?: number + } + type TestGraph = { nodes: TestNode[] } + type TestApp = { + canvas: { setDirty: (dirty: boolean) => void } + canvasPosToClientPos: (pos: [number, number]) => [number, number] + } + const graph = window['graph'] as TestGraph | undefined + const app = window['app'] as TestApp | undefined + if (!graph?.nodes?.[6] || !app?.canvas) { + throw new Error('graph or app not found') + } const src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='768' height='512' viewBox='0 0 1 1'%3E%3Crect width='1' height='1' stroke='black'/%3E%3C/svg%3E" const image1 = new Image() @@ -220,8 +260,9 @@ test.describe('Image widget', () => { targetNode.imageIndex = 1 app.canvas.setDirty(true) + const lastWidget = targetNode.widgets.at(-1) const x = targetNode.pos[0] + targetNode.size[0] - 41 - const y = targetNode.pos[1] + targetNode.widgets.at(-1).last_y + 30 + const y = targetNode.pos[1] + (lastWidget?.last_y ?? 0) + 30 return app.canvasPosToClientPos([x, y]) }) @@ -313,8 +354,10 @@ test.describe('Animated image widget', () => { // Simulate the graph executing await comfyPage.page.evaluate( ([loadId, saveId]) => { + const app = window['app'] + if (!app?.nodeOutputs || !app?.canvas) return // Set the output of the SaveAnimatedWEBP node to equal the loader node's image - window['app'].nodeOutputs[saveId] = window['app'].nodeOutputs[loadId] + app.nodeOutputs[saveId] = app.nodeOutputs[loadId] app.canvas.setDirty(true) }, [loadAnimatedWebpNode.id, saveAnimatedWebpNode.id] diff --git a/browser_tests/tsconfig.json b/browser_tests/tsconfig.json index 391298333bc..334841eec79 100644 --- a/browser_tests/tsconfig.json +++ b/browser_tests/tsconfig.json @@ -9,5 +9,6 @@ }, "include": [ "**/*.ts", + "types/**/*.d.ts" ] } diff --git a/browser_tests/types/global.d.ts b/browser_tests/types/global.d.ts new file mode 100644 index 00000000000..8ca92ba7ab9 --- /dev/null +++ b/browser_tests/types/global.d.ts @@ -0,0 +1,21 @@ +import type { ComfyApp } from '@/scripts/app' +import type { LGraphBadge, LiteGraph } from '@/lib/litegraph/src/litegraph' + +declare global { + interface Window { + // Application globals + app?: ComfyApp + LiteGraph?: typeof LiteGraph + LGraphBadge?: typeof LGraphBadge + + // Test-only properties used in browser tests + TestCommand?: boolean + foo?: unknown + value?: unknown + widgetValue?: unknown + selectionCommandExecuted?: boolean + changeCount?: number + } +} + +export {} diff --git a/docs/typescript/type-safety.md b/docs/typescript/type-safety.md new file mode 100644 index 00000000000..3ebf840d6cb --- /dev/null +++ b/docs/typescript/type-safety.md @@ -0,0 +1,394 @@ +--- +globs: + - '**/*.ts' + - '**/*.tsx' + - '**/*.vue' +--- + +# Type Safety Rules + +## Never Use Type Assertions (`as`) + +Type assertions bypass TypeScript's type checking. Instead, use proper type guards and narrowing. + +### DOM Elements + +❌ **Wrong:** + +```typescript +const el = e.target as HTMLInputElement +el.value +``` + +✅ **Correct:** + +```typescript +if (e.target instanceof HTMLInputElement) { + e.target.value +} +``` + +### Optional Properties on Objects + +❌ **Wrong:** + +```typescript +const obj = value as { prop?: string } +if (obj.prop) { ... } +``` + +✅ **Correct:** + +```typescript +if ('prop' in value && typeof value.prop === 'string') { + value.prop +} +``` + +### Constructor/Static Properties + +❌ **Wrong:** + +```typescript +const ctor = node.constructor as { type?: string } +const type = ctor.type +``` + +✅ **Correct:** + +```typescript +const ctor = node.constructor +const type = 'type' in ctor && typeof ctor.type === 'string' ? ctor.type : undefined +``` + +## Fix the Source Type, Don't Cast + +When you find yourself needing a cast, ask: "Can I fix the type definition instead?" + +### Missing Interface Properties + +If a property exists at runtime but not in the type, add it to the interface: + +❌ **Wrong:** + +```typescript +const box = this.search_box as { close?: () => void } +box?.close?.() +``` + +✅ **Correct:** + +```typescript +// Update the type definition +search_box?: HTMLDivElement & ICloseable + +// Then use directly +this.search_box?.close() +``` + +### Callback Parameter Types + +If a callback receives a different type than declared, fix the callback signature: + +❌ **Wrong:** + +```typescript +onDrawTooltip?: (link: LLink) => void +// Later... +onDrawTooltip(link as LLink) // link is actually LinkSegment +``` + +✅ **Correct:** + +```typescript +onDrawTooltip?: (link: LinkSegment) => void +// Later... +onDrawTooltip(link) // No cast needed +``` + +## Create Type Guard Functions + +For repeated type checks, create reusable type guards: + +```typescript +interface IPanel extends Element, ICloseable { + node?: LGraphNode + graph?: LGraph +} + +function isPanel(el: Element): el is IPanel { + return 'close' in el && typeof el.close === 'function' +} + +// Usage +for (const panel of panels) { + if (!isPanel(panel)) continue + panel.close() // TypeScript knows panel is IPanel +} +``` + +## Never Use `@ts-ignore` or `@ts-expect-error` + +These directives suppress all errors on a line, making it easy to accidentally mask serious bugs. Instead: + +1. Fix the underlying type issue +2. Use a type guard to narrow the type +3. If truly unavoidable, use a targeted cast with explanation + +❌ **Wrong:** + +```typescript +// @ts-expect-error - doesn't work otherwise +node.customProperty = value +``` + +✅ **Correct:** + +```typescript +interface ExtendedNode extends LGraphNode { + customProperty?: string +} + +function isExtendedNode(node: LGraphNode): node is ExtendedNode { + return 'customProperty' in node +} + +if (isExtendedNode(node)) { + node.customProperty = value +} +``` + +## Use `unknown` Instead of `any` + +When you don't know the type, use `unknown` and narrow with type guards: + +❌ **Wrong:** + +```typescript +function process(data: any) { + return data.value // No type checking +} +``` + +✅ **Correct:** + +```typescript +function process(data: unknown) { + if (typeof data === 'object' && data !== null && 'value' in data) { + return data.value + } + return undefined +} +``` + +## Use Type Annotations for Object Literals + +Prefer type annotations over assertions for object literals - this catches refactoring bugs: + +❌ **Wrong:** + +```typescript +const config = { + naem: 'test' // Typo not caught +} as Config +``` + +✅ **Correct:** + +```typescript +const config: Config = { + naem: 'test' // Error: 'naem' does not exist on type 'Config' +} +``` + +## Prefer Interfaces Over Type Aliases for Objects + +Use interfaces for object types. While modern TypeScript has narrowed performance differences between interfaces and type aliases, interfaces still offer clearer error messages and support declaration merging: + +❌ **Avoid for object types:** + +```typescript +type NodeConfig = { + id: string + name: string +} +``` + +✅ **Preferred:** + +```typescript +interface NodeConfig { + id: string + name: string +} +``` + +Use type aliases for unions, primitives, and tuples where interfaces don't apply. + +## Prefer Specific Over General + +Use the most specific type possible: + +❌ **Wrong:** + +```typescript +const value = obj as any +const value = obj as unknown +const value = obj as Record +``` + +✅ **Correct:** + +```typescript +// Use the actual expected type +const value: ISerialisedNode = obj +// Or use type guards to narrow +if (isSerialisedNode(obj)) { ... } +``` + +## Union Types and Narrowing + +For union types, use proper narrowing instead of casting: + +❌ **Wrong:** + +```typescript +// NodeId = number | string +(node.id as number) - (other.id as number) +``` + +✅ **Correct:** + +```typescript +if (typeof node.id === 'number' && typeof other.id === 'number') { + node.id - other.id +} +``` + +## Primitive Types + +Use lowercase primitive types, never boxed object types: + +❌ **Wrong:** + +```typescript +function greet(name: String): String +function count(items: Object): Number +``` + +✅ **Correct:** + +```typescript +function greet(name: string): string +function count(items: object): number +``` + +## Callback Types + +### Return Types + +Use `void` for callbacks whose return value is ignored: + +❌ **Wrong:** + +```typescript +interface Options { + onComplete?: () => any +} +``` + +✅ **Correct:** + +```typescript +interface Options { + onComplete?: () => void +} +``` + +### Optional Parameters + +Don't make callback parameters optional unless you intend to invoke the callback with varying argument counts: + +❌ **Wrong:** + +```typescript +interface Callbacks { + onProgress?: (current: number, total?: number) => void +} +``` + +✅ **Correct:** + +```typescript +interface Callbacks { + onProgress?: (current: number, total: number) => void +} +``` + +Callbacks can always ignore parameters they don't need. + +## Function Overloads + +### Ordering + +Put specific overloads before general ones (TypeScript uses first match): + +❌ **Wrong:** + +```typescript +declare function fn(x: unknown): unknown +declare function fn(x: HTMLElement): HTMLElement +``` + +✅ **Correct:** + +```typescript +declare function fn(x: HTMLElement): HTMLElement +declare function fn(x: unknown): unknown +``` + +### Prefer Optional Parameters Over Overloads + +❌ **Wrong:** + +```typescript +interface Example { + diff(one: string): number + diff(one: string, two: string): number + diff(one: string, two: string, three: string): number +} +``` + +✅ **Correct:** + +```typescript +interface Example { + diff(one: string, two?: string, three?: string): number +} +``` + +## Generics + +Never create a generic type that doesn't use its type parameter: + +❌ **Wrong:** + +```typescript +declare function fn(): void +``` + +✅ **Correct:** + +```typescript +declare function fn(arg: T): T +``` + +## Summary + +1. **Never use `as`** outside of custom type guards (exception: test files may use `as Partial as T` for partial mocks) +2. **Use `instanceof`** for class/DOM element checks +3. **Use `'prop' in obj`** for property existence checks +4. **Use `typeof`** for primitive type checks +5. **Fix source types** instead of casting at usage sites +6. **Create type guards** for repeated patterns +7. **Use `void`** for ignored callback returns diff --git a/scripts/collect-i18n-node-defs.ts b/scripts/collect-i18n-node-defs.ts index 46d85ad7347..ee328dafaf6 100644 --- a/scripts/collect-i18n-node-defs.ts +++ b/scripts/collect-i18n-node-defs.ts @@ -1,14 +1,38 @@ import * as fs from 'fs' import type { ComfyNodeDef } from '@/schemas/nodeDefSchema' +import type { ComfyApp } from '@/scripts/app' + +import type { LiteGraphGlobal } from '../src/lib/litegraph/src/litegraph' import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage' -import { normalizeI18nKey } from '../packages/shared-frontend-utils/src/formatUtil' +import { normalizeI18nKey } from '@comfyorg/shared-frontend-utils/formatUtil' import type { ComfyNodeDefImpl } from '../src/stores/nodeDefStore' const localePath = './src/locales/en/main.json' const nodeDefsPath = './src/locales/en/nodeDefs.json' +interface ComfyWindow extends Window { + app: ComfyApp + LiteGraph: LiteGraphGlobal +} + +function isComfyWindow(win: Window): win is ComfyWindow { + return ( + 'app' in win && + win.app != null && + 'LiteGraph' in win && + win.LiteGraph != null + ) +} + +function getComfyWindow(): ComfyWindow { + if (!isComfyWindow(window)) { + throw new Error('ComfyApp or LiteGraph not found on window') + } + return window +} + interface WidgetInfo { name?: string label?: string @@ -30,8 +54,8 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => { const nodeDefs: ComfyNodeDefImpl[] = await comfyPage.page.evaluate( async () => { - // @ts-expect-error - app is dynamically added to window - const api = window['app'].api + const comfyWindow = getComfyWindow() + const api = comfyWindow.app.api const rawNodeDefs = await api.getNodeDefs() const { ComfyNodeDefImpl } = await import('../src/stores/nodeDefStore') @@ -44,7 +68,7 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => { } ) - console.log(`Collected ${nodeDefs.length} node definitions`) + console.warn(`Collected ${nodeDefs.length} node definitions`) const allDataTypesLocale = Object.fromEntries( nodeDefs @@ -80,9 +104,9 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => { const widgetsMappings = await comfyPage.page.evaluate( (args) => { const [nodeName, displayName, inputNames] = args - // @ts-expect-error - LiteGraph is dynamically added to window - const node = window['LiteGraph'].createNode(nodeName, displayName) - if (!node.widgets?.length) return {} + const comfyWindow = getComfyWindow() + const node = comfyWindow.LiteGraph.createNode(nodeName, displayName) + if (!node?.widgets?.length) return {} return Object.fromEntries( node.widgets .filter( diff --git a/src/components/common/CustomizationDialog.vue b/src/components/common/CustomizationDialog.vue index f5df6d84d75..b4e9bcad0a4 100644 --- a/src/components/common/CustomizationDialog.vue +++ b/src/components/common/CustomizationDialog.vue @@ -92,18 +92,16 @@ const colorOptions = [ const defaultIcon = iconOptions.find( (option) => option.value === nodeBookmarkStore.defaultBookmarkIcon -) +) ?? { name: '', value: '' } -// @ts-expect-error fixme ts strict error const selectedIcon = ref<{ name: string; value: string }>(defaultIcon) const finalColor = ref( props.initialColor || nodeBookmarkStore.defaultBookmarkColor ) const resetCustomization = () => { - // @ts-expect-error fixme ts strict error selectedIcon.value = - iconOptions.find((option) => option.value === props.initialIcon) || + iconOptions.find((option) => option.value === props.initialIcon) ?? defaultIcon finalColor.value = props.initialColor || nodeBookmarkStore.defaultBookmarkColor diff --git a/src/components/common/EditableText.test.ts b/src/components/common/EditableText.test.ts index 2d31123b987..0c1c234cfed 100644 --- a/src/components/common/EditableText.test.ts +++ b/src/components/common/EditableText.test.ts @@ -13,8 +13,10 @@ describe('EditableText', () => { app.use(PrimeVue) }) - // @ts-expect-error fixme ts strict error - const mountComponent = (props, options = {}) => { + const mountComponent = ( + props: { modelValue: string; isEditing: boolean }, + options = {} + ) => { return mount(EditableText, { global: { plugins: [PrimeVue], @@ -65,8 +67,7 @@ describe('EditableText', () => { }) await wrapper.findComponent(InputText).trigger('blur') expect(wrapper.emitted('edit')).toBeTruthy() - // @ts-expect-error fixme ts strict error - expect(wrapper.emitted('edit')[0]).toEqual(['Test Text']) + expect(wrapper.emitted('edit')?.[0]).toEqual(['Test Text']) }) it('cancels editing on escape key', async () => { @@ -124,8 +125,7 @@ describe('EditableText', () => { // Trigger blur that happens after enter await enterWrapper.findComponent(InputText).trigger('blur') expect(enterWrapper.emitted('edit')).toBeTruthy() - // @ts-expect-error fixme ts strict error - expect(enterWrapper.emitted('edit')[0]).toEqual(['Saved Text']) + expect(enterWrapper.emitted('edit')?.[0]).toEqual(['Saved Text']) // Test Escape key cancels changes with a fresh wrapper const escapeWrapper = mountComponent({ diff --git a/src/components/common/EditableText.vue b/src/components/common/EditableText.vue index 322d332a460..91b53541b59 100644 --- a/src/components/common/EditableText.vue +++ b/src/components/common/EditableText.vue @@ -44,12 +44,13 @@ const { const emit = defineEmits(['edit', 'cancel']) const inputValue = ref(modelValue) -const inputRef = ref | undefined>() +const inputRef = ref< + (InstanceType & { $el?: HTMLElement }) | undefined +>() const isCanceling = ref(false) const blurInputElement = () => { - // @ts-expect-error - $el is an internal property of the InputText component - inputRef.value?.$el.blur() + inputRef.value?.$el?.blur() } const finishEditing = () => { // Don't save if we're canceling @@ -74,15 +75,14 @@ watch( if (newVal) { inputValue.value = modelValue await nextTick(() => { - if (!inputRef.value) return + const el = inputRef.value?.$el + if (!el || !(el instanceof HTMLInputElement)) return const fileName = inputValue.value.includes('.') ? inputValue.value.split('.').slice(0, -1).join('.') : inputValue.value const start = 0 const end = fileName.length - // @ts-expect-error - $el is an internal property of the InputText component - const inputElement = inputRef.value.$el - inputElement.setSelectionRange?.(start, end) + el.setSelectionRange(start, end) }) } }, diff --git a/src/components/common/ElectronFileDownload.vue b/src/components/common/ElectronFileDownload.vue index 0a85a197b70..34916d2434d 100644 --- a/src/components/common/ElectronFileDownload.vue +++ b/src/components/common/ElectronFileDownload.vue @@ -116,17 +116,16 @@ const fileSize = computed(() => ) const { copyToClipboard } = useCopyToClipboard() const electronDownloadStore = useElectronDownloadStore() -// @ts-expect-error fixme ts strict error -const [savePath, filename] = props.label.split('/') +const [savePath = '', filename = ''] = props.label?.split('/') ?? [] electronDownloadStore.$subscribe((_, { downloads }) => { - const download = downloads.find((download) => props.url === download.url) + const foundDownload = downloads.find((download) => props.url === download.url) - if (download) { - // @ts-expect-error fixme ts strict error - downloadProgress.value = Number((download.progress * 100).toFixed(1)) - // @ts-expect-error fixme ts strict error - status.value = download.status + if (foundDownload) { + downloadProgress.value = Number( + ((foundDownload.progress ?? 0) * 100).toFixed(1) + ) + status.value = foundDownload.status ?? null } }) diff --git a/src/components/common/FormItem.vue b/src/components/common/FormItem.vue index 011f8075378..d7f8cc82fec 100644 --- a/src/components/common/FormItem.vue +++ b/src/components/common/FormItem.vue @@ -68,19 +68,23 @@ function getFormAttrs(item: FormItem) { } switch (item.type) { case 'combo': - case 'radio': - attrs['options'] = + case 'radio': { + const options = typeof item.options === 'function' - ? // @ts-expect-error: Audit and deprecate usage of legacy options type: - // (value) => [string | {text: string, value: string}] - item.options(formValue.value) + ? item.options(formValue.value) : item.options + attrs['options'] = options - if (typeof item.options?.[0] !== 'string') { + if ( + Array.isArray(options) && + options.length > 0 && + typeof options[0] !== 'string' + ) { attrs['optionLabel'] = 'text' attrs['optionValue'] = 'value' } break + } } return attrs } diff --git a/src/components/common/TreeExplorerTreeNode.test.ts b/src/components/common/TreeExplorerTreeNode.test.ts index d00d3e401e2..192cce6bca4 100644 --- a/src/components/common/TreeExplorerTreeNode.test.ts +++ b/src/components/common/TreeExplorerTreeNode.test.ts @@ -61,8 +61,8 @@ describe('TreeExplorerTreeNode', () => { expect(wrapper.findComponent(EditableText).props('modelValue')).toBe( 'Test Node' ) - // @ts-expect-error fixme ts strict error - expect(wrapper.findComponent(Badge).props()['value'].toString()).toBe('3') + const badgeValue = wrapper.findComponent(Badge).props('value') + expect(String(badgeValue)).toBe('3') }) it('makes node label editable when renamingEditingNode matches', async () => { diff --git a/src/components/dialog/content/MissingCoreNodesMessage.test.ts b/src/components/dialog/content/MissingCoreNodesMessage.test.ts index afcb90996e4..d8b481cdd25 100644 --- a/src/components/dialog/content/MissingCoreNodesMessage.test.ts +++ b/src/components/dialog/content/MissingCoreNodesMessage.test.ts @@ -6,6 +6,7 @@ import { nextTick } from 'vue' import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { SystemStats } from '@/schemas/apiSchema' import { useSystemStatsStore } from '@/stores/systemStatsStore' // Mock the stores @@ -13,10 +14,8 @@ vi.mock('@/stores/systemStatsStore', () => ({ useSystemStatsStore: vi.fn() })) -const createMockNode = (type: string, version?: string): LGraphNode => - // @ts-expect-error - Creating a partial mock of LGraphNode for testing purposes. - // We only need specific properties for our tests, not the full LGraphNode interface. - ({ +function createMockNode(type: string, version?: string): LGraphNode { + return Object.assign(Object.create(null), { type, properties: { cnr_id: 'comfy-core', ver: version }, id: 1, @@ -29,21 +28,58 @@ const createMockNode = (type: string, version?: string): LGraphNode => inputs: [], outputs: [] }) +} + +interface MockSystemStatsStore { + systemStats: SystemStats | null + isLoading: boolean + error: Error | undefined + isInitialized: boolean + refetchSystemStats: ReturnType + getFormFactor: () => string +} + +function createMockSystemStats( + overrides: Partial = {} +): SystemStats { + return { + system: { + os: 'linux', + python_version: '3.10.0', + embedded_python: false, + comfyui_version: '1.0.0', + pytorch_version: '2.0.0', + argv: [], + ram_total: 16000000000, + ram_free: 8000000000, + ...overrides + }, + devices: [] + } +} + +function createMockSystemStatsStore(): MockSystemStatsStore { + return { + systemStats: null, + isLoading: false, + error: undefined, + isInitialized: true, + refetchSystemStats: vi.fn(), + getFormFactor: () => 'other' + } +} describe('MissingCoreNodesMessage', () => { - const mockSystemStatsStore = { - systemStats: null as { system?: { comfyui_version?: string } } | null, - refetchSystemStats: vi.fn() - } + let mockSystemStatsStore: MockSystemStatsStore beforeEach(() => { vi.clearAllMocks() - // Reset the mock store state - mockSystemStatsStore.systemStats = null - mockSystemStatsStore.refetchSystemStats = vi.fn() - // @ts-expect-error - Mocking the return value of useSystemStatsStore for testing. - // The actual store has more properties, but we only need these for our tests. - useSystemStatsStore.mockReturnValue(mockSystemStatsStore) + mockSystemStatsStore = createMockSystemStatsStore() + vi.mocked(useSystemStatsStore).mockReturnValue( + mockSystemStatsStore as Partial< + ReturnType + > as ReturnType + ) }) const mountComponent = (props = {}) => { @@ -88,9 +124,9 @@ describe('MissingCoreNodesMessage', () => { it('displays current ComfyUI version when available', async () => { // Set systemStats directly (store auto-fetches with useAsyncState) - mockSystemStatsStore.systemStats = { - system: { comfyui_version: '1.0.0' } - } + mockSystemStatsStore.systemStats = createMockSystemStats({ + comfyui_version: '1.0.0' + }) const missingCoreNodes = { '1.2.0': [createMockNode('TestNode', '1.2.0')] diff --git a/src/components/dialog/content/PromptDialogContent.vue b/src/components/dialog/content/PromptDialogContent.vue index 92c721a244b..e96275cf4c6 100644 --- a/src/components/dialog/content/PromptDialogContent.vue +++ b/src/components/dialog/content/PromptDialogContent.vue @@ -39,11 +39,13 @@ const onConfirm = () => { useDialogStore().closeDialog() } -const inputRef = ref | undefined>() +const inputRef = ref< + (InstanceType & { $el?: HTMLElement }) | undefined +>() const selectAllText = () => { - if (!inputRef.value) return - // @ts-expect-error - $el is an internal property of the InputText component - const inputElement = inputRef.value.$el - inputElement.setSelectionRange(0, inputElement.value.length) + const el = inputRef.value?.$el + if (el instanceof HTMLInputElement) { + el.setSelectionRange(0, el.value.length) + } } diff --git a/src/components/dialog/content/setting/KeybindingPanel.vue b/src/components/dialog/content/setting/KeybindingPanel.vue index 03ca3df47c3..c97eb88640e 100644 --- a/src/components/dialog/content/setting/KeybindingPanel.vue +++ b/src/components/dialog/content/setting/KeybindingPanel.vue @@ -194,7 +194,9 @@ const selectedCommandData = ref(null) const editDialogVisible = ref(false) const newBindingKeyCombo = ref(null) const currentEditingCommand = ref(null) -const keybindingInput = ref | null>(null) +const keybindingInput = ref< + (InstanceType & { $el?: HTMLElement }) | null +>(null) const existingKeybindingOnCombo = computed(() => { if (!currentEditingCommand.value) { @@ -229,8 +231,8 @@ watchEffect(() => { if (editDialogVisible.value) { // nextTick doesn't work here, so we use a timeout instead setTimeout(() => { - // @ts-expect-error - $el is an internal property of the InputText component - keybindingInput.value?.$el?.focus() + const el = keybindingInput.value?.$el + if (el instanceof HTMLElement) el.focus() }, 300) } }) diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index 48d82e44b4b..83627682257 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -428,7 +428,12 @@ onMounted(async () => { await newUserService().initializeIfNewUser(settingStore) - // @ts-expect-error fixme ts strict error + if (!canvasRef.value) { + console.error( + '[GraphCanvas] canvasRef.value is null during onMounted - comfyApp.setup was skipped' + ) + return + } await comfyApp.setup(canvasRef.value) canvasStore.canvas = comfyApp.canvas canvasStore.canvas.render_canvas_border = false diff --git a/src/components/graph/NodeContextMenu.vue b/src/components/graph/NodeContextMenu.vue index 97ee1b9c99e..e38b4b4a18a 100644 --- a/src/components/graph/NodeContextMenu.vue +++ b/src/components/graph/NodeContextMenu.vue @@ -42,7 +42,14 @@ import { useElementBounding, useEventListener, useRafFn } from '@vueuse/core' import ContextMenu from 'primevue/contextmenu' import type { MenuItem } from 'primevue/menuitem' -import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue' +import { + computed, + onMounted, + onUnmounted, + ref, + useTemplateRef, + watchEffect +} from 'vue' import { registerNodeOptionsInstance, @@ -56,14 +63,29 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import ColorPickerMenu from './selectionToolbox/ColorPickerMenu.vue' +function getMenuElement( + menu: InstanceType | null +): HTMLElement | undefined { + if (!menu) return undefined + if ('container' in menu && menu.container instanceof HTMLElement) { + return menu.container + } + if ('$el' in menu && menu.$el instanceof HTMLElement) { + return menu.$el + } + return undefined +} + interface ExtendedMenuItem extends MenuItem { isColorSubmenu?: boolean shortcut?: string originalOption?: MenuOption } -const contextMenu = ref>() -const colorPickerMenu = ref>() +const contextMenu = + useTemplateRef>('contextMenu') +const colorPickerMenu = + useTemplateRef>('colorPickerMenu') const isOpen = ref(false) const { menuOptions, bump } = useMoreOptionsMenu() @@ -85,10 +107,7 @@ let lastOffsetY = 0 const updateMenuPosition = () => { if (!isOpen.value) return - const menuInstance = contextMenu.value as unknown as { - container?: HTMLElement - } - const menuEl = menuInstance?.container + const menuEl = getMenuElement(contextMenu.value) if (!menuEl) return const { scale, offset } = lgCanvas.ds @@ -137,11 +156,7 @@ useEventListener( if (!isOpen.value || !contextMenu.value) return const target = event.target as Node - const contextMenuInstance = contextMenu.value as unknown as { - container?: HTMLElement - $el?: HTMLElement - } - const menuEl = contextMenuInstance.container || contextMenuInstance.$el + const menuEl = getMenuElement(contextMenu.value) if (menuEl && !menuEl.contains(target)) { hide() diff --git a/src/components/load3d/Load3DControls.vue b/src/components/load3d/Load3DControls.vue index 68251c74122..1c13d5e9c3b 100644 --- a/src/components/load3d/Load3DControls.vue +++ b/src/components/load3d/Load3DControls.vue @@ -161,15 +161,14 @@ const selectCategory = (category: string) => { } const getCategoryIcon = (category: string) => { - const icons = { + const icons: Record = { scene: 'pi pi-image', model: 'pi pi-box', camera: 'pi pi-camera', light: 'pi pi-sun', export: 'pi pi-download' } - // @ts-expect-error fixme ts strict error - return `${icons[category]} text-base-foreground text-lg` + return `${icons[category] ?? ''} text-base-foreground text-lg` } const emit = defineEmits<{ diff --git a/src/components/sidebar/tabs/ModelLibrarySidebarTab.vue b/src/components/sidebar/tabs/ModelLibrarySidebarTab.vue index 21865523142..c7bebb19106 100644 --- a/src/components/sidebar/tabs/ModelLibrarySidebarTab.vue +++ b/src/components/sidebar/tabs/ModelLibrarySidebarTab.vue @@ -145,17 +145,16 @@ const renderedRoot = computed>(() => { children, draggable: node.leaf, handleClick(e: MouseEvent) { - if (this.leaf) { - // @ts-expect-error fixme ts strict error + if (this.leaf && model) { const provider = modelToNodeStore.getNodeProvider(model.directory) if (provider) { - const node = useLitegraphService().addNodeOnGraph(provider.nodeDef) - // @ts-expect-error fixme ts strict error - const widget = node.widgets.find( + const addedNode = useLitegraphService().addNodeOnGraph( + provider.nodeDef + ) + const widget = addedNode?.widgets?.find( (widget) => widget.name === provider.key ) if (widget) { - // @ts-expect-error fixme ts strict error widget.value = model.file_name } } diff --git a/src/components/sidebar/tabs/NodeLibrarySidebarTab.vue b/src/components/sidebar/tabs/NodeLibrarySidebarTab.vue index 2240a60b1a9..1b74d3e9316 100644 --- a/src/components/sidebar/tabs/NodeLibrarySidebarTab.vue +++ b/src/components/sidebar/tabs/NodeLibrarySidebarTab.vue @@ -278,8 +278,7 @@ const renderedRoot = computed>(() => { } }, handleClick(e: MouseEvent) { - if (this.leaf) { - // @ts-expect-error fixme ts strict error + if (this.leaf && this.data) { useLitegraphService().addNodeOnGraph(this.data) } else { toggleNodeOnEvent(e, this) @@ -336,8 +335,7 @@ const onAddFilter = async ( await handleSearch(searchQuery.value) } -// @ts-expect-error fixme ts strict error -const onRemoveFilter = async (filterAndValue) => { +const onRemoveFilter = async (filterAndValue: SearchFilter) => { const index = filters.value.findIndex((f) => f === filterAndValue) if (index !== -1) { filters.value.splice(index, 1) diff --git a/src/components/sidebar/tabs/nodeLibrary/NodeBookmarkTreeExplorer.vue b/src/components/sidebar/tabs/nodeLibrary/NodeBookmarkTreeExplorer.vue index 646f841bb4c..3e5e31387ab 100644 --- a/src/components/sidebar/tabs/nodeLibrary/NodeBookmarkTreeExplorer.vue +++ b/src/components/sidebar/tabs/nodeLibrary/NodeBookmarkTreeExplorer.vue @@ -162,20 +162,17 @@ const renderedBookmarkedRoot = computed>( droppable: !node.leaf, async handleDrop(data: TreeExplorerDragAndDropData) { const nodeDefToAdd = data.data.data + if (!nodeDefToAdd) return // Remove bookmark if the source is the top level bookmarked node. - // @ts-expect-error fixme ts strict error if (nodeBookmarkStore.isBookmarked(nodeDefToAdd)) { - // @ts-expect-error fixme ts strict error await nodeBookmarkStore.toggleBookmark(nodeDefToAdd) } const folderNodeDef = node.data as ComfyNodeDefImpl - // @ts-expect-error fixme ts strict error const nodePath = folderNodeDef.category + '/' + nodeDefToAdd.name await nodeBookmarkStore.addBookmark(nodePath) }, handleClick(e: MouseEvent) { - if (this.leaf) { - // @ts-expect-error fixme ts strict error + if (this.leaf && this.data) { useLitegraphService().addNodeOnGraph(this.data) } else { toggleNodeOnEvent(e, node) @@ -194,8 +191,9 @@ const renderedBookmarkedRoot = computed>( } }, async handleDelete() { - // @ts-expect-error fixme ts strict error - await nodeBookmarkStore.deleteBookmarkFolder(this.data) + if (this.data) { + await nodeBookmarkStore.deleteBookmarkFolder(this.data) + } } }) } diff --git a/src/components/topbar/CurrentUserButton.test.ts b/src/components/topbar/CurrentUserButton.test.ts index db5349b49be..a41455ec992 100644 --- a/src/components/topbar/CurrentUserButton.test.ts +++ b/src/components/topbar/CurrentUserButton.test.ts @@ -1,5 +1,7 @@ import type { VueWrapper } from '@vue/test-utils' import { mount } from '@vue/test-utils' +import type { ComponentExposed } from 'vue-component-type-helpers' + import Button from '@/components/ui/button/Button.vue' import { beforeEach, describe, expect, it, vi } from 'vitest' import { h } from 'vue' @@ -9,6 +11,8 @@ import enMessages from '@/locales/en/main.json' with { type: 'json' } import CurrentUserButton from './CurrentUserButton.vue' +type CurrentUserButtonInstance = ComponentExposed + // Mock all firebase modules vi.mock('firebase/app', () => ({ initializeApp: vi.fn(), @@ -98,8 +102,9 @@ describe('CurrentUserButton', () => { const popoverToggleSpy = vi.fn() // Override the ref with a mock implementation - // @ts-expect-error - accessing internal Vue component vm - wrapper.vm.popover = { toggle: popoverToggleSpy } + Object.assign(wrapper.vm, { + popover: { toggle: popoverToggleSpy } + }) await wrapper.findComponent(Button).trigger('click') expect(popoverToggleSpy).toHaveBeenCalled() @@ -110,12 +115,14 @@ describe('CurrentUserButton', () => { // Replace the popover.hide method with a spy const popoverHideSpy = vi.fn() - // @ts-expect-error - accessing internal Vue component vm - wrapper.vm.popover = { hide: popoverHideSpy } + Object.assign(wrapper.vm, { + popover: { hide: popoverHideSpy } + }) // Directly call the closePopover method through the component instance - // @ts-expect-error - accessing internal Vue component vm - wrapper.vm.closePopover() + // closePopover is exposed via defineExpose in the component + const vm = wrapper.vm as CurrentUserButtonInstance + vm.closePopover() // Verify that popover.hide was called expect(popoverHideSpy).toHaveBeenCalled() diff --git a/src/components/topbar/CurrentUserButton.vue b/src/components/topbar/CurrentUserButton.vue index 44233aac5c7..026c6be1fcf 100644 --- a/src/components/topbar/CurrentUserButton.vue +++ b/src/components/topbar/CurrentUserButton.vue @@ -62,4 +62,14 @@ const photoURL = computed( const closePopover = () => { popover.value?.hide() } + +const openPopover = (event: Event) => { + popover.value?.show(event) +} + +const togglePopover = (event: Event) => { + popover.value?.toggle(event) +} + +defineExpose({ closePopover, openPopover, togglePopover }) diff --git a/src/composables/canvas/useSelectedLiteGraphItems.test.ts b/src/composables/canvas/useSelectedLiteGraphItems.test.ts index 23e1e8dd31e..e70b98f01a3 100644 --- a/src/composables/canvas/useSelectedLiteGraphItems.test.ts +++ b/src/composables/canvas/useSelectedLiteGraphItems.test.ts @@ -28,11 +28,12 @@ vi.mock('@/lib/litegraph/src/litegraph', () => ({ } })) -// Mock Positionable objects -// @ts-expect-error - Mock implementation for testing +// Mock Positionable objects - full implementation for testing class MockNode implements Positionable { + readonly id = 1 pos: [number, number] size: [number, number] + readonly boundingRect: [number, number, number, number] constructor( pos: [number, number] = [0, 0], @@ -40,25 +41,52 @@ class MockNode implements Positionable { ) { this.pos = pos this.size = size + this.boundingRect = [pos[0], pos[1], size[0], size[1]] + } + + move(): void {} + snapToGrid(): boolean { + return false } } -class MockReroute extends Reroute implements Positionable { - // @ts-expect-error - Override for testing - override pos: [number, number] +// MockReroute - passes instanceof Reroute check +class MockReroute implements Positionable { + readonly id = 1 + pos: [number, number] size: [number, number] + readonly boundingRect: [number, number, number, number] constructor( pos: [number, number] = [0, 0], size: [number, number] = [20, 20] ) { - // @ts-expect-error - Mock constructor - super() + Object.setPrototypeOf(this, Reroute.prototype) this.pos = pos this.size = size + this.boundingRect = [pos[0], pos[1], size[0], size[1]] + } + + move(): void {} + snapToGrid(): boolean { + return false } } +// Helper to create mock LGraphNode objects +function createMockLGraphNode( + id: number, + mode: number, + subgraphNodes?: LGraphNode[] +): LGraphNode { + return Object.assign(Object.create(null), { + id, + mode, + isSubgraphNode: subgraphNodes ? () => true : undefined, + subgraph: subgraphNodes ? { nodes: subgraphNodes } : undefined + }) +} + describe('useSelectedLiteGraphItems', () => { let canvasStore: ReturnType let mockCanvas: any @@ -86,7 +114,6 @@ describe('useSelectedLiteGraphItems', () => { it('should return false for non-Reroute items', () => { const { isIgnoredItem } = useSelectedLiteGraphItems() const node = new MockNode() - // @ts-expect-error - Test mock expect(isIgnoredItem(node)).toBe(false) }) }) @@ -98,14 +125,11 @@ describe('useSelectedLiteGraphItems', () => { const node2 = new MockNode([100, 100]) const reroute = new MockReroute([50, 50]) - // @ts-expect-error - Test mocks const items = new Set([node1, node2, reroute]) const filtered = filterSelectableItems(items) expect(filtered.size).toBe(2) - // @ts-expect-error - Test mocks expect(filtered.has(node1)).toBe(true) - // @ts-expect-error - Test mocks expect(filtered.has(node2)).toBe(true) expect(filtered.has(reroute)).toBe(false) }) @@ -143,9 +167,7 @@ describe('useSelectedLiteGraphItems', () => { const selectableItems = getSelectableItems() expect(selectableItems.size).toBe(2) - // @ts-expect-error - Test mock expect(selectableItems.has(node1)).toBe(true) - // @ts-expect-error - Test mock expect(selectableItems.has(node2)).toBe(true) expect(selectableItems.has(reroute)).toBe(false) }) @@ -200,8 +222,8 @@ describe('useSelectedLiteGraphItems', () => { describe('node-specific methods', () => { it('getSelectedNodes should return only LGraphNode instances', () => { const { getSelectedNodes } = useSelectedLiteGraphItems() - const node1 = { id: 1, mode: LGraphEventMode.ALWAYS } as LGraphNode - const node2 = { id: 2, mode: LGraphEventMode.NEVER } as LGraphNode + const node1 = createMockLGraphNode(1, LGraphEventMode.ALWAYS) + const node2 = createMockLGraphNode(2, LGraphEventMode.NEVER) // Mock app.canvas.selected_nodes app.canvas.selected_nodes = { '0': node1, '1': node2 } @@ -215,8 +237,7 @@ describe('useSelectedLiteGraphItems', () => { it('getSelectedNodes should return empty array when no nodes selected', () => { const { getSelectedNodes } = useSelectedLiteGraphItems() - // @ts-expect-error - Testing null case - app.canvas.selected_nodes = null + Object.assign(app.canvas, { selected_nodes: null }) const selectedNodes = getSelectedNodes() expect(selectedNodes).toHaveLength(0) @@ -224,8 +245,8 @@ describe('useSelectedLiteGraphItems', () => { it('toggleSelectedNodesMode should toggle node modes correctly', () => { const { toggleSelectedNodesMode } = useSelectedLiteGraphItems() - const node1 = { id: 1, mode: LGraphEventMode.ALWAYS } as LGraphNode - const node2 = { id: 2, mode: LGraphEventMode.NEVER } as LGraphNode + const node1 = createMockLGraphNode(1, LGraphEventMode.ALWAYS) + const node2 = createMockLGraphNode(2, LGraphEventMode.NEVER) app.canvas.selected_nodes = { '0': node1, '1': node2 } @@ -240,7 +261,7 @@ describe('useSelectedLiteGraphItems', () => { it('toggleSelectedNodesMode should set mode to ALWAYS when already in target mode', () => { const { toggleSelectedNodesMode } = useSelectedLiteGraphItems() - const node = { id: 1, mode: LGraphEventMode.BYPASS } as LGraphNode + const node = createMockLGraphNode(1, LGraphEventMode.BYPASS) app.canvas.selected_nodes = { '0': node } @@ -253,17 +274,13 @@ describe('useSelectedLiteGraphItems', () => { it('getSelectedNodes should include nodes from subgraphs', () => { const { getSelectedNodes } = useSelectedLiteGraphItems() - const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode - const subNode2 = { id: 12, mode: LGraphEventMode.NEVER } as LGraphNode - const subgraphNode = { - id: 1, - mode: LGraphEventMode.ALWAYS, - isSubgraphNode: () => true, - subgraph: { - nodes: [subNode1, subNode2] - } - } as unknown as LGraphNode - const regularNode = { id: 2, mode: LGraphEventMode.NEVER } as LGraphNode + const subNode1 = createMockLGraphNode(11, LGraphEventMode.ALWAYS) + const subNode2 = createMockLGraphNode(12, LGraphEventMode.NEVER) + const subgraphNode = createMockLGraphNode(1, LGraphEventMode.ALWAYS, [ + subNode1, + subNode2 + ]) + const regularNode = createMockLGraphNode(2, LGraphEventMode.NEVER) app.canvas.selected_nodes = { '0': subgraphNode, '1': regularNode } @@ -277,17 +294,13 @@ describe('useSelectedLiteGraphItems', () => { it('toggleSelectedNodesMode should apply unified state to subgraph children', () => { const { toggleSelectedNodesMode } = useSelectedLiteGraphItems() - const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode - const subNode2 = { id: 12, mode: LGraphEventMode.NEVER } as LGraphNode - const subgraphNode = { - id: 1, - mode: LGraphEventMode.ALWAYS, - isSubgraphNode: () => true, - subgraph: { - nodes: [subNode1, subNode2] - } - } as unknown as LGraphNode - const regularNode = { id: 2, mode: LGraphEventMode.BYPASS } as LGraphNode + const subNode1 = createMockLGraphNode(11, LGraphEventMode.ALWAYS) + const subNode2 = createMockLGraphNode(12, LGraphEventMode.NEVER) + const subgraphNode = createMockLGraphNode(1, LGraphEventMode.ALWAYS, [ + subNode1, + subNode2 + ]) + const regularNode = createMockLGraphNode(2, LGraphEventMode.BYPASS) app.canvas.selected_nodes = { '0': subgraphNode, '1': regularNode } @@ -308,16 +321,13 @@ describe('useSelectedLiteGraphItems', () => { it('toggleSelectedNodesMode should toggle to ALWAYS when subgraph is already in target mode', () => { const { toggleSelectedNodesMode } = useSelectedLiteGraphItems() - const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode - const subNode2 = { id: 12, mode: LGraphEventMode.BYPASS } as LGraphNode - const subgraphNode = { - id: 1, - mode: LGraphEventMode.NEVER, // Already in NEVER mode - isSubgraphNode: () => true, - subgraph: { - nodes: [subNode1, subNode2] - } - } as unknown as LGraphNode + const subNode1 = createMockLGraphNode(11, LGraphEventMode.ALWAYS) + const subNode2 = createMockLGraphNode(12, LGraphEventMode.BYPASS) + // subgraphNode already in NEVER mode + const subgraphNode = createMockLGraphNode(1, LGraphEventMode.NEVER, [ + subNode1, + subNode2 + ]) app.canvas.selected_nodes = { '0': subgraphNode } diff --git a/src/composables/graph/useSelectionState.test.ts b/src/composables/graph/useSelectionState.test.ts index cf0e4bd841a..1ba6d4d79ae 100644 --- a/src/composables/graph/useSelectionState.test.ts +++ b/src/composables/graph/useSelectionState.test.ts @@ -1,19 +1,24 @@ -import { createPinia, setActivePinia } from 'pinia' +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' import { beforeEach, describe, expect, test, vi } from 'vitest' -import { ref } from 'vue' -import type { Ref } from 'vue' import { useSelectionState } from '@/composables/graph/useSelectionState' -import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab' +import type { Positionable } from '@/lib/litegraph/src/interfaces' import { LGraphEventMode } from '@/lib/litegraph/src/litegraph' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' -import { useNodeDefStore } from '@/stores/nodeDefStore' -import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore' -import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore' import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil' import { filterOutputNodes } from '@/utils/nodeFilterUtil' -// Test interfaces +vi.mock('@/utils/litegraphUtil', () => ({ + isLGraphNode: vi.fn(), + isImageNode: vi.fn(), + isLoad3dNode: vi.fn(() => false) +})) + +vi.mock('@/utils/nodeFilterUtil', () => ({ + filterOutputNodes: vi.fn() +})) + interface TestNodeConfig { type?: string mode?: LGraphEventMode @@ -22,163 +27,69 @@ interface TestNodeConfig { removable?: boolean } -interface TestNode { +class MockPositionable implements Positionable { + readonly id = 0 + readonly pos: [number, number] = [0, 0] + readonly boundingRect = [0, 0, 100, 100] as const type: string mode: LGraphEventMode flags?: { collapsed?: boolean } pinned?: boolean removable?: boolean - isSubgraphNode: () => boolean -} - -type MockedItem = TestNode | { type: string; isNode: boolean } -// Mock all stores -vi.mock('@/renderer/core/canvas/canvasStore', () => ({ - useCanvasStore: vi.fn() -})) - -vi.mock('@/stores/nodeDefStore', () => ({ - useNodeDefStore: vi.fn() -})) - -vi.mock('@/stores/workspace/sidebarTabStore', () => ({ - useSidebarTabStore: vi.fn() -})) + constructor(config: TestNodeConfig = {}) { + this.type = config.type ?? 'TestNode' + this.mode = config.mode ?? LGraphEventMode.ALWAYS + this.flags = config.flags + this.pinned = config.pinned + this.removable = config.removable + } -vi.mock('@/stores/workspace/nodeHelpStore', () => ({ - useNodeHelpStore: vi.fn() -})) + move(): void {} + snapToGrid(): boolean { + return false + } + isSubgraphNode(): boolean { + return false + } +} -vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({ - useNodeLibrarySidebarTab: vi.fn() -})) +function createTestNode(config: TestNodeConfig = {}): MockPositionable { + return new MockPositionable(config) +} -vi.mock('@/utils/litegraphUtil', () => ({ - isLGraphNode: vi.fn(), - isImageNode: vi.fn() -})) +class MockNonNode implements Positionable { + readonly id = 0 + readonly pos: [number, number] = [0, 0] + readonly boundingRect = [0, 0, 100, 100] as const + readonly isNode = false + type: string -vi.mock('@/utils/nodeFilterUtil', () => ({ - filterOutputNodes: vi.fn() -})) + constructor(type: string) { + this.type = type + } -const createTestNode = (config: TestNodeConfig = {}): TestNode => { - return { - type: config.type || 'TestNode', - mode: config.mode || LGraphEventMode.ALWAYS, - flags: config.flags, - pinned: config.pinned, - removable: config.removable, - isSubgraphNode: () => false + move(): void {} + snapToGrid(): boolean { + return false } } -// Mock comment/connection objects -const mockComment = { type: 'comment', isNode: false } -const mockConnection = { type: 'connection', isNode: false } +const mockComment = new MockNonNode('comment') +const mockConnection = new MockNonNode('connection') describe('useSelectionState', () => { - // Mock store instances - let mockSelectedItems: Ref - beforeEach(() => { vi.clearAllMocks() - setActivePinia(createPinia()) - - // Setup mock canvas store with proper ref - mockSelectedItems = ref([]) - vi.mocked(useCanvasStore).mockReturnValue({ - selectedItems: mockSelectedItems, - // Add minimal required properties for the store - $id: 'canvas', - $state: {} as any, - $patch: vi.fn(), - $reset: vi.fn(), - $subscribe: vi.fn(), - $onAction: vi.fn(), - $dispose: vi.fn(), - _customProperties: new Set(), - _p: {} as any - } as any) - - // Setup mock node def store - vi.mocked(useNodeDefStore).mockReturnValue({ - fromLGraphNode: vi.fn((node: TestNode) => { - if (node?.type === 'TestNode') { - return { nodePath: 'test.TestNode', name: 'TestNode' } - } - return null - }), - // Add minimal required properties for the store - $id: 'nodeDef', - $state: {} as any, - $patch: vi.fn(), - $reset: vi.fn(), - $subscribe: vi.fn(), - $onAction: vi.fn(), - $dispose: vi.fn(), - _customProperties: new Set(), - _p: {} as any - } as any) + setActivePinia(createTestingPinia({ stubActions: false })) - // Setup mock sidebar tab store - const mockToggleSidebarTab = vi.fn() - vi.mocked(useSidebarTabStore).mockReturnValue({ - activeSidebarTabId: null, - toggleSidebarTab: mockToggleSidebarTab, - // Add minimal required properties for the store - $id: 'sidebarTab', - $state: {} as any, - $patch: vi.fn(), - $reset: vi.fn(), - $subscribe: vi.fn(), - $onAction: vi.fn(), - $dispose: vi.fn(), - _customProperties: new Set(), - _p: {} as any - } as any) - - // Setup mock node help store - const mockOpenHelp = vi.fn() - const mockCloseHelp = vi.fn() - const mockNodeHelpStore = { - isHelpOpen: false, - currentHelpNode: null, - openHelp: mockOpenHelp, - closeHelp: mockCloseHelp, - // Add minimal required properties for the store - $id: 'nodeHelp', - $state: {} as any, - $patch: vi.fn(), - $reset: vi.fn(), - $subscribe: vi.fn(), - $onAction: vi.fn(), - $dispose: vi.fn(), - _customProperties: new Set(), - _p: {} as any - } - vi.mocked(useNodeHelpStore).mockReturnValue(mockNodeHelpStore as any) - - // Setup mock composables - vi.mocked(useNodeLibrarySidebarTab).mockReturnValue({ - id: 'node-library-tab', - title: 'Node Library', - type: 'custom', - render: () => null - } as any) - - // Setup mock utility functions vi.mocked(isLGraphNode).mockImplementation((item: unknown) => { - const typedItem = item as { isNode?: boolean } - return typedItem?.isNode !== false - }) - vi.mocked(isImageNode).mockImplementation((node: unknown) => { - const typedNode = node as { type?: string } - return typedNode?.type === 'ImageNode' + if (typeof item !== 'object' || item === null) return false + return !('isNode' in item && item.isNode === false) }) - vi.mocked(filterOutputNodes).mockImplementation( - (nodes: TestNode[]) => nodes.filter((n) => n.type === 'OutputNode') as any + vi.mocked(isImageNode).mockReturnValue(false) + vi.mocked(filterOutputNodes).mockImplementation((nodes) => + nodes.filter((n) => n.type === 'OutputNode') ) }) @@ -189,10 +100,10 @@ describe('useSelectionState', () => { }) test('should return true when items selected', () => { - // Update the mock data before creating the composable + const canvasStore = useCanvasStore() const node1 = createTestNode() const node2 = createTestNode() - mockSelectedItems.value = [node1, node2] + canvasStore.selectedItems.push(node1, node2) const { hasAnySelection } = useSelectionState() expect(hasAnySelection.value).toBe(true) @@ -201,9 +112,9 @@ describe('useSelectionState', () => { describe('Node Type Filtering', () => { test('should pick only LGraphNodes from mixed selections', () => { - // Update the mock data before creating the composable + const canvasStore = useCanvasStore() const graphNode = createTestNode() - mockSelectedItems.value = [graphNode, mockComment, mockConnection] + canvasStore.selectedItems.push(graphNode, mockComment, mockConnection) const { selectedNodes } = useSelectionState() expect(selectedNodes.value).toHaveLength(1) @@ -213,9 +124,9 @@ describe('useSelectionState', () => { describe('Node State Computation', () => { test('should detect bypassed nodes', () => { - // Update the mock data before creating the composable + const canvasStore = useCanvasStore() const bypassedNode = createTestNode({ mode: LGraphEventMode.BYPASS }) - mockSelectedItems.value = [bypassedNode] + canvasStore.selectedItems.push(bypassedNode) const { selectedNodes } = useSelectionState() const isBypassed = selectedNodes.value.some( @@ -225,10 +136,10 @@ describe('useSelectionState', () => { }) test('should detect pinned/collapsed states', () => { - // Update the mock data before creating the composable + const canvasStore = useCanvasStore() const pinnedNode = createTestNode({ pinned: true }) const collapsedNode = createTestNode({ flags: { collapsed: true } }) - mockSelectedItems.value = [pinnedNode, collapsedNode] + canvasStore.selectedItems.push(pinnedNode, collapsedNode) const { selectedNodes } = useSelectionState() const isPinned = selectedNodes.value.some((n) => n.pinned === true) @@ -244,9 +155,9 @@ describe('useSelectionState', () => { }) test('should provide non-reactive state computation', () => { - // Update the mock data before creating the composable + const canvasStore = useCanvasStore() const node = createTestNode({ pinned: true }) - mockSelectedItems.value = [node] + canvasStore.selectedItems.push(node) const { selectedNodes } = useSelectionState() const isPinned = selectedNodes.value.some((n) => n.pinned === true) @@ -261,8 +172,7 @@ describe('useSelectionState', () => { expect(isCollapsed).toBe(false) expect(isBypassed).toBe(false) - // Test with empty selection using new composable instance - mockSelectedItems.value = [] + canvasStore.selectedItems.length = 0 const { selectedNodes: newSelectedNodes } = useSelectionState() const newIsPinned = newSelectedNodes.value.some((n) => n.pinned === true) expect(newIsPinned).toBe(false) diff --git a/src/composables/maskeditor/useCanvasHistory.test.ts b/src/composables/maskeditor/useCanvasHistory.test.ts index 6281baf3964..bf81196fa88 100644 --- a/src/composables/maskeditor/useCanvasHistory.test.ts +++ b/src/composables/maskeditor/useCanvasHistory.test.ts @@ -67,7 +67,10 @@ vi.mock('@/stores/maskEditorStore', () => ({ // Mock ImageBitmap using safe global augmentation pattern if (typeof globalThis.ImageBitmap === 'undefined') { - globalThis.ImageBitmap = class ImageBitmap { + class MockImageBitmap implements Pick< + ImageBitmap, + 'width' | 'height' | 'close' + > { width: number height: number constructor(width = 100, height = 100) { @@ -75,7 +78,8 @@ if (typeof globalThis.ImageBitmap === 'undefined') { this.height = height } close() {} - } as unknown as typeof globalThis.ImageBitmap + } + Object.defineProperty(globalThis, 'ImageBitmap', { value: MockImageBitmap }) } describe('useCanvasHistory', () => { diff --git a/src/composables/maskeditor/useCanvasManager.test.ts b/src/composables/maskeditor/useCanvasManager.test.ts index 4fe40df6e99..ebb3c746620 100644 --- a/src/composables/maskeditor/useCanvasManager.test.ts +++ b/src/composables/maskeditor/useCanvasManager.test.ts @@ -1,20 +1,112 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { MaskBlendMode } from '@/extensions/core/maskeditor/types' import { useCanvasManager } from '@/composables/maskeditor/useCanvasManager' -const mockStore = { - imgCanvas: null as any, - maskCanvas: null as any, - rgbCanvas: null as any, - imgCtx: null as any, - maskCtx: null as any, - rgbCtx: null as any, - canvasBackground: null as any, - maskColor: { r: 0, g: 0, b: 0 }, - maskBlendMode: MaskBlendMode.Black, - maskOpacity: 0.8 +import { MaskBlendMode } from '@/extensions/core/maskeditor/types' + +interface MockCanvasStyle { + mixBlendMode: string + opacity: string + backgroundColor: string +} + +interface MockCanvas { + width: number + height: number + style: Partial +} + +interface MockContext { + drawImage: ReturnType + getImageData?: ReturnType + putImageData?: ReturnType + globalCompositeOperation?: string + fillStyle?: string +} + +interface MockStore { + imgCanvas: MockCanvas | null + maskCanvas: MockCanvas | null + rgbCanvas: MockCanvas | null + imgCtx: MockContext | null + maskCtx: MockContext | null + rgbCtx: MockContext | null + canvasBackground: { style: Partial } | null + maskColor: { r: number; g: number; b: number } + maskBlendMode: MaskBlendMode + maskOpacity: number } +const { + mockStore, + getImgCanvas, + getMaskCanvas, + getRgbCanvas, + getImgCtx, + getMaskCtx, + getRgbCtx, + getCanvasBackground +} = vi.hoisted(() => { + const mockStore: MockStore = { + imgCanvas: null, + maskCanvas: null, + rgbCanvas: null, + imgCtx: null, + maskCtx: null, + rgbCtx: null, + canvasBackground: null, + maskColor: { r: 0, g: 0, b: 0 }, + maskBlendMode: 'black' as MaskBlendMode, + maskOpacity: 0.8 + } + + function getImgCanvas(): MockCanvas { + if (!mockStore.imgCanvas) throw new Error('imgCanvas not initialized') + return mockStore.imgCanvas + } + + function getMaskCanvas(): MockCanvas { + if (!mockStore.maskCanvas) throw new Error('maskCanvas not initialized') + return mockStore.maskCanvas + } + + function getRgbCanvas(): MockCanvas { + if (!mockStore.rgbCanvas) throw new Error('rgbCanvas not initialized') + return mockStore.rgbCanvas + } + + function getImgCtx(): MockContext { + if (!mockStore.imgCtx) throw new Error('imgCtx not initialized') + return mockStore.imgCtx + } + + function getMaskCtx(): MockContext { + if (!mockStore.maskCtx) throw new Error('maskCtx not initialized') + return mockStore.maskCtx + } + + function getRgbCtx(): MockContext { + if (!mockStore.rgbCtx) throw new Error('rgbCtx not initialized') + return mockStore.rgbCtx + } + + function getCanvasBackground(): { style: Partial } { + if (!mockStore.canvasBackground) + throw new Error('canvasBackground not initialized') + return mockStore.canvasBackground + } + + return { + mockStore, + getImgCanvas, + getMaskCanvas, + getRgbCanvas, + getImgCtx, + getMaskCtx, + getRgbCtx, + getCanvasBackground + } +}) + vi.mock('@/stores/maskEditorStore', () => ({ useMaskEditorStore: vi.fn(() => mockStore) })) @@ -56,7 +148,8 @@ describe('useCanvasManager', () => { mockStore.imgCanvas = { width: 0, - height: 0 + height: 0, + style: {} } mockStore.maskCanvas = { @@ -70,7 +163,8 @@ describe('useCanvasManager', () => { mockStore.rgbCanvas = { width: 0, - height: 0 + height: 0, + style: {} } mockStore.canvasBackground = { @@ -93,12 +187,12 @@ describe('useCanvasManager', () => { await manager.invalidateCanvas(origImage, maskImage, null) - expect(mockStore.imgCanvas.width).toBe(512) - expect(mockStore.imgCanvas.height).toBe(512) - expect(mockStore.maskCanvas.width).toBe(512) - expect(mockStore.maskCanvas.height).toBe(512) - expect(mockStore.rgbCanvas.width).toBe(512) - expect(mockStore.rgbCanvas.height).toBe(512) + expect(getImgCanvas().width).toBe(512) + expect(getImgCanvas().height).toBe(512) + expect(getMaskCanvas().width).toBe(512) + expect(getMaskCanvas().height).toBe(512) + expect(getRgbCanvas().width).toBe(512) + expect(getRgbCanvas().height).toBe(512) }) it('should draw original image', async () => { @@ -109,7 +203,7 @@ describe('useCanvasManager', () => { await manager.invalidateCanvas(origImage, maskImage, null) - expect(mockStore.imgCtx.drawImage).toHaveBeenCalledWith( + expect(getImgCtx().drawImage).toHaveBeenCalledWith( origImage, 0, 0, @@ -127,7 +221,7 @@ describe('useCanvasManager', () => { await manager.invalidateCanvas(origImage, maskImage, paintImage) - expect(mockStore.rgbCtx.drawImage).toHaveBeenCalledWith( + expect(getRgbCtx().drawImage).toHaveBeenCalledWith( paintImage, 0, 0, @@ -144,7 +238,7 @@ describe('useCanvasManager', () => { await manager.invalidateCanvas(origImage, maskImage, null) - expect(mockStore.rgbCtx.drawImage).not.toHaveBeenCalled() + expect(getRgbCtx().drawImage).not.toHaveBeenCalled() }) it('should prepare mask', async () => { @@ -155,9 +249,9 @@ describe('useCanvasManager', () => { await manager.invalidateCanvas(origImage, maskImage, null) - expect(mockStore.maskCtx.drawImage).toHaveBeenCalled() - expect(mockStore.maskCtx.getImageData).toHaveBeenCalled() - expect(mockStore.maskCtx.putImageData).toHaveBeenCalled() + expect(getMaskCtx().drawImage).toHaveBeenCalled() + expect(getMaskCtx().getImageData).toHaveBeenCalled() + expect(getMaskCtx().putImageData).toHaveBeenCalled() }) it('should throw error when canvas missing', async () => { @@ -196,12 +290,10 @@ describe('useCanvasManager', () => { await manager.updateMaskColor() - expect(mockStore.maskCtx.fillStyle).toBe('rgb(0, 0, 0)') - expect(mockStore.maskCanvas.style.mixBlendMode).toBe('initial') - expect(mockStore.maskCanvas.style.opacity).toBe('0.8') - expect(mockStore.canvasBackground.style.backgroundColor).toBe( - 'rgba(0,0,0,1)' - ) + expect(getMaskCtx().fillStyle).toBe('rgb(0, 0, 0)') + expect(getMaskCanvas().style.mixBlendMode).toBe('initial') + expect(getMaskCanvas().style.opacity).toBe('0.8') + expect(getCanvasBackground().style.backgroundColor).toBe('rgba(0,0,0,1)') }) it('should update mask color for white blend mode', async () => { @@ -212,9 +304,9 @@ describe('useCanvasManager', () => { await manager.updateMaskColor() - expect(mockStore.maskCtx.fillStyle).toBe('rgb(255, 255, 255)') - expect(mockStore.maskCanvas.style.mixBlendMode).toBe('initial') - expect(mockStore.canvasBackground.style.backgroundColor).toBe( + expect(getMaskCtx().fillStyle).toBe('rgb(255, 255, 255)') + expect(getMaskCanvas().style.mixBlendMode).toBe('initial') + expect(getCanvasBackground().style.backgroundColor).toBe( 'rgba(255,255,255,1)' ) }) @@ -227,9 +319,9 @@ describe('useCanvasManager', () => { await manager.updateMaskColor() - expect(mockStore.maskCanvas.style.mixBlendMode).toBe('difference') - expect(mockStore.maskCanvas.style.opacity).toBe('1') - expect(mockStore.canvasBackground.style.backgroundColor).toBe( + expect(getMaskCanvas().style.mixBlendMode).toBe('difference') + expect(getMaskCanvas().style.opacity).toBe('1') + expect(getCanvasBackground().style.backgroundColor).toBe( 'rgba(255,255,255,1)' ) }) @@ -238,8 +330,8 @@ describe('useCanvasManager', () => { const manager = useCanvasManager() mockStore.maskColor = { r: 128, g: 64, b: 32 } - mockStore.maskCanvas.width = 100 - mockStore.maskCanvas.height = 100 + getMaskCanvas().width = 100 + getMaskCanvas().height = 100 await manager.updateMaskColor() @@ -249,7 +341,7 @@ describe('useCanvasManager', () => { expect(mockImageData.data[i + 2]).toBe(32) } - expect(mockStore.maskCtx.putImageData).toHaveBeenCalledWith( + expect(getMaskCtx().putImageData).toHaveBeenCalledWith( mockImageData, 0, 0 @@ -258,22 +350,24 @@ describe('useCanvasManager', () => { it('should return early when canvas missing', async () => { const manager = useCanvasManager() + const maskCtxBeforeNull = getMaskCtx() mockStore.maskCanvas = null await manager.updateMaskColor() - expect(mockStore.maskCtx.getImageData).not.toHaveBeenCalled() + expect(maskCtxBeforeNull.getImageData).not.toHaveBeenCalled() }) it('should return early when context missing', async () => { const manager = useCanvasManager() + const canvasBgBeforeNull = getCanvasBackground() mockStore.maskCtx = null await manager.updateMaskColor() - expect(mockStore.canvasBackground.style.backgroundColor).toBe('') + expect(canvasBgBeforeNull.style.backgroundColor).toBe('') }) it('should handle different opacity values', async () => { @@ -283,7 +377,7 @@ describe('useCanvasManager', () => { await manager.updateMaskColor() - expect(mockStore.maskCanvas.style.opacity).toBe('0.5') + expect(getMaskCanvas().style.opacity).toBe('0.5') }) }) @@ -330,7 +424,7 @@ describe('useCanvasManager', () => { await manager.invalidateCanvas(origImage, maskImage, null) - expect(mockStore.maskCtx.globalCompositeOperation).toBe('source-over') + expect(getMaskCtx().globalCompositeOperation).toBe('source-over') }) }) }) diff --git a/src/composables/maskeditor/useCanvasTransform.test.ts b/src/composables/maskeditor/useCanvasTransform.test.ts index 95c153d29c6..aaee9e2e27b 100644 --- a/src/composables/maskeditor/useCanvasTransform.test.ts +++ b/src/composables/maskeditor/useCanvasTransform.test.ts @@ -63,7 +63,7 @@ vi.mock('@/stores/maskEditorStore', () => ({ // Mock ImageData with improved type safety if (typeof globalThis.ImageData === 'undefined') { - globalThis.ImageData = class ImageData { + class MockImageData { data: Uint8ClampedArray width: number height: number @@ -95,12 +95,16 @@ if (typeof globalThis.ImageData === 'undefined') { this.data = new Uint8ClampedArray(dataOrWidth * widthOrHeight * 4) } } - } as unknown as typeof globalThis.ImageData + } + Object.defineProperty(globalThis, 'ImageData', { value: MockImageData }) } // Mock ImageBitmap for test environment using safe type casting if (typeof globalThis.ImageBitmap === 'undefined') { - globalThis.ImageBitmap = class ImageBitmap { + class MockImageBitmap implements Pick< + ImageBitmap, + 'width' | 'height' | 'close' + > { width: number height: number constructor(width = 100, height = 100) { @@ -108,7 +112,8 @@ if (typeof globalThis.ImageBitmap === 'undefined') { this.height = height } close() {} - } as unknown as typeof globalThis.ImageBitmap + } + Object.defineProperty(globalThis, 'ImageBitmap', { value: MockImageBitmap }) } describe('useCanvasTransform', () => { diff --git a/src/composables/useGlobalLitegraph.ts b/src/composables/useGlobalLitegraph.ts index 107f8e28928..21c0b38cb21 100644 --- a/src/composables/useGlobalLitegraph.ts +++ b/src/composables/useGlobalLitegraph.ts @@ -6,30 +6,21 @@ import { LGraphCanvas, LGraphGroup, LGraphNode, - LLink, - LiteGraph + LiteGraph, + LLink } from '@/lib/litegraph/src/litegraph' /** * Assign all properties of LiteGraph to window to make it backward compatible. */ -export const useGlobalLitegraph = () => { - // @ts-expect-error fixme ts strict error - window['LiteGraph'] = LiteGraph - // @ts-expect-error fixme ts strict error - window['LGraph'] = LGraph - // @ts-expect-error fixme ts strict error - window['LLink'] = LLink - // @ts-expect-error fixme ts strict error - window['LGraphNode'] = LGraphNode - // @ts-expect-error fixme ts strict error - window['LGraphGroup'] = LGraphGroup - // @ts-expect-error fixme ts strict error - window['DragAndScale'] = DragAndScale - // @ts-expect-error fixme ts strict error - window['LGraphCanvas'] = LGraphCanvas - // @ts-expect-error fixme ts strict error - window['ContextMenu'] = ContextMenu - // @ts-expect-error fixme ts strict error - window['LGraphBadge'] = LGraphBadge +export function useGlobalLitegraph() { + window.LiteGraph = LiteGraph + window.LGraph = LGraph + window.LLink = LLink + window.LGraphNode = LGraphNode + window.LGraphGroup = LGraphGroup + window.DragAndScale = DragAndScale + window.LGraphCanvas = LGraphCanvas + window.ContextMenu = ContextMenu + window.LGraphBadge = LGraphBadge } diff --git a/src/composables/usePaste.test.ts b/src/composables/usePaste.test.ts index 4e1ac3503c5..d48e9e114de 100644 --- a/src/composables/usePaste.test.ts +++ b/src/composables/usePaste.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { LGraphCanvas, - LGraph, LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph' @@ -10,11 +9,19 @@ import { app } from '@/scripts/app' import { isImageNode } from '@/utils/litegraphUtil' import { pasteImageNode, usePaste } from './usePaste' -function createMockNode() { +interface MockPasteNode { + pos: [number, number] + pasteFile: (file: File) => void + pasteFiles: (files: File[]) => void + is_selected?: boolean +} + +function createMockNode(options?: Partial): MockPasteNode { return { pos: [0, 0], - pasteFile: vi.fn(), - pasteFiles: vi.fn() + pasteFile: vi.fn<(file: File) => void>(), + pasteFiles: vi.fn<(files: File[]) => void>(), + ...options } } @@ -38,16 +45,31 @@ function createDataTransfer(files: File[] = []): DataTransfer { return dataTransfer } -const mockCanvas = { - current_node: null as LGraphNode | null, - graph: { - add: vi.fn(), - change: vi.fn() - } as Partial as LGraph, +interface MockGraph { + add: ReturnType + change: ReturnType +} + +interface MockCanvas { + current_node: LGraphNode | null + graph: MockGraph + graph_mouse: [number, number] + pasteFromClipboard: ReturnType + _deserializeItems: ReturnType +} + +const mockGraph: MockGraph = { + add: vi.fn(), + change: vi.fn() +} + +const mockCanvas: MockCanvas = { + current_node: null, + graph: mockGraph, graph_mouse: [100, 200], pasteFromClipboard: vi.fn(), _deserializeItems: vi.fn() -} as Partial as LGraphCanvas +} const mockCanvasStore = { canvas: mockCanvas, @@ -81,7 +103,7 @@ vi.mock('@/scripts/app', () => ({ vi.mock('@/lib/litegraph/src/litegraph', () => ({ LiteGraph: { - createNode: vi.fn() + createNode: vi.fn<(type: string) => LGraphNode | undefined>() } })) @@ -95,30 +117,38 @@ vi.mock('@/workbench/eventHelpers', () => ({ shouldIgnoreCopyPaste: vi.fn() })) +function asLGraphCanvas(canvas: MockCanvas): LGraphCanvas { + return Object.assign(Object.create(null), canvas) +} + +function asLGraphNode(node: MockPasteNode): LGraphNode { + return Object.assign(Object.create(null), node) +} + describe('pasteImageNode', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(mockCanvas.graph!.add).mockImplementation( - (node: LGraphNode | LGraphGroup) => node as LGraphNode - ) + mockGraph.add.mockImplementation((node: LGraphNode | LGraphGroup) => node) }) it('should create new LoadImage node when no image node provided', () => { const mockNode = createMockNode() - vi.mocked(LiteGraph.createNode).mockReturnValue( - mockNode as unknown as LGraphNode - ) + const createdNode = asLGraphNode(mockNode) + vi.mocked(LiteGraph.createNode).mockReturnValue(createdNode) const file = createImageFile() const dataTransfer = createDataTransfer([file]) - pasteImageNode(mockCanvas as unknown as LGraphCanvas, dataTransfer.items) + pasteImageNode(asLGraphCanvas(mockCanvas), dataTransfer.items) expect(LiteGraph.createNode).toHaveBeenCalledWith('LoadImage') - expect(mockNode.pos).toEqual([100, 200]) - expect(mockCanvas.graph!.add).toHaveBeenCalledWith(mockNode) - expect(mockCanvas.graph!.change).toHaveBeenCalled() - expect(mockNode.pasteFile).toHaveBeenCalledWith(file) + // Verify pos was set on the created node (not on mockNode since Object.assign copies) + expect(createdNode.pos).toEqual([100, 200]) + expect(mockGraph.add).toHaveBeenCalled() + expect(mockGraph.change).toHaveBeenCalled() + // pasteFile was called on the node returned by graph.add + const addedNode = mockGraph.add.mock.results[0].value + expect(addedNode.pasteFile).toHaveBeenCalledWith(file) }) it('should use existing image node when provided', () => { @@ -126,11 +156,7 @@ describe('pasteImageNode', () => { const file = createImageFile() const dataTransfer = createDataTransfer([file]) - pasteImageNode( - mockCanvas as unknown as LGraphCanvas, - dataTransfer.items, - mockNode as unknown as LGraphNode - ) + pasteImageNode(asLGraphCanvas(mockCanvas), dataTransfer.items, mockNode) expect(mockNode.pasteFile).toHaveBeenCalledWith(file) expect(mockNode.pasteFiles).toHaveBeenCalledWith([file]) @@ -142,11 +168,7 @@ describe('pasteImageNode', () => { const file2 = createImageFile('test2.jpg', 'image/jpeg') const dataTransfer = createDataTransfer([file1, file2]) - pasteImageNode( - mockCanvas as unknown as LGraphCanvas, - dataTransfer.items, - mockNode as unknown as LGraphNode - ) + pasteImageNode(asLGraphCanvas(mockCanvas), dataTransfer.items, mockNode) expect(mockNode.pasteFile).toHaveBeenCalledWith(file1) expect(mockNode.pasteFiles).toHaveBeenCalledWith([file1, file2]) @@ -156,11 +178,7 @@ describe('pasteImageNode', () => { const mockNode = createMockNode() const dataTransfer = createDataTransfer() - pasteImageNode( - mockCanvas as unknown as LGraphCanvas, - dataTransfer.items, - mockNode as unknown as LGraphNode - ) + pasteImageNode(asLGraphCanvas(mockCanvas), dataTransfer.items, mockNode) expect(mockNode.pasteFile).not.toHaveBeenCalled() expect(mockNode.pasteFiles).not.toHaveBeenCalled() @@ -172,11 +190,7 @@ describe('pasteImageNode', () => { const textFile = new File([''], 'test.txt', { type: 'text/plain' }) const dataTransfer = createDataTransfer([textFile, imageFile]) - pasteImageNode( - mockCanvas as unknown as LGraphCanvas, - dataTransfer.items, - mockNode as unknown as LGraphNode - ) + pasteImageNode(asLGraphCanvas(mockCanvas), dataTransfer.items, mockNode) expect(mockNode.pasteFile).toHaveBeenCalledWith(imageFile) expect(mockNode.pasteFiles).toHaveBeenCalledWith([imageFile]) @@ -188,16 +202,12 @@ describe('usePaste', () => { vi.clearAllMocks() mockCanvas.current_node = null mockWorkspaceStore.shiftDown = false - vi.mocked(mockCanvas.graph!.add).mockImplementation( - (node: LGraphNode | LGraphGroup) => node as LGraphNode - ) + mockGraph.add.mockImplementation((node: LGraphNode | LGraphGroup) => node) }) it('should handle image paste', async () => { const mockNode = createMockNode() - vi.mocked(LiteGraph.createNode).mockReturnValue( - mockNode as unknown as LGraphNode - ) + vi.mocked(LiteGraph.createNode).mockReturnValue(asLGraphNode(mockNode)) usePaste() @@ -214,9 +224,7 @@ describe('usePaste', () => { it('should handle audio paste', async () => { const mockNode = createMockNode() - vi.mocked(LiteGraph.createNode).mockReturnValue( - mockNode as unknown as LGraphNode - ) + vi.mocked(LiteGraph.createNode).mockReturnValue(asLGraphNode(mockNode)) usePaste() @@ -261,12 +269,8 @@ describe('usePaste', () => { }) it('should use existing image node when selected', () => { - const mockNode = { - is_selected: true, - pasteFile: vi.fn(), - pasteFiles: vi.fn() - } as unknown as Partial as LGraphNode - mockCanvas.current_node = mockNode + const mockNode = createMockNode({ is_selected: true }) + mockCanvas.current_node = asLGraphNode(mockNode) vi.mocked(isImageNode).mockReturnValue(true) usePaste() diff --git a/src/composables/usePaste.ts b/src/composables/usePaste.ts index 1809eb838d7..5a0620275fb 100644 --- a/src/composables/usePaste.ts +++ b/src/composables/usePaste.ts @@ -9,6 +9,12 @@ import { useWorkspaceStore } from '@/stores/workspaceStore' import { isAudioNode, isImageNode, isVideoNode } from '@/utils/litegraphUtil' import { shouldIgnoreCopyPaste } from '@/workbench/eventHelpers' +/** A node that supports pasting files */ +interface PasteableNode { + pasteFile?(file: File): void + pasteFiles?(files: File[]): void +} + function pasteClipboardItems(data: DataTransfer): boolean { const rawData = data.getData('text/html') const match = rawData.match(/data-metadata="([A-Za-z0-9+/=]+)"/)?.[1] @@ -28,7 +34,7 @@ function pasteClipboardItems(data: DataTransfer): boolean { function pasteItemsOnNode( items: DataTransferItemList, - node: LGraphNode | null, + node: PasteableNode | null, contentType: string ): void { if (!node) return @@ -51,7 +57,7 @@ function pasteItemsOnNode( export function pasteImageNode( canvas: LGraphCanvas, items: DataTransferItemList, - imageNode: LGraphNode | null = null + imageNode: PasteableNode | null = null ): void { const { graph, diff --git a/src/extensions/core/contextMenuFilter.ts b/src/extensions/core/contextMenuFilter.ts index 5cb0e98c585..5b1cd5a1264 100644 --- a/src/extensions/core/contextMenuFilter.ts +++ b/src/extensions/core/contextMenuFilter.ts @@ -1,179 +1,188 @@ import { + ContextMenu, LGraphCanvas, LiteGraph, isComboWidget } from '@/lib/litegraph/src/litegraph' +import type { + IContextMenuOptions, + IContextMenuValue +} from '@/lib/litegraph/src/litegraph' import { app } from '../../scripts/app' // Adds filtering to combo context menus -const ext = { - name: 'Comfy.ContextMenuFilter', - init() { - const ctxMenu = LiteGraph.ContextMenu - - // @ts-expect-error TODO Very hacky way to modify Litegraph behaviour. Fix ctx later. - LiteGraph.ContextMenu = function (values, options) { - const ctx = new ctxMenu(values, options) - - // If we are a dark menu (only used for combo boxes) then add a filter input - if (options?.className === 'dark' && values?.length > 4) { - const filter = document.createElement('input') - filter.classList.add('comfy-context-menu-filter') - filter.placeholder = 'Filter list' - - ctx.root.prepend(filter) - - const items = Array.from( - ctx.root.querySelectorAll('.litemenu-entry') - ) as HTMLElement[] - let displayedItems = [...items] - let itemCount = displayedItems.length - - // We must request an animation frame for the current node of the active canvas to update. - requestAnimationFrame(() => { - const currentNode = LGraphCanvas.active_canvas.current_node - const clickedComboValue = currentNode?.widgets - ?.filter( - (w) => - isComboWidget(w) && w.options.values?.length === values.length - ) - .find((w) => - // @ts-expect-error Poorly typed; filter above "should" mitigate exceptions - w.options.values?.every((v, i) => v === values[i]) - )?.value - - let selectedIndex = clickedComboValue - ? values.findIndex((v: string) => v === clickedComboValue) - : 0 - if (selectedIndex < 0) { - selectedIndex = 0 - } - let selectedItem = displayedItems[selectedIndex] - updateSelected() - - // Apply highlighting to the selected item - function updateSelected() { - selectedItem?.style.setProperty('background-color', '') - selectedItem?.style.setProperty('color', '') - selectedItem = displayedItems[selectedIndex] - selectedItem?.style.setProperty( - 'background-color', - '#ccc', - 'important' +class FilteredContextMenu extends ContextMenu { + constructor( + values: readonly (string | IContextMenuValue | null)[], + options: IContextMenuOptions + ) { + super(values, options) + + // If we are a dark menu (only used for combo boxes) then add a filter input + if (options?.className === 'dark' && values?.length > 4) { + const filter = document.createElement('input') + filter.classList.add('comfy-context-menu-filter') + filter.placeholder = 'Filter list' + + this.root.prepend(filter) + + const items = Array.from( + this.root.querySelectorAll('.litemenu-entry') + ) + let displayedItems = [...items] + let itemCount = displayedItems.length + + // We must request an animation frame for the current node of the active canvas to update. + requestAnimationFrame(() => { + const activeCanvas = LGraphCanvas.active_canvas + if (!activeCanvas) return + + const currentNode = activeCanvas.current_node + const clickedComboValue = currentNode?.widgets + ?.filter( + (w) => + isComboWidget(w) && w.options.values?.length === values.length + ) + .find((w) => { + const widgetValues = w.options.values + return ( + Array.isArray(widgetValues) && + widgetValues.every((v, i) => v === values[i]) ) - selectedItem?.style.setProperty('color', '#000', 'important') + })?.value + + let selectedIndex = clickedComboValue + ? values.findIndex((v) => v === clickedComboValue) + : 0 + if (selectedIndex < 0) { + selectedIndex = 0 + } + let selectedItem = displayedItems[selectedIndex] + updateSelected() + + // Apply highlighting to the selected item + function updateSelected() { + selectedItem?.style.setProperty('background-color', '') + selectedItem?.style.setProperty('color', '') + selectedItem = displayedItems[selectedIndex] + selectedItem?.style.setProperty( + 'background-color', + '#ccc', + 'important' + ) + selectedItem?.style.setProperty('color', '#000', 'important') + } + + const positionList = () => { + const rect = this.root.getBoundingClientRect() + + // If the top is off-screen then shift the element with scaling applied + if (rect.top < 0) { + const scale = + 1 - + this.root.getBoundingClientRect().height / this.root.clientHeight + + const shift = (this.root.clientHeight * scale) / 2 + + this.root.style.top = -shift + 'px' } - - const positionList = () => { - const rect = ctx.root.getBoundingClientRect() - - // If the top is off-screen then shift the element with scaling applied - if (rect.top < 0) { - const scale = - 1 - - ctx.root.getBoundingClientRect().height / ctx.root.clientHeight - - const shift = (ctx.root.clientHeight * scale) / 2 - - ctx.root.style.top = -shift + 'px' - } - } - - // Arrow up/down to select items - filter.addEventListener('keydown', (event) => { - switch (event.key) { - case 'ArrowUp': - event.preventDefault() - if (selectedIndex === 0) { - selectedIndex = itemCount - 1 - } else { - selectedIndex-- - } - updateSelected() - break - case 'ArrowRight': - event.preventDefault() + } + + // Arrow up/down to select items + filter.addEventListener('keydown', (event) => { + switch (event.key) { + case 'ArrowUp': + event.preventDefault() + if (selectedIndex === 0) { selectedIndex = itemCount - 1 - updateSelected() - break - case 'ArrowDown': - event.preventDefault() - if (selectedIndex === itemCount - 1) { - selectedIndex = 0 - } else { - selectedIndex++ - } - updateSelected() - break - case 'ArrowLeft': - event.preventDefault() + } else { + selectedIndex-- + } + updateSelected() + break + case 'ArrowRight': + event.preventDefault() + selectedIndex = itemCount - 1 + updateSelected() + break + case 'ArrowDown': + event.preventDefault() + if (selectedIndex === itemCount - 1) { selectedIndex = 0 - updateSelected() - break - case 'Enter': - selectedItem?.click() - break - case 'Escape': - ctx.close() - break - } - }) + } else { + selectedIndex++ + } + updateSelected() + break + case 'ArrowLeft': + event.preventDefault() + selectedIndex = 0 + updateSelected() + break + case 'Enter': + selectedItem?.click() + break + case 'Escape': + this.close() + break + } + }) - filter.addEventListener('input', () => { - // Hide all items that don't match our filter - const term = filter.value.toLocaleLowerCase() - // When filtering, recompute which items are visible for arrow up/down and maintain selection. - displayedItems = items.filter((item) => { - const isVisible = - !term || item.textContent?.toLocaleLowerCase().includes(term) - item.style.display = isVisible ? 'block' : 'none' - return isVisible - }) - - selectedIndex = 0 - if (displayedItems.includes(selectedItem)) { - selectedIndex = displayedItems.findIndex( - (d) => d === selectedItem - ) - } - itemCount = displayedItems.length + filter.addEventListener('input', () => { + // Hide all items that don't match our filter + const term = filter.value.toLocaleLowerCase() + // When filtering, recompute which items are visible for arrow up/down and maintain selection. + displayedItems = items.filter((item) => { + const isVisible = + !term || item.textContent?.toLocaleLowerCase().includes(term) + item.style.display = isVisible ? 'block' : 'none' + return isVisible + }) - updateSelected() + selectedIndex = 0 + if (displayedItems.includes(selectedItem)) { + selectedIndex = displayedItems.findIndex((d) => d === selectedItem) + } + itemCount = displayedItems.length - // If we have an event then we can try and position the list under the source - if (options.event) { - let top = options.event.clientY - 10 + updateSelected() - const bodyRect = document.body.getBoundingClientRect() + // If we have an event then we can try and position the list under the source + if (options.event) { + let top = options.event.clientY - 10 - const rootRect = ctx.root.getBoundingClientRect() - if ( - bodyRect.height && - top > bodyRect.height - rootRect.height - 10 - ) { - top = Math.max(0, bodyRect.height - rootRect.height - 10) - } + const bodyRect = document.body.getBoundingClientRect() - ctx.root.style.top = top + 'px' - positionList() + const rootRect = this.root.getBoundingClientRect() + if ( + bodyRect.height && + top > bodyRect.height - rootRect.height - 10 + ) { + top = Math.max(0, bodyRect.height - rootRect.height - 10) } - }) - - requestAnimationFrame(() => { - // Focus the filter box when opening - filter.focus() + this.root.style.top = top + 'px' positionList() - }) + } }) - } - return ctx + requestAnimationFrame(() => { + // Focus the filter box when opening + filter.focus() + + positionList() + }) + }) } + } +} - LiteGraph.ContextMenu.prototype = ctxMenu.prototype +const ext = { + name: 'Comfy.ContextMenuFilter', + init() { + LiteGraph.ContextMenu = FilteredContextMenu } } diff --git a/src/extensions/core/editAttention.ts b/src/extensions/core/editAttention.ts index 119b2775874..07ca38d649d 100644 --- a/src/extensions/core/editAttention.ts +++ b/src/extensions/core/editAttention.ts @@ -78,11 +78,12 @@ app.registerExtension({ } function editAttention(event: KeyboardEvent) { - // @ts-expect-error Runtime narrowing not impl. - const inputField: HTMLTextAreaElement = event.composedPath()[0] - const delta = parseFloat(editAttentionDelta.value) + const composedPath = event.composedPath() + const target = composedPath[0] + if (!(target instanceof HTMLTextAreaElement)) return - if (inputField.tagName !== 'TEXTAREA') return + const inputField = target + const delta = parseFloat(editAttentionDelta.value) if (!(event.key === 'ArrowUp' || event.key === 'ArrowDown')) return if (!event.ctrlKey && !event.metaKey) return diff --git a/src/extensions/core/groupNode.ts b/src/extensions/core/groupNode.ts index a7af7361a30..61b9c34ba90 100644 --- a/src/extensions/core/groupNode.ts +++ b/src/extensions/core/groupNode.ts @@ -1,12 +1,20 @@ import { PREFIX, SEPARATOR } from '@/constants/groupNodeConstants' import { t } from '@/i18n' import type { GroupNodeWorkflowData } from '@/lib/litegraph/src/LGraph' -import type { SerialisedLLinkArray } from '@/lib/litegraph/src/LLink' +import type { + GroupNodeInputConfig, + GroupNodeInputsSpec, + GroupNodeInternalLink, + GroupNodeOutputType, + PartialLinkInfo +} from './groupNodeTypes' +import { LLink } from '@/lib/litegraph/src/LLink' import type { NodeId } from '@/lib/litegraph/src/LGraphNode' import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces' import { type ExecutableLGraphNode, type ExecutionId, + type ISerialisedNode, LGraphNode, type LGraphNodeConstructor, LiteGraph, @@ -32,7 +40,7 @@ import { app } from '../../scripts/app' import { ManageGroupDialog } from './groupNodeManage' import { mergeIfValid } from './widgetInputs' -type GroupNodeLink = SerialisedLLinkArray +type GroupNodeLink = GroupNodeInternalLink type LinksFromMap = Record> type LinksToMap = Record> type ExternalFromMap = Record> @@ -54,9 +62,8 @@ interface GroupNodeOutput { interface GroupNodeData extends Omit< GroupNodeWorkflowData['nodes'][number], - 'inputs' | 'outputs' + 'inputs' | 'outputs' | 'widgets_values' > { - title?: string widgets_values?: unknown[] inputs?: GroupNodeInput[] outputs?: GroupNodeOutput[] @@ -241,7 +248,13 @@ export class GroupNodeConfig { > nodeInputs: Record> outputVisibility: boolean[] - nodeDef: (ComfyNodeDef & { [GROUP]: GroupNodeConfig }) | undefined + nodeDef: + | (Omit & { + input: GroupNodeInputsSpec + output: GroupNodeOutputType[] + [GROUP]: GroupNodeConfig + }) + | undefined inputs!: unknown[] linksFrom!: LinksFromMap linksTo!: LinksToMap @@ -297,8 +310,11 @@ export class GroupNodeConfig { } this.#convertedToProcess = [] if (!this.nodeDef) return - await app.registerNodeDef(`${PREFIX}${SEPARATOR}` + this.name, this.nodeDef) - useNodeDefStore().addNodeDef(this.nodeDef) + const finalizedDef = this.nodeDef as ComfyNodeDef & { + [GROUP]: GroupNodeConfig + } + await app.registerNodeDef(`${PREFIX}${SEPARATOR}` + this.name, finalizedDef) + useNodeDefStore().addNodeDef(finalizedDef) } getLinks() { @@ -520,9 +536,13 @@ export class GroupNodeConfig { node: GroupNodeData, inputName: string, seenInputs: Record, - config: unknown[], + inputConfig: unknown[], extra?: Record - ) { + ): { + name: string + config: GroupNodeInputConfig + customConfig: { name?: string; visible?: boolean } | undefined + } { const nodeConfig = this.nodeData.config?.[node.index ?? -1] as | NodeConfigEntry | undefined @@ -543,28 +563,34 @@ export class GroupNodeConfig { } seenInputs[key] = (seenInputs[key] ?? 1) + 1 + const typeName = String(inputConfig[0]) + let options = + typeof inputConfig[1] === 'object' && inputConfig[1] !== null + ? (inputConfig[1] as Record) + : undefined + if (inputName === 'seed' || inputName === 'noise_seed') { if (!extra) extra = {} extra.control_after_generate = `${prefix}control_after_generate` } - if (config[0] === 'IMAGEUPLOAD') { + if (typeName === 'IMAGEUPLOAD') { if (!extra) extra = {} const nodeIndex = node.index ?? -1 - const configOptions = - typeof config[1] === 'object' && config[1] !== null ? config[1] : {} const widgetKey = - 'widget' in configOptions && typeof configOptions.widget === 'string' - ? configOptions.widget + options && 'widget' in options && typeof options.widget === 'string' + ? options.widget : 'image' extra.widget = this.oldToNewWidgetMap[nodeIndex]?.[widgetKey] ?? 'image' } if (extra) { - const configObj = - typeof config[1] === 'object' && config[1] ? config[1] : {} - config = [config[0], { ...configObj, ...extra }] + options = { ...(options ?? {}), ...extra } } + const config: GroupNodeInputConfig = options + ? [typeName, options] + : [typeName] + return { name, config, customConfig } } @@ -608,7 +634,6 @@ export class GroupNodeConfig { inputs[inputName] as unknown[] ) if (this.nodeDef?.input?.required) { - // @ts-expect-error legacy dynamic input assignment this.nodeDef.input.required[name] = config } widgetMap[inputName] = name @@ -641,14 +666,18 @@ export class GroupNodeConfig { unknown, Record ] - const output = { widget: primitiveConfig } + // Intentional casts: widget config types are dynamic at runtime and the + // compiler cannot infer the union shapes expected by mergeIfValid. + const output = { widget: primitiveConfig } as unknown as Parameters< + typeof mergeIfValid + >[0] + // Intentional casts for targetWidget and primitiveConfig — see mergeIfValid. const config = mergeIfValid( - // @ts-expect-error slot type mismatch - legacy API output, - targetWidget, + targetWidget as Parameters[1], false, undefined, - primitiveConfig + primitiveConfig as Parameters[4] ) const inputConfig = inputs[inputName]?.[1] primitiveConfig[1] = @@ -713,7 +742,6 @@ export class GroupNodeConfig { if (customConfig?.visible === false) continue if (this.nodeDef?.input?.required) { - // @ts-expect-error legacy dynamic input assignment this.nodeDef.input.required[name] = config } inputMap[i] = this.inputCount++ @@ -757,7 +785,6 @@ export class GroupNodeConfig { ) if (this.nodeDef?.input?.required) { - // @ts-expect-error legacy dynamic input assignment this.nodeDef.input.required[name] = config } this.newToOldWidgetMap[name] = { node, inputName } @@ -851,8 +878,7 @@ export class GroupNodeConfig { node, slot: outputId } - // @ts-expect-error legacy dynamic output type assignment - this.nodeDef.output.push(defOutput[outputId]) + this.nodeDef.output.push(defOutput[outputId] as GroupNodeOutputType) this.nodeDef.output_is_list?.push( def.output_is_list?.[outputId] ?? false ) @@ -951,8 +977,13 @@ export class GroupNodeHandler { for (const w of innerNode.widgets ?? []) { if (w.type === 'converted-widget') { - // @ts-expect-error legacy widget property for converted widgets - w.serializeValue = w.origSerializeValue + type SerializeValueFn = (node: LGraphNode, index: number) => unknown + const convertedWidget = w as typeof w & { + origSerializeValue?: SerializeValueFn + } + if (convertedWidget.origSerializeValue) { + w.serializeValue = convertedWidget.origSerializeValue + } } } @@ -978,20 +1009,18 @@ export class GroupNodeHandler { return inputNode } - // @ts-expect-error returns partial link object, not full LLink - innerNode.getInputLink = (slot: number) => { + innerNode.getInputLink = (slot: number): PartialLinkInfo | null => { const nodeIdx = innerNode.index ?? 0 const externalSlot = this.groupData.oldToNewInputMap[nodeIdx]?.[slot] if (externalSlot != null) { - // The inner node is connected via the group node inputs const linkId = this.node.inputs[externalSlot].link if (linkId == null) return null const existingLink = app.rootGraph.links[linkId] if (!existingLink) return null - // Use the outer link, but update the target to the inner node return { - ...existingLink, + origin_id: existingLink.origin_id, + origin_slot: existingLink.origin_slot, target_id: innerNode.id, target_slot: +slot } @@ -1000,11 +1029,11 @@ export class GroupNodeHandler { const innerLink = this.groupData.linksTo[nodeIdx]?.[slot] if (!innerLink) return null const linkSrcIdx = innerLink[0] - if (linkSrcIdx == null) return null - // Use the inner link, but update the origin node to be inner node id + const linkSrcSlot = innerLink[1] + if (linkSrcIdx == null || linkSrcSlot == null) return null return { origin_id: innerNodes[Number(linkSrcIdx)].id, - origin_slot: innerLink[1], + origin_slot: linkSrcSlot, target_id: innerNode.id, target_slot: +slot } @@ -1012,15 +1041,13 @@ export class GroupNodeHandler { } } - this.node.updateLink = (link) => { - // Replace the group node reference with the internal node - // @ts-expect-error Can this be removed? Or replaced with: LLink.create(link.asSerialisable()) - link = { ...link } + this.node.updateLink = (inputLink) => { + const link = LLink.create(inputLink.asSerialisable()) const output = this.groupData.newToOldOutputMap[link.origin_slot] if (!output || !this.innerNodes) return null const nodeIdx = output.node.index ?? 0 let innerNode: LGraphNode | null = this.innerNodes[nodeIdx] - let l + let l = innerNode?.getInputLink(0) while (innerNode?.type === 'Reroute') { l = innerNode.getInputLink(0) innerNode = innerNode.getInputNode(0) @@ -1031,7 +1058,7 @@ export class GroupNodeHandler { } if ( - l && + l instanceof LLink && GroupNodeHandler.isGroupNode(innerNode) && innerNode.updateLink ) { @@ -1063,8 +1090,7 @@ export class GroupNodeHandler { if (!n.type) return null const innerNode = LiteGraph.createNode(n.type) if (!innerNode) return null - // @ts-expect-error legacy node data format used for configure - innerNode.configure(n) + innerNode.configure(n as ISerialisedNode) innerNode.id = `${this.node.id}:${i}` innerNode.graph = this.node.graph return innerNode @@ -1077,18 +1103,16 @@ export class GroupNodeHandler { const subgraphInstanceIdPath = [...subgraphNodePath, this.node.id] - // Assertion: Deprecated, does not matter. - const subgraphNode = (this.node.graph?.getNodeById( - subgraphNodePath.at(-1) - ) ?? undefined) as SubgraphNode | undefined + // Get the parent subgraph node if we're inside a subgraph + const parentNode = this.node.graph?.getNodeById(subgraphNodePath.at(-1)) + const subgraphNode = parentNode?.isSubgraphNode() ? parentNode : undefined for (const node of this.innerNodes ?? []) { node.graph ??= this.node.graph - // Create minimal DTOs rather than cloning the node const currentId = String(node.id) - // @ts-expect-error temporary id reassignment for DTO creation - node.id = currentId.split(':').at(-1) + const shortId = currentId.split(':').at(-1) ?? currentId + node.id = shortId const aVeryRealNode = new ExecutableGroupNodeChildDTO( node, subgraphInstanceIdPath, @@ -1103,7 +1127,6 @@ export class GroupNodeHandler { return nodes } - // @ts-expect-error recreate returns null if creation fails this.node.recreate = async () => { const id = this.node.id const sz = this.node.size @@ -1139,11 +1162,9 @@ export class GroupNodeHandler { this.node as LGraphNode & { convertToNodes: () => LGraphNode[] } ).convertToNodes = () => { const addInnerNodes = () => { - // Clone the node data so we dont mutate it for other nodes const c = { ...this.groupData.nodeData } c.nodes = [...c.nodes] - // @ts-expect-error getInnerNodes called without args in legacy conversion code - const innerNodes = this.node.getInnerNodes?.() + const innerNodes = this.innerNodes const ids: (string | number)[] = [] for (let i = 0; i < c.nodes.length; i++) { let id: string | number | undefined = innerNodes?.[i]?.id @@ -1153,7 +1174,6 @@ export class GroupNodeHandler { } else { ids.push(id) } - // @ts-expect-error adding id to node copy for serialization c.nodes[i] = { ...c.nodes[i], id } } deserialiseAndCreate(JSON.stringify(c), app.canvas) @@ -1182,7 +1202,6 @@ export class GroupNodeHandler { if (!newNode.widgets || !innerNode) continue - // @ts-expect-error index property access on ExecutableLGraphNode const map = this.groupData.oldToNewWidgetMap[innerNode.index ?? 0] if (map) { const widgets = Object.keys(map) @@ -1305,14 +1324,13 @@ export class GroupNodeHandler { null, { content: 'Convert to nodes', - // @ts-expect-error async callback not expected by legacy menu API callback: async () => { const convertFn = ( handlerNode as LGraphNode & { convertToNodes?: () => LGraphNode[] } ).convertToNodes - return convertFn?.() + convertFn?.() } }, { @@ -1423,13 +1441,9 @@ export class GroupNodeHandler { type EventDetail = { display_node?: string; node?: string } | string const handleEvent = ( - type: string, + type: 'executing' | 'executed', getId: (detail: EventDetail) => string | undefined, - getEvent: ( - detail: EventDetail, - id: string, - node: LGraphNode - ) => EventDetail + getEvent: (detail: EventDetail, id: string, node: LGraphNode) => unknown ) => { const handler = ({ detail }: CustomEvent) => { const id = getId(detail) @@ -1443,16 +1457,14 @@ export class GroupNodeHandler { ;( this.node as LGraphNode & { runningInternalNodeId?: number } ).runningInternalNodeId = innerNodeIndex + // Cast needed: dispatching synthetic events for inner nodes with transformed payloads api.dispatchCustomEvent( - type as 'executing', + type, getEvent(detail, `${this.node.id}`, this.node) as string ) } } - api.addEventListener( - type as 'executing' | 'executed', - handler as EventListener - ) + api.addEventListener(type, handler as EventListener) return handler } @@ -1519,7 +1531,6 @@ export class GroupNodeHandler { if (!innerNode) continue if (innerNode.type === 'PrimitiveNode') { - // @ts-expect-error primitiveValue is a custom property on PrimitiveNode innerNode.primitiveValue = newValue const primitiveLinked = this.groupData.primitiveToWidget[nodeIdx] for (const linked of primitiveLinked ?? []) { @@ -1748,12 +1759,7 @@ export class GroupNodeHandler { this.groupData.oldToNewInputMap[Number(targetId)]?.[Number(targetSlot)] if (mappedSlot == null) continue if (typeof originSlot === 'number' || typeof originSlot === 'string') { - originNode.connect( - originSlot, - // @ts-expect-error Valid - uses deprecated interface (node ID instead of node reference) - this.node.id, - mappedSlot - ) + originNode.connect(originSlot, this.node, mappedSlot) } } } @@ -1761,9 +1767,10 @@ export class GroupNodeHandler { static getGroupData( node: LGraphNodeConstructor ): GroupNodeConfig | undefined + static getGroupData(node: typeof LGraphNode): GroupNodeConfig | undefined static getGroupData(node: LGraphNode): GroupNodeConfig | undefined static getGroupData( - node: LGraphNode | LGraphNodeConstructor + node: LGraphNode | LGraphNodeConstructor | typeof LGraphNode ): GroupNodeConfig | undefined { // Check if this is a constructor (function) or an instance if (typeof node === 'function') { @@ -1783,13 +1790,13 @@ export class GroupNodeHandler { } static getHandler(node: LGraphNode): GroupNodeHandler | undefined { - // @ts-expect-error GROUP symbol indexing on LGraphNode - let handler = node[GROUP] as GroupNodeHandler | undefined - // Handler may not be set yet if nodeCreated async hook hasn't run - // Create it synchronously if needed + type GroupNodeWithHandler = LGraphNode & { + [GROUP]?: GroupNodeHandler + } + let handler = (node as GroupNodeWithHandler)[GROUP] if (!handler && GroupNodeHandler.isGroupNode(node)) { handler = new GroupNodeHandler(node) - ;(node as LGraphNode & { [GROUP]: GroupNodeHandler })[GROUP] = handler + ;(node as GroupNodeWithHandler)[GROUP] = handler } return handler } @@ -1948,8 +1955,9 @@ const ext: ComfyExtension = { items.push({ content: `Convert to Group Node (Deprecated)`, disabled: !convertEnabled, - // @ts-expect-error async callback - legacy menu API doesn't expect Promise - callback: async () => convertSelectedNodesToGroupNode() + callback: async () => { + await convertSelectedNodesToGroupNode() + } }) const groups = canvas.graph?.extra?.groupNodes @@ -1975,8 +1983,9 @@ const ext: ComfyExtension = { { content: `Convert to Group Node (Deprecated)`, disabled: !convertEnabled, - // @ts-expect-error async callback - legacy menu API doesn't expect Promise - callback: async () => convertSelectedNodesToGroupNode() + callback: async () => { + await convertSelectedNodesToGroupNode() + } } ] }, diff --git a/src/extensions/core/groupNodeManage.ts b/src/extensions/core/groupNodeManage.ts index 0e91af317ff..ca73f53572c 100644 --- a/src/extensions/core/groupNodeManage.ts +++ b/src/extensions/core/groupNodeManage.ts @@ -1,9 +1,11 @@ +import { merge } from 'es-toolkit' + import { PREFIX, SEPARATOR } from '@/constants/groupNodeConstants' -import { - type LGraphNode, - type LGraphNodeConstructor, - LiteGraph +import type { + GroupNodeWorkflowData, + LGraphNode } from '@/lib/litegraph/src/litegraph' +import { LiteGraph } from '@/lib/litegraph/src/litegraph' import { useToastStore } from '@/platform/updates/common/toastStore' import { type ComfyApp, app } from '../../scripts/app' @@ -13,70 +15,69 @@ import { DraggableList } from '../../scripts/ui/draggableList' import { GroupNodeConfig, GroupNodeHandler } from './groupNode' import './groupNodeManage.css' -const ORDER: symbol = Symbol() - -// @ts-expect-error fixme ts strict error -function merge(target, source) { - if (typeof target === 'object' && typeof source === 'object') { - for (const key in source) { - const sv = source[key] - if (typeof sv === 'object') { - let tv = target[key] - if (!tv) tv = target[key] = {} - merge(tv, source[key]) - } else { - target[key] = sv - } - } - } +/** Group node types have nodeData of type GroupNodeWorkflowData */ +interface GroupNodeType { + nodeData: GroupNodeWorkflowData +} - return target +type GroupNodeConstructor = typeof LGraphNode & GroupNodeType + +function hasNodeData( + nodeType: typeof LGraphNode | undefined +): nodeType is GroupNodeConstructor { + return nodeType != null && 'nodeData' in nodeType } +const ORDER: unique symbol = Symbol('ORDER') + +interface NodeModification { + name?: string + visible?: boolean +} + +interface OrderModification { + order: number +} + +type NodeModifications = Record & { + [ORDER]?: OrderModification +} + +type DragEndEvent = CustomEvent<{ + element: Element + oldPosition: number + newPosition: number +}> + export class ManageGroupDialog extends ComfyDialog { - // @ts-expect-error fixme ts strict error - tabs: Record< + tabs!: Record< 'Inputs' | 'Outputs' | 'Widgets', { tab: HTMLAnchorElement; page: HTMLElement } > selectedNodeIndex: number | null | undefined selectedTab: keyof ManageGroupDialog['tabs'] = 'Inputs' selectedGroup: string | undefined - modifications: Record< - string, - Record< - string, - Record< - string, - { name?: string | undefined; visible?: boolean | undefined } - > - > - > = {} - // @ts-expect-error fixme ts strict error - nodeItems: any[] + modifications: Record }> = + {} + nodeItems: HTMLLIElement[] = [] app: ComfyApp - // @ts-expect-error fixme ts strict error - groupNodeType: LGraphNodeConstructor - groupNodeDef: any - groupData: any - - // @ts-expect-error fixme ts strict error - innerNodesList: HTMLUListElement - // @ts-expect-error fixme ts strict error - widgetsPage: HTMLElement - // @ts-expect-error fixme ts strict error - inputsPage: HTMLElement - // @ts-expect-error fixme ts strict error - outputsPage: HTMLElement - draggable: any - - get selectedNodeInnerIndex() { - // @ts-expect-error fixme ts strict error - return +this.nodeItems[this.selectedNodeIndex].dataset.nodeindex + groupNodeType!: GroupNodeConstructor + groupNodeDef: unknown + groupData: ReturnType | null = null + + innerNodesList!: HTMLUListElement + widgetsPage!: HTMLElement + inputsPage!: HTMLElement + outputsPage!: HTMLElement + draggable: DraggableList | null = null + + get selectedNodeInnerIndex(): number { + if (this.selectedNodeIndex == null) return 0 + const item = this.nodeItems[this.selectedNodeIndex] + return +(item?.dataset?.nodeindex ?? 0) } - // @ts-expect-error fixme ts strict error - constructor(app) { + constructor(app: ComfyApp) { super() this.app = app this.element = $el('dialog.comfy-group-manage', { @@ -84,19 +85,15 @@ export class ManageGroupDialog extends ComfyDialog { }) as HTMLDialogElement } - // @ts-expect-error fixme ts strict error - changeTab(tab) { + changeTab(tab: keyof ManageGroupDialog['tabs']) { this.tabs[this.selectedTab].tab.classList.remove('active') this.tabs[this.selectedTab].page.classList.remove('active') - // @ts-expect-error fixme ts strict error this.tabs[tab].tab.classList.add('active') - // @ts-expect-error fixme ts strict error this.tabs[tab].page.classList.add('active') this.selectedTab = tab } - // @ts-expect-error fixme ts strict error - changeNode(index, force?) { + changeNode(index: number, force?: boolean) { if (!force && this.selectedNodeIndex === index) return if (this.selectedNodeIndex != null) { @@ -119,26 +116,29 @@ export class ManageGroupDialog extends ComfyDialog { } getGroupData() { - this.groupNodeType = LiteGraph.registered_node_types[ - `${PREFIX}${SEPARATOR}` + this.selectedGroup - ] as unknown as LGraphNodeConstructor + const nodeType = + LiteGraph.registered_node_types[ + `${PREFIX}${SEPARATOR}` + this.selectedGroup + ] + if (!hasNodeData(nodeType)) { + throw new Error(`Group node type not found: ${this.selectedGroup}`) + } + this.groupNodeType = nodeType this.groupNodeDef = this.groupNodeType.nodeData this.groupData = GroupNodeHandler.getGroupData(this.groupNodeType) } - // @ts-expect-error fixme ts strict error - changeGroup(group, reset = true) { + changeGroup(group: string, reset = true) { this.selectedGroup = group this.getGroupData() - const nodes = this.groupData.nodeData.nodes - // @ts-expect-error fixme ts strict error + const nodes = this.groupData?.nodeData.nodes ?? [] this.nodeItems = nodes.map((n, i) => $el( 'li.draggable-item', { dataset: { - nodeindex: n.index + '' + nodeindex: String(n.index ?? i) }, onclick: () => { this.changeNode(i) @@ -159,7 +159,7 @@ export class ManageGroupDialog extends ComfyDialog { ) ] ) - ) + ) as HTMLLIElement[] this.innerNodesList.replaceChildren(...this.nodeItems) @@ -167,63 +167,76 @@ export class ManageGroupDialog extends ComfyDialog { this.selectedNodeIndex = null this.changeNode(0) } else { - const items = this.draggable.getAllItems() - // @ts-expect-error fixme ts strict error - let index = items.findIndex((item) => item.classList.contains('selected')) - if (index === -1) index = this.selectedNodeIndex + const items = this.draggable?.getAllItems() ?? [] + let index = items.findIndex((item: Element) => + item.classList.contains('selected') + ) + if (index === -1) index = this.selectedNodeIndex ?? 0 this.changeNode(index, true) } const ordered = [...nodes] this.draggable?.dispose() this.draggable = new DraggableList(this.innerNodesList, 'li') - this.draggable.addEventListener( - 'dragend', - // @ts-expect-error fixme ts strict error - ({ detail: { oldPosition, newPosition } }) => { - if (oldPosition === newPosition) return - ordered.splice(newPosition, 0, ordered.splice(oldPosition, 1)[0]) - for (let i = 0; i < ordered.length; i++) { - this.storeModification({ - nodeIndex: ordered[i].index, - section: ORDER, - prop: 'order', - value: i - }) - } + this.draggable.addEventListener('dragend', (e: Event) => { + const detail = (e as DragEndEvent).detail + const { oldPosition, newPosition } = detail + if (oldPosition === newPosition) return + ordered.splice(newPosition, 0, ordered.splice(oldPosition, 1)[0]) + for (let i = 0; i < ordered.length; i++) { + const nodeIndex = ordered[i].index + if (nodeIndex == null) continue + this.storeModification({ + nodeIndex, + section: ORDER, + prop: 'order', + value: i + }) } - ) + }) } storeModification(props: { nodeIndex?: number - section: symbol + section: string | typeof ORDER prop: string - value: any + value: unknown }) { const { nodeIndex, section, prop, value } = props - // @ts-expect-error fixme ts strict error + if (!this.selectedGroup) return + const groupMod = (this.modifications[this.selectedGroup] ??= {}) const nodesMod = (groupMod.nodes ??= {}) - const nodeMod = (nodesMod[nodeIndex ?? this.selectedNodeInnerIndex] ??= {}) - const typeMod = (nodeMod[section] ??= {}) - if (typeof value === 'object') { - const objMod = (typeMod[prop] ??= {}) - Object.assign(objMod, value) + const nodeKey = String(nodeIndex ?? this.selectedNodeInnerIndex) + const nodeMod = (nodesMod[nodeKey] ??= {} as NodeModifications) + + if (section === ORDER) { + nodeMod[ORDER] = { order: value as number } } else { - typeMod[prop] = value + const sectionMod = (nodeMod[section] ??= {}) + if (typeof value === 'object' && value !== null) { + Object.assign(sectionMod, value) + } else { + Object.assign(sectionMod, { [prop]: value }) + } } } - // @ts-expect-error fixme ts strict error - getEditElement(section, prop, value, placeholder, checked, checkable = true) { + getEditElement( + section: string, + prop: string | number, + value: string, + placeholder: string, + checked: boolean, + checkable = true + ) { if (value === placeholder) value = '' - const mods = - // @ts-expect-error fixme ts strict error - this.modifications[this.selectedGroup]?.nodes?.[ - this.selectedNodeInnerIndex - ]?.[section]?.[prop] + const mods = this.selectedGroup + ? this.modifications[this.selectedGroup]?.nodes?.[ + this.selectedNodeInnerIndex + ]?.[section] + : undefined if (mods) { if (mods.name != null) { value = mods.name @@ -238,12 +251,11 @@ export class ManageGroupDialog extends ComfyDialog { value, placeholder, type: 'text', - // @ts-expect-error fixme ts strict error - onchange: (e) => { + onchange: (e: Event) => { this.storeModification({ section, - prop, - value: { name: e.target.value } + prop: String(prop), + value: { name: (e.target as HTMLInputElement).value } }) } }), @@ -252,12 +264,11 @@ export class ManageGroupDialog extends ComfyDialog { type: 'checkbox', checked, disabled: !checkable, - // @ts-expect-error fixme ts strict error - onchange: (e) => { + onchange: (e: Event) => { this.storeModification({ section, - prop, - value: { visible: !!e.target.checked } + prop: String(prop), + value: { visible: !!(e.target as HTMLInputElement).checked } }) } }) @@ -267,17 +278,22 @@ export class ManageGroupDialog extends ComfyDialog { buildWidgetsPage() { const widgets = - this.groupData.oldToNewWidgetMap[this.selectedNodeInnerIndex] + this.groupData?.oldToNewWidgetMap[this.selectedNodeInnerIndex] const items = Object.keys(widgets ?? {}) - // @ts-expect-error fixme ts strict error - const type = app.rootGraph.extra.groupNodes[this.selectedGroup] - const config = type.config?.[this.selectedNodeInnerIndex]?.input + const type = this.selectedGroup + ? app.rootGraph.extra?.groupNodes?.[this.selectedGroup] + : undefined + const config = ( + type?.config as + | Record }> + | undefined + )?.[this.selectedNodeInnerIndex]?.input this.widgetsPage.replaceChildren( ...items.map((oldName) => { return this.getEditElement( 'input', oldName, - widgets[oldName], + widgets?.[oldName] ?? '', oldName, config?.[oldName]?.visible !== false ) @@ -287,54 +303,68 @@ export class ManageGroupDialog extends ComfyDialog { } buildInputsPage() { - const inputs = this.groupData.nodeInputs[this.selectedNodeInnerIndex] - const items = Object.keys(inputs ?? {}) - // @ts-expect-error fixme ts strict error - const type = app.rootGraph.extra.groupNodes[this.selectedGroup] - const config = type.config?.[this.selectedNodeInnerIndex]?.input - this.inputsPage.replaceChildren( - // @ts-expect-error fixme ts strict error - ...items - .map((oldName) => { - let value = inputs[oldName] - if (!value) { - return - } + const inputs = this.groupData?.nodeInputs[this.selectedNodeInnerIndex] ?? {} + const items = Object.keys(inputs) + const type = this.selectedGroup + ? app.rootGraph.extra?.groupNodes?.[this.selectedGroup] + : undefined + const config = ( + type?.config as + | Record }> + | undefined + )?.[this.selectedNodeInnerIndex]?.input + const filteredElements = items + .map((oldName) => { + const value = inputs[oldName] + if (!value) { + return null + } - return this.getEditElement( - 'input', - oldName, - value, - oldName, - config?.[oldName]?.visible !== false - ) - }) - .filter(Boolean) - ) + return this.getEditElement( + 'input', + oldName, + value as string, + oldName, + config?.[oldName]?.visible !== false + ) + }) + .filter((el): el is HTMLDivElement => el !== null) + this.inputsPage.replaceChildren(...filteredElements) return !!items.length } buildOutputsPage() { - const nodes = this.groupData.nodeData.nodes - const innerNodeDef = this.groupData.getNodeDef( - nodes[this.selectedNodeInnerIndex] - ) - const outputs = innerNodeDef?.output ?? [] + const nodes = this.groupData?.nodeData.nodes ?? [] + const nodeData = nodes[this.selectedNodeInnerIndex] + const innerNodeDef = nodeData + ? this.groupData?.getNodeDef( + nodeData as Parameters[0] + ) + : undefined + const outputs = (innerNodeDef?.output ?? []) as string[] const groupOutputs = - this.groupData.oldToNewOutputMap[this.selectedNodeInnerIndex] - - // @ts-expect-error fixme ts strict error - const type = app.rootGraph.extra.groupNodes[this.selectedGroup] - const config = type.config?.[this.selectedNodeInnerIndex]?.output - const node = this.groupData.nodeData.nodes[this.selectedNodeInnerIndex] - const checkable = node.type !== 'PrimitiveNode' + this.groupData?.oldToNewOutputMap[this.selectedNodeInnerIndex] + + const workflowType = this.selectedGroup + ? app.rootGraph.extra?.groupNodes?.[this.selectedGroup] + : undefined + const config = ( + workflowType?.config as + | Record< + number, + { output?: Record } + > + | undefined + )?.[this.selectedNodeInnerIndex]?.output + const node = nodes[this.selectedNodeInnerIndex] + const checkable = node?.type !== 'PrimitiveNode' this.outputsPage.replaceChildren( ...outputs - // @ts-expect-error fixme ts strict error - .map((type, slot) => { + .map((outputType: string, slot: number) => { const groupOutputIndex = groupOutputs?.[slot] - const oldName = innerNodeDef.output_name?.[slot] ?? type - let value = config?.[slot]?.name + const oldName = (innerNodeDef?.output_name?.[slot] ?? + outputType) as string + let value = config?.[slot]?.name ?? '' const visible = config?.[slot]?.visible || groupOutputIndex != null if (!value || value === oldName) { value = '' @@ -353,8 +383,7 @@ export class ManageGroupDialog extends ComfyDialog { return !!outputs.length } - // @ts-expect-error fixme ts strict error - show(type?) { + override show(type?: string) { const groupNodes = Object.keys(app.rootGraph.extra?.groupNodes ?? {}).sort( (a, b) => a.localeCompare(b) ) @@ -371,24 +400,28 @@ export class ManageGroupDialog extends ComfyDialog { this.outputsPage ]) - this.tabs = [ - ['Inputs', this.inputsPage], - ['Widgets', this.widgetsPage], - ['Outputs', this.outputsPage] - // @ts-expect-error fixme ts strict error - ].reduce((p, [name, page]: [string, HTMLElement]) => { - // @ts-expect-error fixme ts strict error - p[name] = { - tab: $el('a', { - onclick: () => { - this.changeTab(name) - }, - textContent: name - }), - page - } - return p - }, {}) as any + type TabName = keyof ManageGroupDialog['tabs'] + this.tabs = ( + [ + ['Inputs', this.inputsPage], + ['Widgets', this.widgetsPage], + ['Outputs', this.outputsPage] + ] as [TabName, HTMLElement][] + ).reduce( + (p, [name, page]) => { + p[name] = { + tab: $el('a', { + onclick: () => { + this.changeTab(name) + }, + textContent: name + }) as HTMLAnchorElement, + page + } + return p + }, + {} as ManageGroupDialog['tabs'] + ) const outer = $el('div.comfy-group-manage-outer', [ $el('header', [ @@ -396,9 +429,8 @@ export class ManageGroupDialog extends ComfyDialog { $el( 'select', { - // @ts-expect-error fixme ts strict error - onchange: (e) => { - this.changeGroup(e.target.value) + onchange: (e: Event) => { + this.changeGroup((e.target as HTMLSelectElement).value) } }, groupNodes.map((g) => @@ -439,8 +471,9 @@ export class ManageGroupDialog extends ComfyDialog { `Are you sure you want to remove the node: "${this.selectedGroup}"` ) ) { - // @ts-expect-error fixme ts strict error - delete app.rootGraph.extra.groupNodes[this.selectedGroup] + if (this.selectedGroup && app.rootGraph.extra?.groupNodes) { + delete app.rootGraph.extra.groupNodes[this.selectedGroup] + } LiteGraph.unregisterNodeType( `${PREFIX}${SEPARATOR}` + this.selectedGroup ) @@ -454,97 +487,105 @@ export class ManageGroupDialog extends ComfyDialog { 'button.comfy-btn', { onclick: async () => { - let nodesByType - let recreateNodes = [] - const types = {} + let nodesByType: Record | null = null + const recreateNodes: LGraphNode[] = [] + const types: Record = {} for (const g in this.modifications) { - // @ts-expect-error fixme ts strict error - const type = app.rootGraph.extra.groupNodes[g] - let config = (type.config ??= {}) + const groupNodeData = app.rootGraph.extra?.groupNodes?.[g] + if (!groupNodeData) continue + + let config = (groupNodeData.config ??= {}) as Record< + number, + unknown + > let nodeMods = this.modifications[g]?.nodes if (nodeMods) { const keys = Object.keys(nodeMods) - // @ts-expect-error fixme ts strict error - if (nodeMods[keys[0]][ORDER]) { + const firstMod = nodeMods[keys[0]] + if (firstMod?.[ORDER]) { // If any node is reordered, they will all need sequencing - const orderedNodes = [] - const orderedMods = {} - const orderedConfig = {} + const orderedNodes: typeof groupNodeData.nodes = [] + const orderedMods: Record = {} + const orderedConfig: Record = {} for (const n of keys) { - // @ts-expect-error fixme ts strict error - const order = nodeMods[n][ORDER].order - orderedNodes[order] = type.nodes[+n] - // @ts-expect-error fixme ts strict error + const order = nodeMods[n]?.[ORDER]?.order ?? 0 + orderedNodes[order] = groupNodeData.nodes[+n] orderedMods[order] = nodeMods[n] orderedNodes[order].index = order } // Rewrite links - for (const l of type.links) { - // @ts-expect-error l[0]/l[2] used as node index - if (l[0] != null) l[0] = type.nodes[l[0]].index - // @ts-expect-error l[0]/l[2] used as node index - if (l[2] != null) l[2] = type.nodes[l[2]].index + for (const l of groupNodeData.links) { + const srcIdx = l[1] + const tgtIdx = l[3] + if (srcIdx != null) + l[1] = + groupNodeData.nodes[srcIdx as number]?.index ?? srcIdx + if (tgtIdx != null) + l[3] = + groupNodeData.nodes[tgtIdx as number]?.index ?? tgtIdx } // Rewrite externals - if (type.external) { - for (const ext of type.external) { + if (groupNodeData.external) { + for (const ext of groupNodeData.external) { if (ext[0] != null) { - // @ts-expect-error ext[0] used as node index - ext[0] = type.nodes[ext[0]].index + ext[0] = + groupNodeData.nodes[ext[0] as number]?.index ?? + ext[0] } } } // Rewrite modifications for (const id of keys) { - // @ts-expect-error id used as node index - if (config[id]) { - // @ts-expect-error fixme ts strict error - orderedConfig[type.nodes[id].index] = config[id] + const nodeIdx = +id + if (config[nodeIdx]) { + const newIdx = + groupNodeData.nodes[nodeIdx]?.index ?? nodeIdx + orderedConfig[newIdx] = config[nodeIdx] } - // @ts-expect-error id used as config key - delete config[id] + delete config[nodeIdx] } - type.nodes = orderedNodes + groupNodeData.nodes = orderedNodes nodeMods = orderedMods - type.config = config = orderedConfig + groupNodeData.config = config = orderedConfig } - merge(config, nodeMods) + merge(config, nodeMods as Record) } - // @ts-expect-error fixme ts strict error - types[g] = type + types[g] = groupNodeData if (!nodesByType) { - nodesByType = app.rootGraph.nodes.reduce((p, n) => { - // @ts-expect-error fixme ts strict error - p[n.type] ??= [] - // @ts-expect-error fixme ts strict error - p[n.type].push(n) - return p - }, {}) + nodesByType = app.rootGraph.nodes.reduce( + (p, n) => { + const nodeType = n.type ?? '' + ;(p[nodeType] ??= []).push(n) + return p + }, + {} as Record + ) } - // @ts-expect-error fixme ts strict error - const nodes = nodesByType[`${PREFIX}${SEPARATOR}` + g] - if (nodes) recreateNodes.push(...nodes) + const groupTypeNodes = nodesByType[`${PREFIX}${SEPARATOR}` + g] + if (groupTypeNodes) recreateNodes.push(...groupTypeNodes) } await GroupNodeConfig.registerFromWorkflow(types, []) for (const node of recreateNodes) { - node.recreate() + ;(node as LGraphNode & { recreate?: () => void }).recreate?.() } this.modifications = {} this.app.canvas.setDirty(true, true) - this.changeGroup(this.selectedGroup, false) + if (this.selectedGroup) { + this.changeGroup(this.selectedGroup, false) + } } }, 'Save' diff --git a/src/extensions/core/groupNodeTypes.ts b/src/extensions/core/groupNodeTypes.ts new file mode 100644 index 00000000000..badabbe63bd --- /dev/null +++ b/src/extensions/core/groupNodeTypes.ts @@ -0,0 +1,66 @@ +import type { ILinkRouting } from '@/lib/litegraph/src/interfaces' +import type { ISlotType } from '@/lib/litegraph/src/interfaces' +import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation' + +/** + * Group node internal link format. + * This differs from standard SerialisedLLinkArray - indices represent node/slot positions within the group. + * Format: [sourceNodeIndex, sourceSlot, targetNodeIndex, targetSlot, ...optionalData] + * The type (ISlotType) may be at index 5 if present. + */ +export type GroupNodeInternalLink = [ + sourceNodeIndex: number | null, + sourceSlot: number | null, + targetNodeIndex: number | null, + targetSlot: number | null, + ...rest: (number | string | ISlotType | null | undefined)[] +] + +/** Serialized node data within a group node workflow, with group-specific index */ +interface GroupNodeSerializedNode extends Partial { + /** Position of this node within the group */ + index?: number +} + +export interface GroupNodeWorkflowData { + external: (number | string)[][] + links: GroupNodeInternalLink[] + nodes: GroupNodeSerializedNode[] + config?: Record +} + +/** + * Input config tuple type for group nodes. + * First element is the input type name (e.g. 'INT', 'FLOAT', 'MODEL', etc.) + * Second element (optional) is the input options object. + */ +export type GroupNodeInputConfig = [string, Record?] + +/** + * Mutable inputs specification for group nodes that are built dynamically. + * Uses a more permissive type than ComfyInputsSpec to allow dynamic assignment. + */ +export interface GroupNodeInputsSpec { + required: Record + optional?: Record +} + +/** + * Output type for group nodes - can be a type string or an array of combo options. + */ +export type GroupNodeOutputType = string | (string | number)[] + +/** + * Represents a partial or synthetic link used internally by group node's + * `getInputLink` override when resolving connections through collapsed group nodes. + * + * Unlike a full `ILinkRouting`, this represents a computed/virtual link that may not + * correspond to an actual link in the graph's link registry. It's constructed on-the-fly + * to represent the logical connection path through group node boundaries. + * + * This type aliases `ILinkRouting` (rather than narrowing it) because the consuming code + * expects the same shape for both real and synthetic links. The distinction is purely + * semantic: callers should be aware that these links are transient and may not have + * valid `link_id` references in the global link map. + */ +export interface PartialLinkInfo extends ILinkRouting {} diff --git a/src/extensions/core/groupOptions.ts b/src/extensions/core/groupOptions.ts index 7e3240bcf70..c41f42e964d 100644 --- a/src/extensions/core/groupOptions.ts +++ b/src/extensions/core/groupOptions.ts @@ -25,11 +25,10 @@ function addNodesToGroup(group: LGraphGroup, items: Iterable) { const ext: ComfyExtension = { name: 'Comfy.GroupOptions', - getCanvasMenuItems(canvas: LGraphCanvas): IContextMenuValue[] { - const items: IContextMenuValue[] = [] + getCanvasMenuItems(canvas: LGraphCanvas): (IContextMenuValue | null)[] { + const items: (IContextMenuValue | null)[] = [] - // @ts-expect-error fixme ts strict error - const group = canvas.graph.getGroupOnPos( + const group = canvas.graph?.getGroupOnPos( canvas.graph_mouse[0], canvas.graph_mouse[1] ) @@ -41,10 +40,8 @@ const ext: ComfyExtension = { callback: () => { const group = new LGraphGroup() addNodesToGroup(group, canvas.selectedItems) - // @ts-expect-error fixme ts strict error - canvas.graph.add(group) - // @ts-expect-error fixme ts strict error - canvas.graph.change() + canvas.graph?.add(group) + canvas.graph?.change() group.recomputeInsideNodes() } @@ -63,8 +60,7 @@ const ext: ComfyExtension = { disabled: !canvas.selectedItems?.size, callback: () => { addNodesToGroup(group, canvas.selectedItems) - // @ts-expect-error fixme ts strict error - canvas.graph.change() + canvas.graph?.change() } }) @@ -73,7 +69,6 @@ const ext: ComfyExtension = { return items } else { // Add a separator between the default options and the group options - // @ts-expect-error fixme ts strict error items.push(null) } @@ -94,8 +89,7 @@ const ext: ComfyExtension = { 'Comfy.GroupSelectedNodes.Padding' ) group.resizeTo(group.children, padding) - // @ts-expect-error fixme ts strict error - canvas.graph.change() + canvas.graph?.change() } }) @@ -103,8 +97,7 @@ const ext: ComfyExtension = { content: 'Select Nodes', callback: () => { canvas.selectNodes(nodesInGroup) - // @ts-expect-error fixme ts strict error - canvas.graph.change() + canvas.graph?.change() canvas.canvas.focus() } }) diff --git a/src/extensions/core/load3d.ts b/src/extensions/core/load3d.ts index 55358d62caa..5de1b53a882 100644 --- a/src/extensions/core/load3d.ts +++ b/src/extensions/core/load3d.ts @@ -225,7 +225,6 @@ useExtensionService().registerExtension({ if (!isLoad3dNode(selectedNode)) return ComfyApp.copyToClipspace(selectedNode) - // @ts-expect-error clipspace_return_node is an extension property added at runtime ComfyApp.clipspace_return_node = selectedNode const props = { node: selectedNode } @@ -412,9 +411,8 @@ useExtensionService().registerExtension({ name: 'Comfy.Preview3D', async beforeRegisterNodeDef(_nodeType, nodeData) { - if ('Preview3D' === nodeData.name) { - // @ts-expect-error InputSpec is not typed correctly - nodeData.input.required.image = ['PREVIEW_3D'] + if ('Preview3D' === nodeData.name && nodeData.input?.required) { + nodeData.input.required.image = ['PREVIEW_3D', {}] } }, diff --git a/src/extensions/core/nodeTemplates.ts b/src/extensions/core/nodeTemplates.ts index 040b54526e5..9cc374a5412 100644 --- a/src/extensions/core/nodeTemplates.ts +++ b/src/extensions/core/nodeTemplates.ts @@ -32,11 +32,15 @@ import { GroupNodeConfig, GroupNodeHandler } from './groupNode' const id = 'Comfy.NodeTemplates' const file = 'comfy.templates.json' +interface NodeTemplate { + name: string + data: string +} + class ManageTemplates extends ComfyDialog { - // @ts-expect-error fixme ts strict error - templates: any[] + templates: NodeTemplate[] = [] draggedEl: HTMLElement | null - saveVisualCue: number | null + saveVisualCue: ReturnType | null emptyImg: HTMLImageElement importInput: HTMLInputElement @@ -67,8 +71,9 @@ class ManageTemplates extends ComfyDialog { const btns = super.createButtons() btns[0].textContent = 'Close' btns[0].onclick = () => { - // @ts-expect-error fixme ts strict error - clearTimeout(this.saveVisualCue) + if (this.saveVisualCue !== null) { + clearTimeout(this.saveVisualCue) + } this.close() } btns.unshift( @@ -109,14 +114,17 @@ class ManageTemplates extends ComfyDialog { await api.storeUserData(file, templates, { stringify: false }) } catch (error) { console.error(error) - // @ts-expect-error fixme ts strict error - useToastStore().addAlert(error.message) + useToastStore().addAlert( + error instanceof Error ? error.message : String(error) + ) } } async importAll() { - // @ts-expect-error fixme ts strict error - for (const file of this.importInput.files) { + const files = this.importInput.files + if (!files) return + + for (const file of files) { if (file.type === 'application/json' || file.name.endsWith('.json')) { const reader = new FileReader() reader.onload = async () => { @@ -134,8 +142,7 @@ class ManageTemplates extends ComfyDialog { } } - // @ts-expect-error fixme ts strict error - this.importInput.value = null + this.importInput.value = '' this.close() } @@ -158,8 +165,7 @@ class ManageTemplates extends ComfyDialog { 'div', {}, this.templates.flatMap((t, i) => { - // @ts-expect-error fixme ts strict error - let nameInput + let nameInput: HTMLInputElement | undefined return [ $el( 'div', @@ -173,55 +179,58 @@ class ManageTemplates extends ComfyDialog { gap: '5px', backgroundColor: 'var(--comfy-menu-bg)' }, - // @ts-expect-error fixme ts strict error - ondragstart: (e) => { - this.draggedEl = e.currentTarget - e.currentTarget.style.opacity = '0.6' - e.currentTarget.style.border = '1px dashed yellow' - e.dataTransfer.effectAllowed = 'move' - e.dataTransfer.setDragImage(this.emptyImg, 0, 0) + ondragstart: (e: DragEvent) => { + const target = e.currentTarget + if (!(target instanceof HTMLElement)) return + this.draggedEl = target + target.style.opacity = '0.6' + target.style.border = '1px dashed yellow' + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setDragImage(this.emptyImg, 0, 0) + } }, - // @ts-expect-error fixme ts strict error - ondragend: (e) => { - e.target.style.opacity = '1' - e.currentTarget.style.border = '1px dashed transparent' - e.currentTarget.removeAttribute('draggable') + ondragend: (e: DragEvent) => { + const target = e.currentTarget + if (!(target instanceof HTMLElement)) return + target.style.opacity = '1' + target.style.border = '1px dashed transparent' + target.removeAttribute('draggable') // rearrange the elements this.element .querySelectorAll('.templateManagerRow') - // @ts-expect-error fixme ts strict error - .forEach((el: HTMLElement, i) => { - // @ts-expect-error fixme ts strict error - var prev_i = Number.parseInt(el.dataset.id) + .forEach((el, index) => { + if (!(el instanceof HTMLElement)) return + const prev_i = Number.parseInt(el.dataset.id ?? '0') - if (el == this.draggedEl && prev_i != i) { + if (el === this.draggedEl && prev_i !== index) { this.templates.splice( - i, + index, 0, this.templates.splice(prev_i, 1)[0] ) } - el.dataset.id = i.toString() + el.dataset.id = index.toString() }) this.store() }, - // @ts-expect-error fixme ts strict error - ondragover: (e) => { + ondragover: (e: DragEvent) => { e.preventDefault() - if (e.currentTarget == this.draggedEl) return - - let rect = e.currentTarget.getBoundingClientRect() - if (e.clientY > rect.top + rect.height / 2) { - e.currentTarget.parentNode.insertBefore( - this.draggedEl, - e.currentTarget.nextSibling - ) - } else { - e.currentTarget.parentNode.insertBefore( - this.draggedEl, - e.currentTarget - ) + const target = e.currentTarget + if (!(target instanceof HTMLElement)) return + if (target === this.draggedEl) return + + const rect = target.getBoundingClientRect() + if (this.draggedEl) { + if (e.clientY > rect.top + rect.height / 2) { + target.parentNode?.insertBefore( + this.draggedEl, + target.nextSibling + ) + } else { + target.parentNode?.insertBefore(this.draggedEl, target) + } } } }, @@ -233,11 +242,18 @@ class ManageTemplates extends ComfyDialog { style: { cursor: 'grab' }, - // @ts-expect-error fixme ts strict error - onmousedown: (e) => { + onmousedown: (e: MouseEvent) => { // enable dragging only from the label - if (e.target.localName == 'label') - e.currentTarget.parentNode.draggable = 'true' + const target = e.target + const currentTarget = e.currentTarget + if ( + target instanceof HTMLElement && + target.localName === 'label' && + currentTarget instanceof HTMLElement && + currentTarget.parentNode instanceof HTMLElement + ) { + currentTarget.parentNode.draggable = true + } } }, [ @@ -248,33 +264,39 @@ class ManageTemplates extends ComfyDialog { transitionProperty: 'background-color', transitionDuration: '0s' }, - // @ts-expect-error fixme ts strict error - onchange: (e) => { - // @ts-expect-error fixme ts strict error - clearTimeout(this.saveVisualCue) - var el = e.target - var row = el.parentNode.parentNode - this.templates[row.dataset.id].name = - el.value.trim() || 'untitled' + onchange: (e: Event) => { + if (this.saveVisualCue !== null) { + clearTimeout(this.saveVisualCue) + } + const el = e.target + if (!(el instanceof HTMLInputElement)) return + const row = el.parentNode?.parentNode + if (!(row instanceof HTMLElement) || !row.dataset.id) + return + const idx = Number.parseInt(row.dataset.id) + this.templates[idx].name = el.value.trim() || 'untitled' this.store() el.style.backgroundColor = 'rgb(40, 95, 40)' el.style.transitionDuration = '0s' - // @ts-expect-error - // In browser env the return value is number. this.saveVisualCue = setTimeout(function () { el.style.transitionDuration = '.7s' el.style.backgroundColor = 'var(--comfy-input-bg)' }, 15) }, - // @ts-expect-error fixme ts strict error - onkeypress: (e) => { - var el = e.target - // @ts-expect-error fixme ts strict error - clearTimeout(this.saveVisualCue) + onkeypress: (e: KeyboardEvent) => { + const el = e.target + if (!(el instanceof HTMLInputElement)) return + if (this.saveVisualCue !== null) { + clearTimeout(this.saveVisualCue) + } el.style.transitionDuration = '0s' el.style.backgroundColor = 'var(--comfy-input-bg)' }, - $: (el) => (nameInput = el) + $: (el) => { + if (el instanceof HTMLInputElement) { + nameInput = el + } + } }) ] ), @@ -286,12 +308,11 @@ class ManageTemplates extends ComfyDialog { fontWeight: 'normal' }, onclick: () => { - const json = JSON.stringify({ templates: [t] }, null, 2) // convert the data to a JSON string + const json = JSON.stringify({ templates: [t] }, null, 2) const blob = new Blob([json], { type: 'application/json' }) - // @ts-expect-error fixme ts strict error - const name = (nameInput.value || t.name) + '.json' + const name = (nameInput?.value || t.name) + '.json' downloadBlob(name, blob) } }), @@ -302,20 +323,23 @@ class ManageTemplates extends ComfyDialog { color: 'red', fontWeight: 'normal' }, - // @ts-expect-error fixme ts strict error - onclick: (e) => { - const item = e.target.parentNode.parentNode - item.parentNode.removeChild(item) - this.templates.splice(item.dataset.id * 1, 1) + onclick: (e: MouseEvent) => { + const target = e.target + if (!(target instanceof HTMLElement)) return + const item = target.parentNode?.parentNode + if (!(item instanceof HTMLElement) || !item.dataset.id) + return + item.parentNode?.removeChild(item) + this.templates.splice(Number.parseInt(item.dataset.id), 1) this.store() // update the rows index, setTimeout ensures that the list is updated - var that = this - setTimeout(function () { - that.element + setTimeout(() => { + this.element .querySelectorAll('.templateManagerRow') - // @ts-expect-error fixme ts strict error - .forEach((el: HTMLElement, i) => { - el.dataset.id = i.toString() + .forEach((el, index) => { + if (el instanceof HTMLElement) { + el.dataset.id = index.toString() + } }) }, 0) } @@ -332,23 +356,24 @@ class ManageTemplates extends ComfyDialog { const manage = new ManageTemplates() -// @ts-expect-error fixme ts strict error -const clipboardAction = async (cb) => { +const clipboardAction = async (cb: () => void | Promise) => { // We use the clipboard functions but dont want to overwrite the current user clipboard // Restore it after we've run our callback const old = localStorage.getItem('litegrapheditor_clipboard') await cb() - // @ts-expect-error fixme ts strict error - localStorage.setItem('litegrapheditor_clipboard', old) + if (old !== null) { + localStorage.setItem('litegrapheditor_clipboard', old) + } else { + localStorage.removeItem('litegrapheditor_clipboard') + } } const ext: ComfyExtension = { name: id, - getCanvasMenuItems(_canvas: LGraphCanvas): IContextMenuValue[] { - const items: IContextMenuValue[] = [] + getCanvasMenuItems(_canvas: LGraphCanvas): (IContextMenuValue | null)[] { + const items: (IContextMenuValue | null)[] = [] - // @ts-expect-error fixme ts strict error items.push(null) items.push({ content: `Save Selected as Template`, @@ -363,8 +388,11 @@ const ext: ComfyExtension = { clipboardAction(() => { app.canvas.copyToClipboard() - let data = localStorage.getItem('litegrapheditor_clipboard') - data = JSON.parse(data || '{}') + const rawData = localStorage.getItem('litegrapheditor_clipboard') + const data = JSON.parse(rawData || '{}') as { + groupNodes?: Record + nodes?: Array<{ type: string }> + } const nodeIds = Object.keys(app.canvas.selected_nodes) for (let i = 0; i < nodeIds.length; i++) { const node = app.canvas.graph?.getNodeById(nodeIds[i]) @@ -374,16 +402,14 @@ const ext: ComfyExtension = { const groupConfig = GroupNodeHandler.getGroupData(node) if (groupConfig) { const groupData = groupConfig.nodeData - // @ts-expect-error if (!data.groupNodes) { - // @ts-expect-error data.groupNodes = {} } if (nodeData == null) throw new TypeError('nodeData is not set') - // @ts-expect-error data.groupNodes[nodeData.name] = groupData - // @ts-expect-error - data.nodes[i].type = nodeData.name + if (data.nodes?.[i]) { + data.nodes[i].type = nodeData.name + } } } @@ -397,7 +423,7 @@ const ext: ComfyExtension = { }) // Map each template to a menu item - const subItems = manage.templates.map((t) => { + const subItems: (IContextMenuValue | null)[] = manage.templates.map((t) => { return { content: t.name, callback: () => { @@ -420,7 +446,6 @@ const ext: ComfyExtension = { } }) - // @ts-expect-error fixme ts strict error subItems.push(null, { content: 'Manage', callback: () => manage.show() diff --git a/src/extensions/core/saveImageExtraOutput.ts b/src/extensions/core/saveImageExtraOutput.ts index f216f31a7db..00f69df55b6 100644 --- a/src/extensions/core/saveImageExtraOutput.ts +++ b/src/extensions/core/saveImageExtraOutput.ts @@ -1,3 +1,4 @@ +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import { applyTextReplacements } from '@/utils/searchAndReplace' import { app } from '../../scripts/app' @@ -25,18 +26,15 @@ app.registerExtension({ if (saveNodeTypes.has(nodeData.name)) { const onNodeCreated = nodeType.prototype.onNodeCreated // When the SaveImage node is created we want to override the serialization of the output name widget to run our S&R - nodeType.prototype.onNodeCreated = function () { - const r = onNodeCreated - ? // @ts-expect-error fixme ts strict error - onNodeCreated.apply(this, arguments) - : undefined - - // @ts-expect-error fixme ts strict error - const widget = this.widgets.find((w) => w.name === 'filename_prefix') - // @ts-expect-error fixme ts strict error - widget.serializeValue = () => { - // @ts-expect-error fixme ts strict error - return applyTextReplacements(app.graph, widget.value) + nodeType.prototype.onNodeCreated = function (this: LGraphNode) { + const r = onNodeCreated?.call(this) + + const widget = this.widgets?.find((w) => w.name === 'filename_prefix') + if (widget) { + widget.serializeValue = () => { + const value = typeof widget.value === 'string' ? widget.value : '' + return applyTextReplacements(app.rootGraph, value) + } } return r @@ -44,11 +42,8 @@ app.registerExtension({ } else { // When any other node is created add a property to alias the node const onNodeCreated = nodeType.prototype.onNodeCreated - nodeType.prototype.onNodeCreated = function () { - const r = onNodeCreated - ? // @ts-expect-error fixme ts strict error - onNodeCreated.apply(this, arguments) - : undefined + nodeType.prototype.onNodeCreated = function (this: LGraphNode) { + const r = onNodeCreated?.call(this) if (!this.properties || !('Node name for S&R' in this.properties)) { this.addProperty('Node name for S&R', this.constructor.type, 'string') diff --git a/src/extensions/core/saveMesh.ts b/src/extensions/core/saveMesh.ts index 9fb33ef94cd..2ca613eb422 100644 --- a/src/extensions/core/saveMesh.ts +++ b/src/extensions/core/saveMesh.ts @@ -21,9 +21,8 @@ useExtensionService().registerExtension({ name: 'Comfy.SaveGLB', async beforeRegisterNodeDef(_nodeType, nodeData) { - if ('SaveGLB' === nodeData.name) { - // @ts-expect-error InputSpec is not typed correctly - nodeData.input.required.image = ['PREVIEW_3D'] + if ('SaveGLB' === nodeData.name && nodeData.input?.required) { + nodeData.input.required.image = ['PREVIEW_3D', {}] } }, diff --git a/src/extensions/core/simpleTouchSupport.ts b/src/extensions/core/simpleTouchSupport.ts index 67215f47198..a014614837d 100644 --- a/src/extensions/core/simpleTouchSupport.ts +++ b/src/extensions/core/simpleTouchSupport.ts @@ -120,7 +120,6 @@ app.registerExtension({ touchZooming = true LiteGraph.closeAllContextMenus(window) - // @ts-expect-error app.canvas.search_box?.close() const newTouchDist = getMultiTouchPos(e) diff --git a/src/extensions/core/slotDefaults.ts b/src/extensions/core/slotDefaults.ts index f7ca293a445..7fa7266accc 100644 --- a/src/extensions/core/slotDefaults.ts +++ b/src/extensions/core/slotDefaults.ts @@ -55,10 +55,11 @@ app.registerExtension({ if (!(lowerType in LiteGraph.registered_slot_in_types)) { LiteGraph.registered_slot_in_types[lowerType] = { nodes: [] } } - LiteGraph.registered_slot_in_types[lowerType].nodes.push( - // @ts-expect-error ComfyNode - nodeType.comfyClass - ) + if ('comfyClass' in nodeType && typeof nodeType.comfyClass === 'string') { + LiteGraph.registered_slot_in_types[lowerType].nodes.push( + nodeType.comfyClass + ) + } } var outputs = nodeData['output'] ?? [] @@ -75,8 +76,11 @@ app.registerExtension({ if (!(type in LiteGraph.registered_slot_out_types)) { LiteGraph.registered_slot_out_types[type] = { nodes: [] } } - // @ts-expect-error ComfyNode - LiteGraph.registered_slot_out_types[type].nodes.push(nodeType.comfyClass) + if ('comfyClass' in nodeType && typeof nodeType.comfyClass === 'string') { + LiteGraph.registered_slot_out_types[type].nodes.push( + nodeType.comfyClass + ) + } if (!LiteGraph.slot_types_out.includes(type)) { LiteGraph.slot_types_out.push(type) diff --git a/src/extensions/core/uploadAudio.ts b/src/extensions/core/uploadAudio.ts index 71c2295d106..83cd31cd405 100644 --- a/src/extensions/core/uploadAudio.ts +++ b/src/extensions/core/uploadAudio.ts @@ -1,3 +1,4 @@ +import type { IMediaRecorder } from 'extendable-media-recorder' import { MediaRecorder as ExtendableMediaRecorder } from 'extendable-media-recorder' import { useChainCallback } from '@/composables/functional/useChainCallback' @@ -47,10 +48,9 @@ async function uploadFile( let path = data.name if (data.subfolder) path = data.subfolder + '/' + path - // @ts-expect-error fixme ts strict error - if (!audioWidget.options.values.includes(path)) { - // @ts-expect-error fixme ts strict error - audioWidget.options.values.push(path) + const values = audioWidget.options.values + if (Array.isArray(values) && !values.includes(path)) { + values.push(path) } if (updateNode) { @@ -66,8 +66,9 @@ async function uploadFile( useToastStore().addAlert(resp.status + ' - ' + resp.statusText) } } catch (error) { - // @ts-expect-error fixme ts strict error - useToastStore().addAlert(error) + useToastStore().addAlert( + error instanceof Error ? error.message : String(error) + ) } } @@ -83,13 +84,11 @@ app.registerExtension({ 'PreviewAudio', 'SaveAudioMP3', 'SaveAudioOpus' - ].includes( - // @ts-expect-error fixme ts strict error - nodeType.prototype.comfyClass - ) + ].includes(nodeType.prototype.comfyClass ?? '') ) { - // @ts-expect-error fixme ts strict error - nodeData.input.required.audioUI = ['AUDIO_UI', {}] + if (nodeData.input?.required) { + nodeData.input.required.audioUI = ['AUDIO_UI', {}] + } } }, getCustomWidgets() { @@ -113,8 +112,7 @@ app.registerExtension({ // Populate the audio widget UI on node execution. const onExecuted = node.onExecuted node.onExecuted = function (message: any) { - // @ts-expect-error fixme ts strict error - onExecuted?.apply(this, arguments) + onExecuted?.call(this, message) const audios = message.audio if (!audios) return const audio = audios[0] @@ -145,10 +143,10 @@ app.registerExtension({ const node = getNodeByLocatorId(app.rootGraph, nodeLocatorId) if (!node) continue - // @ts-expect-error fixme ts strict error - const audioUIWidget = node.widgets.find( + const audioUIWidget = node.widgets?.find( (w) => w.name === 'audioUI' - ) as unknown as DOMWidget + ) as DOMWidget | undefined + if (!audioUIWidget) continue const audio = output.audio[0] audioUIWidget.element.src = api.apiURL( getResourceURL(audio.subfolder, audio.filename, audio.type) @@ -170,14 +168,16 @@ app.registerExtension({ return { AUDIOUPLOAD(node, inputName: string) { // The widget that allows user to select file. - // @ts-expect-error fixme ts strict error - const audioWidget = node.widgets.find( - (w) => w.name === 'audio' - ) as IStringWidget - // @ts-expect-error fixme ts strict error - const audioUIWidget = node.widgets.find( + const audioWidget = node.widgets?.find((w) => w.name === 'audio') as + | IStringWidget + | undefined + const audioUIWidget = node.widgets?.find( (w) => w.name === 'audioUI' - ) as unknown as DOMWidget + ) as DOMWidget | undefined + + if (!audioWidget || !audioUIWidget) { + throw new Error('Required audio widgets not found') + } audioUIWidget.options.canvasOnly = true const onAudioWidgetUpdate = () => { @@ -195,8 +195,7 @@ app.registerExtension({ // Load saved audio file widget values if restoring from workflow const onGraphConfigured = node.onGraphConfigured node.onGraphConfigured = function () { - // @ts-expect-error fixme ts strict error - onGraphConfigured?.apply(this, arguments) + onGraphConfigured?.call(this) if (audioWidget.value) { onAudioWidgetUpdate() } @@ -258,7 +257,7 @@ app.registerExtension({ node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio) audioUIWidget.options.canvasOnly = false - let mediaRecorder: MediaRecorder | null = null + let mediaRecorder: IMediaRecorder | null = null let isRecording = false let audioChunks: Blob[] = [] let currentStream: MediaStream | null = null @@ -303,7 +302,7 @@ app.registerExtension({ mediaRecorder = new ExtendableMediaRecorder(currentStream, { mimeType: 'audio/wav' - }) as unknown as MediaRecorder + }) audioChunks = [] diff --git a/src/extensions/core/webcamCapture.ts b/src/extensions/core/webcamCapture.ts index 6c02229c5f2..14e84ed86cb 100644 --- a/src/extensions/core/webcamCapture.ts +++ b/src/extensions/core/webcamCapture.ts @@ -1,4 +1,6 @@ import { t } from '@/i18n' +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { INumericWidget } from '@/lib/litegraph/src/types/widgets' import { useToastStore } from '@/platform/updates/common/toastStore' import { api } from '../../scripts/api' @@ -6,15 +8,19 @@ import { app } from '../../scripts/app' const WEBCAM_READY = Symbol() +interface WebcamNode extends LGraphNode { + [WEBCAM_READY]?: Promise +} + app.registerExtension({ name: 'Comfy.WebcamCapture', getCustomWidgets() { return { - WEBCAM(node, inputName) { - // @ts-expect-error fixme ts strict error - let res - // @ts-expect-error fixme ts strict error - node[WEBCAM_READY] = new Promise((resolve) => (res = resolve)) + WEBCAM(node: WebcamNode, inputName: string) { + let resolveVideo: (video: HTMLVideoElement) => void + node[WEBCAM_READY] = new Promise((resolve) => { + resolveVideo = resolve + }) const container = document.createElement('div') container.style.background = 'rgba(0,0,0,0.25)' @@ -31,10 +37,15 @@ app.registerExtension({ }) container.replaceChildren(video) - // @ts-expect-error fixme ts strict error - setTimeout(() => res(video), 500) // Fallback as loadedmetadata doesnt fire sometimes? - // @ts-expect-error fixme ts strict error - video.addEventListener('loadedmetadata', () => res(video), false) + let resolved = false + const resolve = () => { + if (resolved) return + resolved = true + clearTimeout(fallbackTimeout) + resolveVideo(video) + } + const fallbackTimeout = setTimeout(resolve, 500) + video.addEventListener('loadedmetadata', resolve, { once: true }) video.srcObject = stream video.play() } catch (error) { @@ -44,16 +55,16 @@ app.registerExtension({ label.style.maxHeight = '100%' label.style.whiteSpace = 'pre-wrap' + const errorMessage = + error instanceof Error ? error.message : String(error) if (window.isSecureContext) { label.textContent = 'Unable to load webcam, please ensure access is granted:\n' + - // @ts-expect-error fixme ts strict error - error.message + errorMessage } else { label.textContent = 'Unable to load webcam. A secure context is required, if you are not accessing ComfyUI on localhost (127.0.0.1) you will have to enable TLS (https)\n\n' + - // @ts-expect-error fixme ts strict error - error.message + errorMessage } container.replaceChildren(label) @@ -66,32 +77,31 @@ app.registerExtension({ } } }, - nodeCreated(node) { + nodeCreated(node: WebcamNode) { if ((node.type, node.constructor.comfyClass !== 'WebcamCapture')) return - // @ts-expect-error fixme ts strict error - let video - // @ts-expect-error fixme ts strict error - const camera = node.widgets.find((w) => w.name === 'image') - // @ts-expect-error fixme ts strict error - const w = node.widgets.find((w) => w.name === 'width') - // @ts-expect-error fixme ts strict error - const h = node.widgets.find((w) => w.name === 'height') - // @ts-expect-error fixme ts strict error - const captureOnQueue = node.widgets.find( + let video: HTMLVideoElement | undefined + const camera = node.widgets?.find((w) => w.name === 'image') + const widthWidget = node.widgets?.find((w) => w.name === 'width') as + | INumericWidget + | undefined + const heightWidget = node.widgets?.find((w) => w.name === 'height') as + | INumericWidget + | undefined + const captureOnQueue = node.widgets?.find( (w) => w.name === 'capture_on_queue' ) const canvas = document.createElement('canvas') const capture = () => { - // @ts-expect-error widget value type narrow down - canvas.width = w.value - // @ts-expect-error widget value type narrow down - canvas.height = h.value + if (!widthWidget || !heightWidget || !video) return + const width = widthWidget.value ?? 640 + const height = heightWidget.value ?? 480 + canvas.width = width + canvas.height = height const ctx = canvas.getContext('2d') - // @ts-expect-error widget value type narrow down - ctx.drawImage(video, 0, 0, w.value, h.value) + ctx?.drawImage(video, 0, 0, width, height) const data = canvas.toDataURL('image/png') const img = new Image() @@ -112,48 +122,47 @@ app.registerExtension({ btn.disabled = true btn.serializeValue = () => undefined - // @ts-expect-error fixme ts strict error - camera.serializeValue = async () => { - // @ts-expect-error fixme ts strict error - if (captureOnQueue.value) { - capture() - } else if (!node.imgs?.length) { - const err = `No webcam image captured` - useToastStore().addAlert(err) - throw new Error(err) - } + if (camera) { + camera.serializeValue = async () => { + if (captureOnQueue?.value) { + capture() + } else if (!node.imgs?.length) { + const err = `No webcam image captured` + useToastStore().addAlert(err) + throw new Error(err) + } - // Upload image to temp storage - // @ts-expect-error fixme ts strict error - const blob = await new Promise((r) => canvas.toBlob(r)) - const name = `${+new Date()}.png` - const file = new File([blob], name) - const body = new FormData() - body.append('image', file) - body.append('subfolder', 'webcam') - body.append('type', 'temp') - const resp = await api.fetchApi('/upload/image', { - method: 'POST', - body - }) - if (resp.status !== 200) { - const err = `Error uploading camera image: ${resp.status} - ${resp.statusText}` - useToastStore().addAlert(err) - throw new Error(err) + // Upload image to temp storage + const blob = await new Promise((resolve, reject) => { + canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('No blob')))) + }) + const name = `${+new Date()}.png` + const file = new File([blob], name) + const body = new FormData() + body.append('image', file) + body.append('subfolder', 'webcam') + body.append('type', 'temp') + const resp = await api.fetchApi('/upload/image', { + method: 'POST', + body + }) + if (resp.status !== 200) { + const err = `Error uploading camera image: ${resp.status} - ${resp.statusText}` + useToastStore().addAlert(err) + throw new Error(err) + } + return `webcam/${name} [temp]` } - return `webcam/${name} [temp]` } - // @ts-expect-error fixme ts strict error - node[WEBCAM_READY].then((v) => { + node[WEBCAM_READY]?.then((v) => { video = v // If width isn't specified then use video output resolution - // @ts-expect-error fixme ts strict error - if (!w.value) { - // @ts-expect-error fixme ts strict error - w.value = video.videoWidth || 640 - // @ts-expect-error fixme ts strict error - h.value = video.videoHeight || 480 + if (widthWidget && !widthWidget.value) { + widthWidget.value = video.videoWidth || 640 + } + if (heightWidget && !heightWidget.value) { + heightWidget.value = video.videoHeight || 480 } btn.disabled = false btn.label = t('g.capture') diff --git a/src/extensions/core/widgetInputs.ts b/src/extensions/core/widgetInputs.ts index c7f9f6eb828..637e256ceb4 100644 --- a/src/extensions/core/widgetInputs.ts +++ b/src/extensions/core/widgetInputs.ts @@ -9,9 +9,15 @@ import type { ISlotType, LLink } from '@/lib/litegraph/src/litegraph' +import type { IWidgetLocator } from '@/lib/litegraph/src/interfaces' import { NodeSlot } from '@/lib/litegraph/src/node/NodeSlot' import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events' -import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' +import type { + IBaseWidget, + IComboWidget, + INumericWidget +} from '@/lib/litegraph/src/types/widgets' +import { isPrimitiveNode } from '@/renderer/utils/nodeTypeGuards' import type { InputSpec } from '@/schemas/nodeDefSchema' import { app } from '@/scripts/app' import { @@ -22,7 +28,16 @@ import { import { CONFIG, GET_CONFIG } from '@/services/litegraphService' import { mergeInputSpec } from '@/utils/nodeDefUtil' import { applyTextReplacements } from '@/utils/searchAndReplace' -import { isPrimitiveNode } from '@/renderer/utils/nodeTypeGuards' + +/** + * Widget locator with CONFIG symbol properties for accessing input spec. + * Used on input/output slots to retrieve widget configuration. + */ +interface IWidgetLocatorWithConfig extends IWidgetLocator { + name: string + [GET_CONFIG]?: () => InputSpec + [CONFIG]?: InputSpec +} const replacePropertyName = 'Run widget replace on values' export class PrimitiveNode extends LGraphNode { @@ -89,14 +104,21 @@ export class PrimitiveNode extends LGraphNode { override refreshComboInNode() { const widget = this.widgets?.[0] if (widget?.type === 'combo') { - // @ts-expect-error fixme ts strict error - widget.options.values = this.outputs[0].widget[GET_CONFIG]()[0] - - // @ts-expect-error fixme ts strict error - if (!widget.options.values.includes(widget.value as string)) { - // @ts-expect-error fixme ts strict error - widget.value = widget.options.values[0] - ;(widget.callback as Function)(widget.value) + const comboWidget = widget as IComboWidget + const widgetLocator = this.outputs[0].widget as IWidgetLocatorWithConfig + const config = widgetLocator?.[GET_CONFIG]?.() + const rawValues = config?.[0] + if (Array.isArray(rawValues)) { + const newValues = rawValues.map(String) + comboWidget.options.values = newValues + if (!newValues.includes(String(comboWidget.value))) { + if (newValues.length > 0) { + comboWidget.value = newValues[0] + comboWidget.callback?.(comboWidget.value) + } else { + comboWidget.value = '' + } + } } } } @@ -186,15 +208,16 @@ export class PrimitiveNode extends LGraphNode { const input = theirNode.inputs[link.target_slot] if (!input) return - let widget + let widget: IWidgetLocatorWithConfig if (!input.widget) { - if (!(input.type in ComfyWidgets)) return - widget = { name: input.name, [GET_CONFIG]: () => [input.type, {}] } //fake widget + if (typeof input.type !== 'string' || !(input.type in ComfyWidgets)) + return + const inputType = input.type + widget = { name: input.name, [GET_CONFIG]: () => [inputType, {}] } } else { - widget = input.widget + widget = input.widget as IWidgetLocatorWithConfig } - // @ts-expect-error fixme ts strict error const config = widget[GET_CONFIG]?.() if (!config) return @@ -208,8 +231,7 @@ export class PrimitiveNode extends LGraphNode { widget[CONFIG] ?? config, theirNode, widget.name, - // @ts-expect-error fixme ts strict error - recreating + recreating ?? false ) } @@ -227,12 +249,12 @@ export class PrimitiveNode extends LGraphNode { // Store current size as addWidget resizes the node const [oldWidth, oldHeight] = this.size - let widget: IBaseWidget + let widget: IBaseWidget | undefined if (isValidWidgetType(type)) { widget = (ComfyWidgets[type](this, 'value', inputData, app) || {}).widget } else { - // @ts-expect-error InputSpec is not typed correctly - widget = this.addWidget(type, 'value', null, () => {}, {}) + // Unknown widget type - use 'custom' as fallback + widget = this.addWidget('custom', 'value', type, () => {}, {}) } if (node?.widgets && widget) { @@ -474,7 +496,7 @@ export function mergeIfValid( output: INodeOutputSlot | INodeInputSlot, config2: InputSpec, forceUpdate?: boolean, - recreateWidget?: () => void, + recreateWidget?: () => IBaseWidget | undefined, config1?: InputSpec ): { customConfig: InputSpec[1] } { if (!config1) { @@ -484,25 +506,20 @@ export function mergeIfValid( const customSpec = mergeInputSpec(config1, config2) if (customSpec || forceUpdate) { - if (customSpec) { - // @ts-expect-error fixme ts strict error - output.widget[CONFIG] = customSpec + if (customSpec && output.widget) { + const widgetLocator = output.widget as IWidgetLocatorWithConfig + widgetLocator[CONFIG] = customSpec } - // @ts-expect-error fixme ts strict error - const widget = recreateWidget?.call(this) - // When deleting a node this can be null - if (widget) { - // @ts-expect-error fixme ts strict error - const min = widget.options.min - // @ts-expect-error fixme ts strict error - const max = widget.options.max - // @ts-expect-error fixme ts strict error - if (min != null && widget.value < min) widget.value = min - // @ts-expect-error fixme ts strict error - if (max != null && widget.value > max) widget.value = max - // @ts-expect-error fixme ts strict error - widget.callback(widget.value) + const widget = recreateWidget?.() + if (widget?.type === 'number') { + const numericWidget = widget as INumericWidget + const { min, max } = numericWidget.options + let currentValue = numericWidget.value ?? 0 + if (min != null && currentValue < min) currentValue = min + if (max != null && currentValue > max) currentValue = max + numericWidget.value = currentValue + numericWidget.callback?.(currentValue) } } @@ -512,7 +529,6 @@ export function mergeIfValid( app.registerExtension({ name: 'Comfy.WidgetInputs', async beforeRegisterNodeDef(nodeType, _nodeData, app) { - // @ts-expect-error adding extra property nodeType.prototype.convertWidgetToInput = function (this: LGraphNode) { console.warn( 'Please remove call to convertWidgetToInput. Widget to socket conversion is no longer necessary, as they co-exist now.' diff --git a/src/lib/litegraph/src/CurveEditor.ts b/src/lib/litegraph/src/CurveEditor.ts index 4a536ce0ace..b1d28d408d1 100644 --- a/src/lib/litegraph/src/CurveEditor.ts +++ b/src/lib/litegraph/src/CurveEditor.ts @@ -45,8 +45,7 @@ export class CurveEditor { draw( ctx: CanvasRenderingContext2D, size: Rect, - // @ts-expect-error - LGraphCanvas parameter type needs fixing - graphcanvas?: LGraphCanvas, + _graphcanvas?: LGraphCanvas, background_color?: string, line_color?: string, inactive = false diff --git a/src/lib/litegraph/src/LGraph.test.ts b/src/lib/litegraph/src/LGraph.test.ts index 1c2f38da287..8d622e7d6b3 100644 --- a/src/lib/litegraph/src/LGraph.test.ts +++ b/src/lib/litegraph/src/LGraph.test.ts @@ -39,11 +39,12 @@ describe('LGraph', () => { expect(result1).toEqual(result2) }) test('can be instantiated', ({ expect }) => { - // @ts-expect-error Intentional - extra holds any / all consumer data that should be serialised - const graph = new LGraph({ extra: 'TestGraph' }) + // extra holds any / all consumer data that should be serialised + const graph = new LGraph({ + extra: 'TestGraph' + } as unknown as ConstructorParameters[0]) expect(graph).toBeInstanceOf(LGraph) expect(graph.extra).toBe('TestGraph') - expect(graph.extra).toBe('TestGraph') }) test('is exactly the same type', async ({ expect }) => { @@ -211,12 +212,13 @@ describe('Graph Clearing and Callbacks', () => { describe('Legacy LGraph Compatibility Layer', () => { test('can be extended via prototype', ({ expect, minimalGraph }) => { - // @ts-expect-error Should always be an error. - LGraph.prototype.newMethod = function () { - return 'New method added via prototype' - } - // @ts-expect-error Should always be an error. - expect(minimalGraph.newMethod()).toBe('New method added via prototype') + ;(LGraph.prototype as unknown as Record).newMethod = + function () { + return 'New method added via prototype' + } + expect( + (minimalGraph as unknown as Record string>).newMethod() + ).toBe('New method added via prototype') }) test('is correctly assigned to LiteGraph', ({ expect }) => { diff --git a/src/lib/litegraph/src/LGraph.ts b/src/lib/litegraph/src/LGraph.ts index 561836d1eb8..8ca15e58a38 100644 --- a/src/lib/litegraph/src/LGraph.ts +++ b/src/lib/litegraph/src/LGraph.ts @@ -15,7 +15,7 @@ import { LGraphGroup } from './LGraphGroup' import { LGraphNode } from './LGraphNode' import type { NodeId } from './LGraphNode' import { LLink } from './LLink' -import type { LinkId, SerialisedLLinkArray } from './LLink' +import type { LinkId } from './LLink' import { MapProxyHandler } from './MapProxyHandler' import { Reroute } from './Reroute' import type { RerouteId } from './Reroute' @@ -63,6 +63,7 @@ import type { LGraphTriggerHandler, LGraphTriggerParam } from './types/graphTriggers' +import type { GroupNodeWorkflowData } from '@/extensions/core/groupNodeTypes' import type { ExportedSubgraph, ExposedWidget, @@ -74,6 +75,8 @@ import type { } from './types/serialisation' import { getAllNestedItems } from './utils/collections' +export type { GroupNodeWorkflowData } from '@/extensions/core/groupNodeTypes' + export type { LGraphTriggerAction, LGraphTriggerParam @@ -102,18 +105,6 @@ export interface LGraphConfig { links_ontop?: boolean } -export interface GroupNodeWorkflowData { - external: (number | string)[][] - links: SerialisedLLinkArray[] - nodes: { - index?: number - type?: string - inputs?: unknown[] - outputs?: unknown[] - }[] - config?: Record -} - export interface LGraphExtra extends Dictionary { reroutes?: SerialisableReroute[] linkExtensions?: { id: number; parentId: number | undefined }[] @@ -157,7 +148,8 @@ export class LGraph 'widgets', 'inputNode', 'outputNode', - 'extra' + 'extra', + 'version' ]) id: UUID = zeroUuid @@ -206,7 +198,7 @@ export class LGraph last_update_time: number = 0 starttime: number = 0 catch_errors: boolean = true - execution_timer_id?: number | null + execution_timer_id?: ReturnType | number | null errors_in_execution?: boolean /** @deprecated Unused */ execution_time!: number @@ -215,9 +207,12 @@ export class LGraph /** Must contain serialisable values, e.g. primitive types */ config: LGraphConfig = {} vars: Dictionary = {} - nodes_executing: boolean[] = [] - nodes_actioning: (string | boolean)[] = [] - nodes_executedAction: string[] = [] + /** @deprecated Use a Map or dedicated state management instead */ + nodes_executing: Record = {} + /** @deprecated Use a Map or dedicated state management instead */ + nodes_actioning: Record = {} + /** @deprecated Use a Map or dedicated state management instead */ + nodes_executedAction: Record = {} extra: LGraphExtra = {} /** @deprecated Deserialising a workflow sets this unused property. */ @@ -296,9 +291,6 @@ export class LGraph node: LGraphNode ): void - // @ts-expect-error - Private property type needs fixing - private _input_nodes?: LGraphNode[] - /** * See {@link LGraph} * @param o data from previous serialization [optional] @@ -383,9 +375,9 @@ export class LGraph this.catch_errors = true - this.nodes_executing = [] - this.nodes_actioning = [] - this.nodes_executedAction = [] + this.nodes_executing = {} + this.nodes_actioning = {} + this.nodes_executedAction = {} // notify canvas to redraw this.change() @@ -474,7 +466,6 @@ export class LGraph on_frame() } else { // execute every 'interval' ms - // @ts-expect-error - Timer ID type mismatch needs fixing this.execution_timer_id = setInterval(() => { // execute this.onBeforeStep?.() @@ -574,9 +565,9 @@ export class LGraph this.iteration += 1 this.elapsed_time = (now - this.last_update_time) * 0.001 this.last_update_time = now - this.nodes_executing = [] - this.nodes_actioning = [] - this.nodes_executedAction = [] + this.nodes_executing = {} + this.nodes_actioning = {} + this.nodes_executedAction = {} } /** @@ -711,13 +702,22 @@ export class LGraph // sort now by priority L.sort(function (A, B) { - // @ts-expect-error ctor props - const Ap = A.constructor.priority || A.priority || 0 - // @ts-expect-error ctor props - const Bp = B.constructor.priority || B.priority || 0 + const ctorA = A.constructor + const ctorB = B.constructor + const priorityA = + 'priority' in ctorA && typeof ctorA.priority === 'number' + ? ctorA.priority + : 'priority' in A && typeof A.priority === 'number' + ? A.priority + : 0 + const priorityB = + 'priority' in ctorB && typeof ctorB.priority === 'number' + ? ctorB.priority + : 'priority' in B && typeof B.priority === 'number' + ? B.priority + : 0 // if same priority, sort by order - - return Ap == Bp ? A.order - B.order : Ap - Bp + return priorityA == priorityB ? A.order - B.order : priorityA - priorityB }) // save order number in the node, again... @@ -807,18 +807,15 @@ export class LGraph if (!nodes) return for (const node of nodes) { - // @ts-expect-error deprecated - if (!node[eventname] || node.mode != mode) continue + if (!(eventname in node) || node.mode != mode) continue + const handler = node[eventname as keyof typeof node] + if (typeof handler !== 'function') continue if (params === undefined) { - // @ts-expect-error deprecated - node[eventname]() + handler.call(node) } else if (params && params.constructor === Array) { - // @ts-expect-error deprecated - // eslint-disable-next-line prefer-spread - node[eventname].apply(node, params) + handler.apply(node, params) } else { - // @ts-expect-error deprecated - node[eventname](params) + handler.call(node, params) } } } @@ -1233,8 +1230,10 @@ export class LGraph triggerInput(name: string, value: unknown): void { const nodes = this.findNodesByTitle(name) for (const node of nodes) { - // @ts-expect-error - onTrigger method may not exist on all node types - node.onTrigger(value) + const nodeWithTrigger = node as LGraphNode & { + onTrigger?: (value: unknown) => void + } + nodeWithTrigger.onTrigger?.(value) } } @@ -1242,8 +1241,10 @@ export class LGraph setCallback(name: string, func?: () => void): void { const nodes = this.findNodesByTitle(name) for (const node of nodes) { - // @ts-expect-error - setTrigger method may not exist on all node types - node.setTrigger(func) + const nodeWithTrigger = node as LGraphNode & { + setTrigger?: (func: unknown) => void + } + nodeWithTrigger.setTrigger?.(func) } } @@ -2145,8 +2146,12 @@ export class LGraph const nodeList = !LiteGraph.use_uuids && options?.sortNodes - ? // @ts-expect-error If LiteGraph.use_uuids is false, ids are numbers. - [...this._nodes].sort((a, b) => a.id - b.id) + ? [...this._nodes].sort((a, b) => { + if (typeof a.id === 'number' && typeof b.id === 'number') { + return a.id - b.id + } + return 0 + }) : this._nodes const nodes = nodeList.map((node) => node.serialize()) @@ -2167,7 +2172,8 @@ export class LGraph if (LiteGraph.saveViewportWithGraph) extra.ds = this.#getDragAndScale() if (!extra.ds) delete extra.ds - const data: ReturnType = { + const data: SerialisableGraph & + Required> = { id, revision, version: LGraph.serialisedSchemaVersion, @@ -2197,7 +2203,7 @@ export class LGraph } protected _configureBase(data: ISerialisedGraph | SerialisableGraph): void { - const { id, extra } = data + const { id, extra, version } = data // Create a new graph ID if none is provided if (id) { @@ -2206,6 +2212,11 @@ export class LGraph this.id = createUuidv4() } + // Store the schema version from loaded data + if (typeof version === 'number') { + this.version = version + } + // Extra this.extra = extra ? structuredClone(extra) : {} @@ -2298,12 +2309,15 @@ export class LGraph const nodesData = data.nodes - // copy all stored fields - for (const i in data) { - if (LGraph.ConfigureProperties.has(i)) continue - - // @ts-expect-error #574 Legacy property assignment - this[i] = data[i] + // copy all stored fields (legacy property assignment) + // Unknown properties are stored in `extra` for backwards compat + for (const key in data) { + if (LGraph.ConfigureProperties.has(key)) continue + if (key in this) continue // Skip known properties + const value = data[key as keyof typeof data] + if (value !== undefined) { + this.extra[key] = value + } } // Subgraph definitions diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index 3e09ba5fdaa..481597cd7cb 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -1,3 +1,4 @@ +import { default as DOMPurify } from 'dompurify' import { toString } from 'es-toolkit/compat' import { PREFIX, SEPARATOR } from '@/constants/groupNodeConstants' @@ -103,11 +104,19 @@ import type { UUID } from './utils/uuid' import { BaseWidget } from './widgets/BaseWidget' import { toConcreteWidget } from './widgets/widgetMap' +function isContentEditable(el: HTMLElement | null): boolean { + while (el) { + if (el.isContentEditable) return true + el = el.parentElement + } + return false +} + interface IShowSearchOptions { - node_to?: LGraphNode | null - node_from?: LGraphNode | null - slot_from: number | INodeOutputSlot | INodeInputSlot | null | undefined - type_filter_in?: ISlotType + node_to?: SubgraphOutputNode | LGraphNode | null + node_from?: SubgraphInputNode | LGraphNode | null + slot_from?: number | INodeOutputSlot | INodeInputSlot | SubgraphIO | null + type_filter_in?: ISlotType | false type_filter_out?: ISlotType | false // TODO check for registered_slot_[in/out]_types not empty // this will be checked for functionality enabled : filter on slot type, in and out @@ -161,6 +170,15 @@ interface ICloseable { close(): void } +interface IPanel extends Element, ICloseable { + node?: LGraphNode + graph?: LGraph +} + +function isPanel(el: Element): el is IPanel { + return 'close' in el && typeof el.close === 'function' +} + interface IDialogExtensions extends ICloseable { modified(): void is_modified: boolean @@ -702,7 +720,7 @@ export class LGraphCanvas implements CustomEventDispatcher bg_tint?: string | CanvasGradient | CanvasPattern // TODO: This looks like another panel thing prompt_box?: PromptDialog | null - search_box?: HTMLDivElement + search_box?: HTMLDivElement & ICloseable /** @deprecated Panels */ SELECTED_NODE?: LGraphNode /** @deprecated Panels */ @@ -742,7 +760,7 @@ export class LGraphCanvas implements CustomEventDispatcher /** called when rendering a tooltip */ onDrawLinkTooltip?: ( ctx: CanvasRenderingContext2D, - link: LLink | null, + link: LinkSegment | null, canvas?: LGraphCanvas ) => boolean @@ -838,10 +856,7 @@ export class LGraphCanvas implements CustomEventDispatcher if ('shiftKey' in e && e.shiftKey) { if (this.allow_searchbox) { - this.showSearchBox( - e as unknown as MouseEvent, - linkReleaseContext as IShowSearchOptions - ) + this.showSearchBox(e, linkReleaseContext as IShowSearchOptions) } } else if (this.linkConnector.state.connectingTo === 'input') { this.showConnectionMenu({ @@ -1390,7 +1405,7 @@ export class LGraphCanvas implements CustomEventDispatcher _menu: ContextMenu, node: LGraphNode ): void { - const property = item.property || 'title' + const property: keyof LGraphNode = item.property || 'title' const value = node[property] const title = document.createElement('span') @@ -1484,8 +1499,13 @@ export class LGraphCanvas implements CustomEventDispatcher } else if (item.type == 'Boolean') { value = Boolean(value) } - // @ts-expect-error Requires refactor. - node[property] = value + // Set the node property - property is validated as keyof LGraphNode + if (property === 'title' && typeof value === 'string') { + node.title = value + } else if (property in node) { + // For other properties, use the properties bag + node.properties[property as string] = value + } dialog.remove() canvas.setDirty(true, true) } @@ -1503,10 +1523,9 @@ export class LGraphCanvas implements CustomEventDispatcher if (typeof values === 'object') { let desc_value = '' - for (const k in values) { - // @ts-expect-error deprecated #578 - if (values[k] != value) continue - + const valuesRecord = values as Record + for (const k in valuesRecord) { + if (valuesRecord[k] != value) continue desc_value = k break } @@ -2029,8 +2048,8 @@ export class LGraphCanvas implements CustomEventDispatcher if (!this.canvas) return window const doc = this.canvas.ownerDocument - // @ts-expect-error Check if required - return doc.defaultView || doc.parentWindow + // parentWindow is an IE-specific fallback, no longer relevant + return doc.defaultView ?? window } /** @@ -3668,8 +3687,10 @@ export class LGraphCanvas implements CustomEventDispatcher if (!graph) return let block_default = false - // @ts-expect-error EventTarget.localName is not in standard types - if (e.target.localName == 'input') return + const targetEl = e.target + if (targetEl instanceof HTMLInputElement) return + if (targetEl instanceof HTMLTextAreaElement) return + if (targetEl instanceof HTMLElement && isContentEditable(targetEl)) return if (e.type == 'keydown') { // TODO: Switch @@ -3706,16 +3727,13 @@ export class LGraphCanvas implements CustomEventDispatcher this.pasteFromClipboard({ connectInputs: e.shiftKey }) } else if (e.key === 'Delete' || e.key === 'Backspace') { // delete or backspace - // @ts-expect-error EventTarget.localName is not in standard types - if (e.target.localName != 'input' && e.target.localName != 'textarea') { - if (this.selectedItems.size === 0) { - this.#noItemsSelected() - return - } - - this.deleteSelected() - block_default = true + if (this.selectedItems.size === 0) { + this.#noItemsSelected() + return } + + this.deleteSelected() + block_default = true } // TODO @@ -4694,9 +4712,12 @@ export class LGraphCanvas implements CustomEventDispatcher const { ctx, canvas, graph, linkConnector } = this - // @ts-expect-error start2D method not in standard CanvasRenderingContext2D - if (ctx.start2D && !this.viewport) { - // @ts-expect-error start2D method not in standard CanvasRenderingContext2D + // start2D is a non-standard method (e.g., GL-backed canvas libraries) + if ( + 'start2D' in ctx && + typeof ctx.start2D === 'function' && + !this.viewport + ) { ctx.start2D() ctx.restore() ctx.setTransform(1, 0, 0, 1, 0, 0) @@ -5369,11 +5390,9 @@ export class LGraphCanvas implements CustomEventDispatcher } ctx.fill() - // @ts-expect-error TODO: Better value typing const { data } = link if (data == null) return - // @ts-expect-error TODO: Better value typing if (this.onDrawLinkTooltip?.(ctx, link, this) == true) return let text: string | null = null @@ -6649,17 +6668,13 @@ export class LGraphCanvas implements CustomEventDispatcher case 'Search': if (isFrom) { opts.showSearchBox(e, { - // @ts-expect-error - Subgraph types node_from: opts.nodeFrom, - // @ts-expect-error - Subgraph types slot_from: slotX, type_filter_in: fromSlotType }) } else { opts.showSearchBox(e, { - // @ts-expect-error - Subgraph types node_to: opts.nodeTo, - // @ts-expect-error - Subgraph types slot_from: slotX, type_filter_out: fromSlotType }) @@ -6843,9 +6858,7 @@ export class LGraphCanvas implements CustomEventDispatcher do_type_filter: LiteGraph.search_filter_enabled, // these are default: pass to set initially set values - // @ts-expect-error Property missing from interface definition type_filter_in: false, - type_filter_out: false, show_general_if_none_on_typefilter: true, show_general_after_typefiltered: true, @@ -6953,7 +6966,6 @@ export class LGraphCanvas implements CustomEventDispatcher } } - // @ts-expect-error Panel? that.search_box?.close() that.search_box = dialog @@ -7020,7 +7032,6 @@ export class LGraphCanvas implements CustomEventDispatcher opt.innerHTML = aSlots[iK] selIn.append(opt) if ( - // @ts-expect-error Property missing from interface definition options.type_filter_in !== false && String(options.type_filter_in).toLowerCase() == String(aSlots[iK]).toLowerCase() @@ -7065,13 +7076,12 @@ export class LGraphCanvas implements CustomEventDispatcher // Handles cases where the searchbox is initiated by // non-click events. e.g. Keyboard shortcuts + const defaultY = rect.top + rect.height * 0.5 const safeEvent = event ?? new MouseEvent('click', { clientX: rect.left + rect.width * 0.5, - clientY: rect.top + rect.height * 0.5, - // @ts-expect-error layerY is a nonstandard property - layerY: rect.top + rect.height * 0.5 + clientY: defaultY }) const left = safeEvent.clientX - 80 @@ -7079,9 +7089,10 @@ export class LGraphCanvas implements CustomEventDispatcher dialog.style.left = `${left}px` dialog.style.top = `${top}px` - // To avoid out of screen problems - if (safeEvent.layerY > rect.height - 200) { - helper.style.maxHeight = `${rect.height - safeEvent.layerY - 20}px` + // To avoid out of screen problems - derive layerY from clientY + const safeLayerY = event?.layerY ?? safeEvent.clientY - rect.top + if (safeLayerY > rect.height - 200) { + helper.style.maxHeight = `${rect.height - safeLayerY - 20}px` } requestAnimationFrame(function () { input.focus() @@ -7103,27 +7114,32 @@ export class LGraphCanvas implements CustomEventDispatcher } // join node after inserting - if (options.node_from) { + // These code paths only work with LGraphNode instances (not SubgraphIO nodes) + if (options.node_from && options.node_from instanceof LGraphNode) { + const nodeFrom = options.node_from // FIXME: any let iS: any = false switch (typeof options.slot_from) { case 'string': - iS = options.node_from.findOutputSlot(options.slot_from) + iS = nodeFrom.findOutputSlot(options.slot_from) break - case 'object': + case 'object': { if (options.slot_from == null) throw new TypeError( 'options.slot_from was null when showing search box' ) iS = options.slot_from.name - ? options.node_from.findOutputSlot(options.slot_from.name) + ? nodeFrom.findOutputSlot(options.slot_from.name) : -1 - // @ts-expect-error - slot_index property - if (iS == -1 && options.slot_from.slot_index !== undefined) - // @ts-expect-error - slot_index property + if ( + iS == -1 && + 'slot_index' in options.slot_from && + typeof options.slot_from.slot_index === 'number' + ) iS = options.slot_from.slot_index break + } case 'number': iS = options.slot_from break @@ -7131,44 +7147,44 @@ export class LGraphCanvas implements CustomEventDispatcher // try with first if no name set iS = 0 } - if (options.node_from.outputs[iS] !== undefined) { + if (nodeFrom.outputs?.[iS] !== undefined) { if (iS !== false && iS > -1) { if (node == null) throw new TypeError( 'options.slot_from was null when showing search box' ) - options.node_from.connectByType( - iS, - node, - options.node_from.outputs[iS].type - ) + nodeFrom.connectByType(iS, node, nodeFrom.outputs[iS].type) } } else { // console.warn("can't find slot " + options.slot_from); } } - if (options.node_to) { + if (options.node_to && options.node_to instanceof LGraphNode) { + const nodeTo = options.node_to // FIXME: any let iS: any = false switch (typeof options.slot_from) { case 'string': - iS = options.node_to.findInputSlot(options.slot_from) + iS = nodeTo.findInputSlot(options.slot_from) break - case 'object': + case 'object': { if (options.slot_from == null) throw new TypeError( 'options.slot_from was null when showing search box' ) iS = options.slot_from.name - ? options.node_to.findInputSlot(options.slot_from.name) + ? nodeTo.findInputSlot(options.slot_from.name) : -1 - // @ts-expect-error - slot_index property - if (iS == -1 && options.slot_from.slot_index !== undefined) - // @ts-expect-error - slot_index property + if ( + iS == -1 && + 'slot_index' in options.slot_from && + typeof options.slot_from.slot_index === 'number' + ) iS = options.slot_from.slot_index break + } case 'number': iS = options.slot_from break @@ -7176,18 +7192,14 @@ export class LGraphCanvas implements CustomEventDispatcher // try with first if no name set iS = 0 } - if (options.node_to.inputs[iS] !== undefined) { + if (nodeTo.inputs?.[iS] !== undefined) { if (iS !== false && iS > -1) { if (node == null) throw new TypeError( 'options.slot_from was null when showing search box' ) // try connection - options.node_to.connectByTypeOutput( - iS, - node, - options.node_to.inputs[iS].type - ) + nodeTo.connectByTypeOutput(iS, node, nodeTo.inputs[iS].type) } } else { // console.warn("can't find slot_nodeTO " + options.slot_from); @@ -7437,15 +7449,18 @@ export class LGraphCanvas implements CustomEventDispatcher input = dialog.querySelector('select') input?.addEventListener('change', function (e) { dialog.modified() - setValue((e.target as HTMLSelectElement)?.value) + if (e.target instanceof HTMLSelectElement) setValue(e.target.value) }) } else if (type == 'boolean' || type == 'toggle') { input = dialog.querySelector('input') - input?.addEventListener('click', function () { - dialog.modified() - // @ts-expect-error setValue function signature not strictly typed - setValue(!!input.checked) - }) + if (input instanceof HTMLInputElement) { + const checkbox = input + checkbox.addEventListener('click', function () { + dialog.modified() + // Convert boolean to string for setValue which expects string + setValue(checkbox.checked ? 'true' : 'false') + }) + } } else { input = dialog.querySelector('input') if (input) { @@ -7461,8 +7476,8 @@ export class LGraphCanvas implements CustomEventDispatcher v = JSON.stringify(v) } - // @ts-expect-error HTMLInputElement.value expects string but v can be other types - input.value = v + // Ensure v is converted to string for HTMLInputElement.value + input.value = String(v) input.addEventListener('keydown', function (e) { if (e.key == 'Escape') { // ESC @@ -7494,6 +7509,7 @@ export class LGraphCanvas implements CustomEventDispatcher function setValue(value: string | number | undefined) { if ( + value !== undefined && info?.values && typeof info.values === 'object' && info.values[value] != undefined @@ -7505,8 +7521,7 @@ export class LGraphCanvas implements CustomEventDispatcher value = Number(value) } if (type == 'array' || type == 'object') { - // @ts-expect-error JSON.parse doesn't care. - value = JSON.parse(value) + value = JSON.parse(String(value)) } node.properties[property] = value if (node.graph) { @@ -7801,18 +7816,18 @@ export class LGraphCanvas implements CustomEventDispatcher value_element.addEventListener('click', function (event) { const values = options.values || [] const propname = this.parentElement?.dataset['property'] - const inner_clicked = (v: string | null) => { - // node.setProperty(propname,v); - // graphcanvas.dirty_canvas = true; - this.textContent = v - innerChange(propname, v) - return false - } + const textElement = this new LiteGraph.ContextMenu(values, { event, className: 'dark', - // @ts-expect-error fixme ts strict error - callback signature mismatch - callback: inner_clicked + callback: (v?: string | IContextMenuValue) => { + // node.setProperty(propname,v); + // graphcanvas.dirty_canvas = true; + const value = typeof v === 'string' ? v : (v?.value ?? null) + textElement.textContent = value + innerChange(propname, value) + return false + } }) }) } @@ -7829,6 +7844,8 @@ export class LGraphCanvas implements CustomEventDispatcher if (typeof root.onOpen == 'function') root.onOpen() + root.graph = this.graph + return root } @@ -7864,9 +7881,11 @@ export class LGraphCanvas implements CustomEventDispatcher const inner_refresh = () => { // clear panel.content.innerHTML = '' + const ctor = node.constructor + const nodeDesc = + 'desc' in ctor && typeof ctor.desc === 'string' ? ctor.desc : '' panel.addHTML( - // @ts-expect-error - desc property - `${node.type}${node.constructor.desc || ''}` + `${DOMPurify.sanitize(node.type ?? '')}${DOMPurify.sanitize(nodeDesc)}` ) panel.addHTML('

Properties

') @@ -8023,9 +8042,8 @@ export class LGraphCanvas implements CustomEventDispatcher throw new TypeError('checkPanels - this.canvas.parentNode was null') const panels = this.canvas.parentNode.querySelectorAll('.litegraph.dialog') for (const panel of panels) { - // @ts-expect-error Panel + if (!isPanel(panel)) continue if (!panel.node) continue - // @ts-expect-error Panel if (!panel.node.graph || panel.graph != this.graph) panel.close() } } @@ -8294,8 +8312,9 @@ export class LGraphCanvas implements CustomEventDispatcher menu_info.push(...node.getExtraSlotMenuOptions(slot)) } } - // @ts-expect-error Slot type can be number and has number checks - options.title = (slot.input ? slot.input.type : slot.output.type) || '*' + // Slot type can be ISlotType which includes number, but we convert to string for title + const slotType = slot.input ? slot.input.type : slot.output?.type + options.title = String(slotType ?? '*') if (slot.input && slot.input.type == LiteGraph.ACTION) options.title = 'Action' diff --git a/src/lib/litegraph/src/LGraphNode.test.ts b/src/lib/litegraph/src/LGraphNode.test.ts index 98ef533f605..47ce522ec8d 100644 --- a/src/lib/litegraph/src/LGraphNode.test.ts +++ b/src/lib/litegraph/src/LGraphNode.test.ts @@ -38,8 +38,8 @@ describe('LGraphNode', () => { beforeEach(() => { origLiteGraph = Object.assign({}, LiteGraph) - // @ts-expect-error Intended: Force remove an otherwise readonly non-optional property - delete origLiteGraph.Classes + // Intended: Force remove an otherwise readonly non-optional property for test isolation + delete (origLiteGraph as { Classes?: unknown }).Classes Object.assign(LiteGraph, { NODE_TITLE_HEIGHT: 20, diff --git a/src/lib/litegraph/src/LGraphNode.ts b/src/lib/litegraph/src/LGraphNode.ts index 0ca41a7320f..287b1526890 100644 --- a/src/lib/litegraph/src/LGraphNode.ts +++ b/src/lib/litegraph/src/LGraphNode.ts @@ -34,6 +34,7 @@ import type { IColorable, IContextMenuValue, IFoundSlot, + ILinkRouting, INodeFlags, INodeInputSlot, INodeOutputSlot, @@ -617,7 +618,7 @@ export class LGraphNode ): void onDeselected?(this: LGraphNode): void onKeyUp?(this: LGraphNode, e: KeyboardEvent): void - onKeyDown?(this: LGraphNode, e: KeyboardEvent): void + onKeyDown?(this: LGraphNode, e: KeyboardEvent): boolean | void onSelected?(this: LGraphNode): void getExtraMenuOptions?( this: LGraphNode, @@ -784,7 +785,15 @@ export class LGraphNode if (this.graph) { this.graph._version++ } - for (const j in info) { + + // Use Record types to enable dynamic property access on both info and this + const infoRecord = info as unknown as Record + const nodeRecord = this as unknown as Record< + string, + unknown & { configure?(data: unknown): void } + > + + for (const j in infoRecord) { if (j == 'properties') { // i don't want to clone properties, I want to reuse the old container for (const k in info.properties) { @@ -794,23 +803,27 @@ export class LGraphNode continue } - // @ts-expect-error #594 - if (info[j] == null) { + const infoValue = infoRecord[j] + if (infoValue == null) { continue - // @ts-expect-error #594 - } else if (typeof info[j] == 'object') { - // @ts-expect-error #594 - if (this[j]?.configure) { - // @ts-expect-error #594 - this[j]?.configure(info[j]) + } else if (typeof infoValue == 'object') { + const nodeValue = nodeRecord[j] + if ( + nodeValue && + typeof nodeValue === 'object' && + 'configure' in nodeValue && + typeof nodeValue.configure === 'function' + ) { + nodeValue.configure(infoValue) } else { - // @ts-expect-error #594 - this[j] = LiteGraph.cloneObject(info[j], this[j]) + nodeRecord[j] = LiteGraph.cloneObject( + infoValue as object, + nodeValue as object + ) } } else { // value - // @ts-expect-error #594 - this[j] = info[j] + nodeRecord[j] = infoValue } } @@ -903,7 +916,6 @@ export class LGraphNode if (this.inputs) o.inputs = this.inputs.map((input) => inputAsSerialisable(input)) if (this.outputs) - // @ts-expect-error - Output serialization type mismatch o.outputs = this.outputs.map((output) => outputAsSerialisable(output)) if (this.title && this.title != this.constructor.title) o.title = this.title @@ -915,8 +927,10 @@ export class LGraphNode o.widgets_values = [] for (const [i, widget] of widgets.entries()) { if (widget.serialize === false) continue - // @ts-expect-error #595 No-null - o.widgets_values[i] = widget ? widget.value : null + // Widget value can be any serializable type; null is valid for missing widgets + o.widgets_values[i] = widget + ? widget.value + : (null as unknown as TWidgetValue) } } @@ -958,10 +972,14 @@ export class LGraphNode } } - // @ts-expect-error Exceptional case: id is removed so that the graph can assign a new one on add. - data.id = undefined - - if (LiteGraph.use_uuids) data.id = LiteGraph.uuidv4() + // Exceptional case: id is removed so that the graph can assign a new one on add. + // The id field is overwritten to -1 which signals the graph to assign a new id. + // When using UUIDs, a new UUID is generated immediately. + if (LiteGraph.use_uuids) { + data.id = LiteGraph.uuidv4() + } else { + data.id = -1 + } node.configure(data) @@ -1153,10 +1171,11 @@ export class LGraphNode } /** - * Returns the link info in the connection of an input slot - * @returns object or null + * Returns the link info in the connection of an input slot. + * Group nodes may override this to return ILinkRouting for internal routing. + * @returns LLink, ILinkRouting, or null */ - getInputLink(slot: number): LLink | null { + getInputLink(slot: number): LLink | ILinkRouting | null { if (!this.inputs) return null if (slot < this.inputs.length) { @@ -1324,10 +1343,6 @@ export class LGraphNode case LGraphEventMode.ALWAYS: break - // @ts-expect-error Not impl. - case LiteGraph.ON_REQUEST: - break - default: return false break @@ -1346,17 +1361,14 @@ export class LGraphNode options.action_call ||= `${this.id}_exec_${Math.floor(Math.random() * 9999)}` if (!this.graph) throw new NullGraphError() - // @ts-expect-error Technically it works when id is a string. Array gets props. this.graph.nodes_executing[this.id] = true this.onExecute(param, options) - // @ts-expect-error deprecated this.graph.nodes_executing[this.id] = false // save execution/action ref this.exec_version = this.graph.iteration if (options?.action_call) { this.action_call = options.action_call - // @ts-expect-error deprecated this.graph.nodes_executedAction[this.id] = options.action_call } } @@ -1380,16 +1392,13 @@ export class LGraphNode options.action_call ||= `${this.id}_${action || 'action'}_${Math.floor(Math.random() * 9999)}` if (!this.graph) throw new NullGraphError() - // @ts-expect-error deprecated this.graph.nodes_actioning[this.id] = action || 'actioning' this.onAction(action, param, options) - // @ts-expect-error deprecated this.graph.nodes_actioning[this.id] = false // save execution/action ref if (options?.action_call) { this.action_call = options.action_call - // @ts-expect-error deprecated this.graph.nodes_executedAction[this.id] = options.action_call } } @@ -1838,11 +1847,13 @@ export class LGraphNode } } } - // litescene mode using the constructor - // @ts-expect-error deprecated https://github.com/Comfy-Org/litegraph.js/issues/639 - if (this.constructor[`@${property}`]) - // @ts-expect-error deprecated https://github.com/Comfy-Org/litegraph.js/issues/639 - info = this.constructor[`@${property}`] + // litescene mode using the constructor (deprecated) + const ctor = this.constructor as unknown as Record< + string, + INodePropertyInfo | undefined + > + const ctorPropertyInfo = ctor[`@${property}`] + if (ctorPropertyInfo) info = ctorPropertyInfo if (this.constructor.widgets_info?.[property]) info = this.constructor.widgets_info[property] @@ -1896,8 +1907,7 @@ export class LGraphNode } const w: IBaseWidget & { type: Type } = { - // @ts-expect-error - Type casting for widget type property - type: type.toLowerCase(), + type: type.toLowerCase() as Type, name: name, value: value, callback: typeof callback !== 'function' ? undefined : callback, @@ -3396,8 +3406,8 @@ export class LGraphNode trace(msg: string): void { this.console ||= [] this.console.push(msg) - // @ts-expect-error deprecated - if (this.console.length > LGraphNode.MAX_CONSOLE) this.console.shift() + const maxConsole = LGraphNode.MAX_CONSOLE ?? 100 + if (this.console.length > maxConsole) this.console.shift() } /* Forces to redraw or the main canvas (LGraphNode) or the bg canvas (links) */ diff --git a/src/lib/litegraph/src/LLink.ts b/src/lib/litegraph/src/LLink.ts index 6ca8832a175..af7dab2d6ce 100644 --- a/src/lib/litegraph/src/LLink.ts +++ b/src/lib/litegraph/src/LLink.ts @@ -11,6 +11,7 @@ import type { LGraphNode, NodeId } from './LGraphNode' import type { Reroute, RerouteId } from './Reroute' import type { CanvasColour, + ILinkRouting, INodeInputSlot, INodeOutputSlot, ISlotType, @@ -89,7 +90,9 @@ type BasicReadonlyNetwork = Pick< > // this is the class in charge of storing link information -export class LLink implements LinkSegment, Serialisable { +export class LLink + implements LinkSegment, Serialisable, ILinkRouting +{ static _drawDebug = false /** Link ID */ diff --git a/src/lib/litegraph/src/LiteGraphGlobal.ts b/src/lib/litegraph/src/LiteGraphGlobal.ts index 6a9cb5db666..eb077b11d3e 100644 --- a/src/lib/litegraph/src/LiteGraphGlobal.ts +++ b/src/lib/litegraph/src/LiteGraphGlobal.ts @@ -412,10 +412,11 @@ export class LiteGraphGlobal { base_class.title ||= classname - // extend class - for (const i in LGraphNode.prototype) { - // @ts-expect-error #576 This functionality is deprecated and should be removed. - base_class.prototype[i] ||= LGraphNode.prototype[i] + // extend class (deprecated - should be using proper class inheritance) + const nodeProto = LGraphNode.prototype as unknown as Record + const baseProto = base_class.prototype as unknown as Record + for (const i in nodeProto) { + baseProto[i] ||= nodeProto[i] } const prev = this.registered_node_types[type] @@ -460,20 +461,24 @@ export class LiteGraphGlobal { * @param slot_type name of the slot type (variable type), eg. string, number, array, boolean, .. */ registerNodeAndSlotType( - type: LGraphNode, + type: LGraphNode | string, slot_type: ISlotType, out?: boolean ): void { out ||= false + // Handle both string type names and node instances const base_class = - typeof type === 'string' && - // @ts-expect-error Confirm this function no longer supports string types - base_class should always be an instance not a constructor. - this.registered_node_types[type] !== 'anonymous' + typeof type === 'string' && this.registered_node_types[type] ? this.registered_node_types[type] : type - // @ts-expect-error Confirm this function no longer supports string types - base_class should always be an instance not a constructor. - const class_type = base_class.constructor.type + // Get the type from the constructor for node classes + const ctor = + typeof base_class !== 'string' ? base_class.constructor : undefined + const class_type = + ctor && 'type' in ctor && typeof ctor.type === 'string' + ? ctor.type + : undefined let allTypes = [] if (typeof slot_type === 'string') { @@ -493,7 +498,8 @@ export class LiteGraphGlobal { register[slotType] ??= { nodes: [] } const { nodes } = register[slotType] - if (!nodes.includes(class_type)) nodes.push(class_type) + if (class_type !== undefined && !nodes.includes(class_type)) + nodes.push(class_type) // check if is a new type const types = out ? this.slot_types_out : this.slot_types_in @@ -559,11 +565,11 @@ export class LiteGraphGlobal { node.pos ||= [this.DEFAULT_POSITION[0], this.DEFAULT_POSITION[1]] node.mode ||= LGraphEventMode.ALWAYS - // extra options + // extra options (dynamic property assignment for node configuration) if (options) { + const nodeRecord = node as unknown as Record for (const i in options) { - // @ts-expect-error #577 Requires interface - node[i] = options[i] + nodeRecord[i] = (options as Record)[i] } } @@ -655,20 +661,21 @@ export class LiteGraphGlobal { } // separated just to improve if it doesn't work - /** @deprecated Prefer {@link structuredClone} */ + /** + * @deprecated Prefer {@link structuredClone} + * Note: JSON.parse returns `unknown`, so type assertions are unavoidable here. + * This function is deprecated precisely because it cannot be made type-safe. + */ cloneObject( obj: T, target?: T ): WhenNullish { if (obj == null) return null as WhenNullish - const r = JSON.parse(JSON.stringify(obj)) - if (!target) return r + const cloned: unknown = JSON.parse(JSON.stringify(obj)) + if (!target) return cloned as WhenNullish - for (const i in r) { - // @ts-expect-error deprecated - target[i] = r[i] - } + Object.assign(target, cloned) return target } @@ -788,33 +795,30 @@ export class LiteGraphGlobal { } } - switch (sEvent) { - // both pointer and move events - case 'down': - case 'up': - case 'move': - case 'over': - case 'out': - // @ts-expect-error - intentional fallthrough - case 'enter': { - oDOM.addEventListener(sMethod + sEvent, fCall, capture) - } - // only pointerevents - // falls through - case 'leave': - case 'cancel': - case 'gotpointercapture': - // @ts-expect-error - intentional fallthrough - case 'lostpointercapture': { - if (sMethod != 'mouse') { - return oDOM.addEventListener(sMethod + sEvent, fCall, capture) - } + // Events that apply to both pointer and mouse methods + const pointerAndMouseEvents = ['down', 'up', 'move', 'over', 'out', 'enter'] + // Events that only apply to pointer method + const pointerOnlyEvents = [ + 'leave', + 'cancel', + 'gotpointercapture', + 'lostpointercapture' + ] + + if (pointerAndMouseEvents.includes(sEvent)) { + oDOM.addEventListener(sMethod + sEvent, fCall, capture) + } + + if ( + pointerAndMouseEvents.includes(sEvent) || + pointerOnlyEvents.includes(sEvent) + ) { + if (sMethod != 'mouse') { + return oDOM.addEventListener(sMethod + sEvent, fCall, capture) } - // not "pointer" || "mouse" - // falls through - default: - return oDOM.addEventListener(sEvent, fCall, capture) } + + return oDOM.addEventListener(sEvent, fCall, capture) } pointerListenerRemove( @@ -831,45 +835,37 @@ export class LiteGraphGlobal { ) return - switch (sEvent) { - // both pointer and move events - case 'down': - case 'up': - case 'move': - case 'over': - case 'out': - // @ts-expect-error - intentional fallthrough - case 'enter': { - if ( - this.pointerevents_method == 'pointer' || - this.pointerevents_method == 'mouse' - ) { - oDOM.removeEventListener( - this.pointerevents_method + sEvent, - fCall, - capture - ) - } + // Events that apply to both pointer and mouse methods + const pointerAndMouseEvents = ['down', 'up', 'move', 'over', 'out', 'enter'] + // Events that only apply to pointer method + const pointerOnlyEvents = [ + 'leave', + 'cancel', + 'gotpointercapture', + 'lostpointercapture' + ] + + if (pointerAndMouseEvents.includes(sEvent)) { + if ( + this.pointerevents_method == 'pointer' || + this.pointerevents_method == 'mouse' + ) { + oDOM.removeEventListener( + this.pointerevents_method + sEvent, + fCall, + capture + ) } - // only pointerevents - // falls through - case 'leave': - case 'cancel': - case 'gotpointercapture': - // @ts-expect-error - intentional fallthrough - case 'lostpointercapture': { - if (this.pointerevents_method == 'pointer') { - return oDOM.removeEventListener( - this.pointerevents_method + sEvent, - fCall, - capture - ) - } + } else if (pointerOnlyEvents.includes(sEvent)) { + if (this.pointerevents_method == 'pointer') { + oDOM.removeEventListener( + this.pointerevents_method + sEvent, + fCall, + capture + ) } - // not "pointer" || "mouse" - // falls through - default: - return oDOM.removeEventListener(sEvent, fCall, capture) + } else { + oDOM.removeEventListener(sEvent, fCall, capture) } } diff --git a/src/lib/litegraph/src/__snapshots__/LGraph.test.ts.snap b/src/lib/litegraph/src/__snapshots__/LGraph.test.ts.snap index 1a92319bb77..bc4625f887e 100644 --- a/src/lib/litegraph/src/__snapshots__/LGraph.test.ts.snap +++ b/src/lib/litegraph/src/__snapshots__/LGraph.test.ts.snap @@ -32,7 +32,6 @@ LGraph { "title": "A group to test with", }, ], - "_input_nodes": undefined, "_last_trigger_time": undefined, "_links": Map {}, "_nodes": [ @@ -281,9 +280,9 @@ LGraph { "last_update_time": 0, "links": Map {}, "list_of_graphcanvas": null, - "nodes_actioning": [], - "nodes_executedAction": [], - "nodes_executing": [], + "nodes_actioning": {}, + "nodes_executedAction": {}, + "nodes_executing": {}, "onTrigger": undefined, "reroutesInternal": Map {}, "revision": 0, diff --git a/src/lib/litegraph/src/canvas/FloatingRenderLink.ts b/src/lib/litegraph/src/canvas/FloatingRenderLink.ts index 9f4fc92f07e..d1ebfb2cbe9 100644 --- a/src/lib/litegraph/src/canvas/FloatingRenderLink.ts +++ b/src/lib/litegraph/src/canvas/FloatingRenderLink.ts @@ -196,8 +196,7 @@ export class FloatingRenderLink implements RenderLink { } connectToRerouteInput( - // @ts-expect-error - Reroute type needs fixing - reroute: Reroute, + _reroute: Reroute, { node: inputNode, input }: { node: LGraphNode; input: INodeInputSlot }, events: CustomEventTarget ) { @@ -213,8 +212,7 @@ export class FloatingRenderLink implements RenderLink { } connectToRerouteOutput( - // @ts-expect-error - Reroute type needs fixing - reroute: Reroute, + _reroute: Reroute, outputNode: LGraphNode, output: INodeOutputSlot, events: CustomEventTarget diff --git a/src/lib/litegraph/src/interfaces.ts b/src/lib/litegraph/src/interfaces.ts index 0983770a11a..936cf99a1ff 100644 --- a/src/lib/litegraph/src/interfaces.ts +++ b/src/lib/litegraph/src/interfaces.ts @@ -184,6 +184,21 @@ export interface ItemLocator { ): SubgraphInputNode | SubgraphOutputNode | undefined } +/** + * Minimal link routing information used for input resolution. + * Both LLink and partial link info objects satisfy this interface. + */ +export interface ILinkRouting { + /** Output node ID */ + readonly origin_id: NodeId + /** Output slot index */ + readonly origin_slot: number + /** Input node ID */ + readonly target_id: NodeId + /** Input slot index */ + readonly target_slot: number +} + /** Contains a cached 2D canvas path and a centre point, with an optional forward angle. */ export interface LinkSegment { /** Link / reroute ID */ @@ -209,6 +224,9 @@ export interface LinkSegment { readonly origin_id: NodeId | undefined /** Output slot index */ readonly origin_slot: number | undefined + + /** Optional data attached to the link for tooltip display */ + data?: number | string | boolean | { toToolTip?(): string } } interface IInputOrOutput { diff --git a/src/lib/litegraph/src/litegraph.ts b/src/lib/litegraph/src/litegraph.ts index 0b24eb47b80..2e7baca1adb 100644 --- a/src/lib/litegraph/src/litegraph.ts +++ b/src/lib/litegraph/src/litegraph.ts @@ -103,10 +103,12 @@ export type { Size } from './interfaces' export { + type GroupNodeWorkflowData, LGraph, type LGraphTriggerAction, type LGraphTriggerParam } from './LGraph' + export type { LGraphTriggerEvent } from './types/graphTriggers' export { BadgePosition, LGraphBadge } from './LGraphBadge' export { LGraphCanvas } from './LGraphCanvas' diff --git a/src/lib/litegraph/src/node/NodeSlot.test.ts b/src/lib/litegraph/src/node/NodeSlot.test.ts index 6971736fc02..49f24986003 100644 --- a/src/lib/litegraph/src/node/NodeSlot.test.ts +++ b/src/lib/litegraph/src/node/NodeSlot.test.ts @@ -13,22 +13,22 @@ import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces' const boundingRect: ReadOnlyRect = [0, 0, 10, 10] describe('NodeSlot', () => { - describe('inputAsSerialisable', () => { + describe('outputAsSerialisable', () => { it('removes _data from serialized slot', () => { - const slot: INodeOutputSlot = { + const slot: INodeOutputSlot & { _data: string } = { _data: 'test data', name: 'test-id', type: 'STRING', links: [], boundingRect } - // @ts-expect-error Argument type mismatch for test const serialized = outputAsSerialisable(slot) expect(serialized).not.toHaveProperty('_data') }) + }) + describe('inputAsSerialisable', () => { it('removes pos from widget input slots', () => { - // Minimal slot for serialization test - boundingRect is calculated at runtime, not serialized const widgetInputSlot: INodeInputSlot = { name: 'test-id', pos: [10, 20], @@ -55,7 +55,6 @@ describe('NodeSlot', () => { }) it('preserves only widget name during serialization', () => { - // Extra widget properties simulate real data that should be stripped during serialization const widgetInputSlot: INodeInputSlot = { name: 'test-id', type: 'STRING', diff --git a/src/lib/litegraph/src/node/NodeSlot.ts b/src/lib/litegraph/src/node/NodeSlot.ts index 4e17069b28e..6d6196f3def 100644 --- a/src/lib/litegraph/src/node/NodeSlot.ts +++ b/src/lib/litegraph/src/node/NodeSlot.ts @@ -74,12 +74,12 @@ export abstract class NodeSlot extends SlotBase implements INodeSlot { slot: OptionalProps, node: LGraphNode ) { - // @ts-expect-error Workaround: Ensure internal properties are not copied to the slot (_listenerController + // Workaround: Ensure internal properties are not copied to the slot // https://github.com/Comfy-Org/litegraph.js/issues/1138 - const maybeSubgraphSlot: OptionalProps< + const maybeSubgraphSlot = slot as OptionalProps< ISubgraphInput, 'link' | 'boundingRect' - > = slot + > & { _listenerController?: unknown } const { boundingRect, name, type, _listenerController, ...rest } = maybeSubgraphSlot const rectangle = boundingRect diff --git a/src/lib/litegraph/src/node/slotUtils.ts b/src/lib/litegraph/src/node/slotUtils.ts index 712637040a2..66d08296954 100644 --- a/src/lib/litegraph/src/node/slotUtils.ts +++ b/src/lib/litegraph/src/node/slotUtils.ts @@ -5,8 +5,7 @@ import type { import type { INodeInputSlot, INodeOutputSlot, - INodeSlot, - IWidget + INodeSlot } from '@/lib/litegraph/src/litegraph' import type { ISerialisableNodeInput, @@ -63,7 +62,7 @@ export function inputAsSerialisable( } export function outputAsSerialisable( - slot: INodeOutputSlot & { widget?: IWidget } + slot: INodeOutputSlot & { widget?: { name: string } } ): ISerialisableNodeOutput { const { pos, slot_index, links, widget } = slot // Output widgets do not exist in Litegraph; this is a temporary downstream workaround. diff --git a/src/lib/litegraph/src/polyfills.ts b/src/lib/litegraph/src/polyfills.ts index 488e2478839..4b4dca8742b 100644 --- a/src/lib/litegraph/src/polyfills.ts +++ b/src/lib/litegraph/src/polyfills.ts @@ -1,7 +1,24 @@ -// @ts-expect-error Polyfill -Symbol.dispose ??= Symbol('Symbol.dispose') -// @ts-expect-error Polyfill -Symbol.asyncDispose ??= Symbol('Symbol.asyncDispose') +// Polyfill Symbol.dispose and Symbol.asyncDispose for environments that don't support them +// These are well-known symbols added in ES2024 for explicit resource management + +// Use a separate reference to Symbol constructor for creating new symbols +// This avoids TypeScript narrowing issues inside the conditional blocks +const SymbolCtor: (description?: string) => symbol = Symbol + +if (!('dispose' in Symbol)) { + Object.defineProperty(Symbol, 'dispose', { + value: SymbolCtor('Symbol.dispose'), + writable: false, + configurable: false + }) +} +if (!('asyncDispose' in Symbol)) { + Object.defineProperty(Symbol, 'asyncDispose', { + value: SymbolCtor('Symbol.asyncDispose'), + writable: false, + configurable: false + }) +} // API ************************************************* // like rect but rounded corners @@ -11,14 +28,15 @@ export function loadPolyfills() { window.CanvasRenderingContext2D && !window.CanvasRenderingContext2D.prototype.roundRect ) { - // @ts-expect-error Slightly broken polyfill - radius_low not impl. anywhere - window.CanvasRenderingContext2D.prototype.roundRect = function ( + // Legacy polyfill for roundRect with additional radius_low parameter (non-standard) + const roundRectPolyfill = function ( + this: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, radius: number | number[], - radius_low: number | number[] + radius_low?: number | number[] ) { let top_left_radius = 0 let top_right_radius = 0 @@ -78,16 +96,23 @@ export function loadPolyfills() { this.lineTo(x, y + bottom_left_radius) this.quadraticCurveTo(x, y, x + top_left_radius, y) } + + // Assign the polyfill, casting to handle the slightly different signature + window.CanvasRenderingContext2D.prototype.roundRect = + roundRectPolyfill as CanvasRenderingContext2D['roundRect'] } - if (typeof window != 'undefined' && !window['requestAnimationFrame']) { + // Legacy requestAnimationFrame polyfill for older browsers + if (typeof window != 'undefined' && !window.requestAnimationFrame) { + const win = window as Window & { + webkitRequestAnimationFrame?: typeof requestAnimationFrame + mozRequestAnimationFrame?: typeof requestAnimationFrame + } window.requestAnimationFrame = - // @ts-expect-error Legacy code - window.webkitRequestAnimationFrame || - // @ts-expect-error Legacy code - window.mozRequestAnimationFrame || + win.webkitRequestAnimationFrame || + win.mozRequestAnimationFrame || function (callback) { - window.setTimeout(callback, 1000 / 60) + return window.setTimeout(callback, 1000 / 60) } } } diff --git a/src/lib/litegraph/src/subgraph/ExecutableNodeDTO.ts b/src/lib/litegraph/src/subgraph/ExecutableNodeDTO.ts index 9e9454a817b..fdb1e6547bd 100644 --- a/src/lib/litegraph/src/subgraph/ExecutableNodeDTO.ts +++ b/src/lib/litegraph/src/subgraph/ExecutableNodeDTO.ts @@ -9,7 +9,11 @@ import type { CallbackReturn, ISlotType } from '@/lib/litegraph/src/interfaces' -import { LGraphEventMode, LiteGraph } from '@/lib/litegraph/src/litegraph' +import { + LGraphEventMode, + LiteGraph, + LLink +} from '@/lib/litegraph/src/litegraph' import type { Subgraph } from './Subgraph' import type { SubgraphNode } from './SubgraphNode' @@ -289,7 +293,7 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode { if (node.isVirtualNode) { const virtualLink = this.node.getInputLink(slot) - if (virtualLink) { + if (virtualLink instanceof LLink) { const { inputNode } = virtualLink.resolve(this.graph) if (!inputNode) throw new InvalidLinkError( diff --git a/src/lib/litegraph/src/subgraph/Subgraph.test.ts b/src/lib/litegraph/src/subgraph/Subgraph.test.ts index ecd939654bb..8e1b9e357d5 100644 --- a/src/lib/litegraph/src/subgraph/Subgraph.test.ts +++ b/src/lib/litegraph/src/subgraph/Subgraph.test.ts @@ -46,8 +46,7 @@ describe.skip('Subgraph Construction', () => { const subgraphData = createTestSubgraphData() expect(() => { - // @ts-expect-error Testing invalid null parameter - new Subgraph(null, subgraphData) + new Subgraph(null as never, subgraphData) }).toThrow('Root graph is required') }) diff --git a/src/lib/litegraph/src/subgraph/SubgraphNode.titleButton.test.ts b/src/lib/litegraph/src/subgraph/SubgraphNode.titleButton.test.ts index c76e9d5ee04..4bae0da4d0e 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphNode.titleButton.test.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphNode.titleButton.test.ts @@ -136,8 +136,7 @@ describe.skip('SubgraphNode Title Button', () => { 80 - subgraphNode.pos[1] // 80 - 100 = -20 ] - // @ts-expect-error onMouseDown possibly undefined - const handled = subgraphNode.onMouseDown( + const handled = subgraphNode.onMouseDown?.( event, clickPosRelativeToNode, canvas @@ -173,8 +172,7 @@ describe.skip('SubgraphNode Title Button', () => { 150 - subgraphNode.pos[1] // 150 - 100 = 50 ] - // @ts-expect-error onMouseDown possibly undefined - const handled = subgraphNode.onMouseDown( + const handled = subgraphNode.onMouseDown?.( event, clickPosRelativeToNode, canvas @@ -220,8 +218,7 @@ describe.skip('SubgraphNode Title Button', () => { 80 - subgraphNode.pos[1] // -20 ] - // @ts-expect-error onMouseDown possibly undefined - const handled = subgraphNode.onMouseDown( + const handled = subgraphNode.onMouseDown?.( event, clickPosRelativeToNode, canvas diff --git a/src/lib/litegraph/src/subgraph/SubgraphSerialization.test.ts b/src/lib/litegraph/src/subgraph/SubgraphSerialization.test.ts index f773fe63d54..31e6a2822da 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphSerialization.test.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphSerialization.test.ts @@ -8,12 +8,36 @@ import { describe, expect, it } from 'vitest' import { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph' +import type { ExportedSubgraph } from '@/lib/litegraph/src/types/serialisation' import { createTestSubgraph, createTestSubgraphNode } from './__fixtures__/subgraphHelpers' +function createTestExportedSubgraph( + overrides: Partial +): ExportedSubgraph { + return { + version: 1, + revision: 0, + state: { lastGroupId: 0, lastNodeId: 0, lastLinkId: 0, lastRerouteId: 0 }, + id: 'test-id', + name: 'Test Subgraph', + nodes: [], + links: [], + groups: [], + config: {}, + definitions: { subgraphs: [] }, + inputs: [], + outputs: [], + inputNode: { id: -10, bounding: [0, 0, 120, 60] }, + outputNode: { id: -20, bounding: [300, 0, 120, 60] }, + widgets: [], + ...overrides + } +} + describe.skip('SubgraphSerialization - Basic Serialization', () => { it('should save and load simple subgraphs', () => { const original = createTestSubgraph({ @@ -76,8 +100,6 @@ describe.skip('SubgraphSerialization - Basic Serialization', () => { // Verify core properties expect(restored.id).toBe(original.id) expect(restored.name).toBe(original.name) - // @ts-expect-error description property not in type definition - expect(restored.description).toBe(original.description) // Verify I/O structure expect(restored.inputs.length).toBe(original.inputs.length) @@ -229,30 +251,14 @@ describe.skip('SubgraphSerialization - Version Compatibility', () => { }) it('should load version 1.0+ format', () => { - const modernFormat = { - version: 1, // Number as expected by current implementation + const modernFormat = createTestExportedSubgraph({ id: 'test-modern-id', name: 'Modern Subgraph', - nodes: [], - links: {}, - groups: [], - config: {}, - definitions: { subgraphs: [] }, inputs: [{ id: 'input-id', name: 'modern_input', type: 'number' }], - outputs: [{ id: 'output-id', name: 'modern_output', type: 'string' }], - inputNode: { - id: -10, - bounding: [0, 0, 120, 60] - }, - outputNode: { - id: -20, - bounding: [300, 0, 120, 60] - }, - widgets: [] - } + outputs: [{ id: 'output-id', name: 'modern_output', type: 'string' }] + }) expect(() => { - // @ts-expect-error Type mismatch in ExportedSubgraph format const subgraph = new Subgraph(new LGraph(), modernFormat) expect(subgraph.name).toBe('Modern Subgraph') expect(subgraph.inputs.length).toBe(1) @@ -261,28 +267,15 @@ describe.skip('SubgraphSerialization - Version Compatibility', () => { }) it('should handle missing fields gracefully', () => { - const incompleteFormat = { - version: 1, + const incompleteFormat = createTestExportedSubgraph({ id: 'incomplete-id', name: 'Incomplete Subgraph', - nodes: [], - links: {}, - groups: [], - config: {}, - definitions: { subgraphs: [] }, - inputNode: { - id: -10, - bounding: [0, 0, 120, 60] - }, - outputNode: { - id: -20, - bounding: [300, 0, 120, 60] - } - // Missing optional: inputs, outputs, widgets - } + inputs: undefined, + outputs: undefined, + widgets: undefined + }) expect(() => { - // @ts-expect-error Type mismatch in ExportedSubgraph format const subgraph = new Subgraph(new LGraph(), incompleteFormat) expect(subgraph.name).toBe('Incomplete Subgraph') // Should have default empty arrays @@ -292,33 +285,23 @@ describe.skip('SubgraphSerialization - Version Compatibility', () => { }) it('should consider future-proofing', () => { - const futureFormat = { - version: 2, // Future version (number) + const futureFormat = createTestExportedSubgraph({ id: 'future-id', - name: 'Future Subgraph', - nodes: [], - links: {}, - groups: [], - config: {}, - definitions: { subgraphs: [] }, - inputs: [], - outputs: [], - inputNode: { - id: -10, - bounding: [0, 0, 120, 60] - }, - outputNode: { - id: -20, - bounding: [300, 0, 120, 60] - }, - widgets: [], - futureFeature: 'unknown_data' // Unknown future field - } + name: 'Future Subgraph' + }) + // Test with unknown future fields - simulating a hypothetical future version. + // NOTE: The "as unknown as ExportedSubgraph" assertion below is an intentional + // exception to normal type-safety rules. It simulates a future schema/version + // to validate deserialization resilience against forward-compatible data. + const extendedFormat = { + ...futureFormat, + version: 2 as const, + futureFeature: 'unknown_data' + } as unknown as ExportedSubgraph // Should handle future format gracefully expect(() => { - // @ts-expect-error Type mismatch in ExportedSubgraph format - const subgraph = new Subgraph(new LGraph(), futureFormat) + const subgraph = new Subgraph(new LGraph(), extendedFormat) expect(subgraph.name).toBe('Future Subgraph') }).not.toThrow() }) diff --git a/src/lib/litegraph/src/subgraph/SubgraphWidgetPromotion.test.ts b/src/lib/litegraph/src/subgraph/SubgraphWidgetPromotion.test.ts index d47ca186d9d..909fdd15536 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphWidgetPromotion.test.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphWidgetPromotion.test.ts @@ -7,6 +7,14 @@ import type { TWidgetType } from '@/lib/litegraph/src/litegraph' import { BaseWidget, LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { + DrawWidgetOptions, + WidgetEventOptions +} from '@/lib/litegraph/src/widgets/BaseWidget' +import type { + IBaseWidget, + TWidgetValue +} from '@/lib/litegraph/src/types/widgets' import { createEventCapture, @@ -14,11 +22,47 @@ import { createTestSubgraphNode } from './__fixtures__/subgraphHelpers' +/** Concrete test implementation of abstract BaseWidget */ +class TestWidget extends BaseWidget { + constructor(options: { + name: string + type: TWidgetType + value: TWidgetValue + y: number + options: Record + node: LGraphNode + tooltip?: string + }) { + super( + { + name: options.name, + type: options.type, + value: options.value, + y: options.y, + options: options.options, + tooltip: options.tooltip + } as IBaseWidget, + options.node + ) + } + + drawWidget( + _ctx: CanvasRenderingContext2D, + _options: DrawWidgetOptions + ): void { + // No-op for test + } + + onClick(_options: WidgetEventOptions): void { + // No-op for test + } +} + // Helper to create a node with a widget function createNodeWithWidget( title: string, widgetType: TWidgetType = 'number', - widgetValue: any = 42, + widgetValue: TWidgetValue = 42, slotType: ISlotType = 'number', tooltip?: string ) { @@ -26,8 +70,7 @@ function createNodeWithWidget( const input = node.addInput('value', slotType) node.addOutput('out', slotType) - // @ts-expect-error Abstract class instantiation - const widget = new BaseWidget({ + const widget = new TestWidget({ name: 'widget', type: widgetType, value: widgetValue, @@ -181,8 +224,7 @@ describe.skip('SubgraphWidgetPromotion', () => { const numInput = multiWidgetNode.addInput('num', 'number') const strInput = multiWidgetNode.addInput('str', 'string') - // @ts-expect-error Abstract class instantiation - const widget1 = new BaseWidget({ + const widget1 = new TestWidget({ name: 'widget1', type: 'number', value: 10, @@ -191,8 +233,7 @@ describe.skip('SubgraphWidgetPromotion', () => { node: multiWidgetNode }) - // @ts-expect-error Abstract class instantiation - const widget2 = new BaseWidget({ + const widget2 = new TestWidget({ name: 'widget2', type: 'string', value: 'hello', @@ -331,8 +372,7 @@ describe.skip('SubgraphWidgetPromotion', () => { const numInput = multiWidgetNode.addInput('num', 'number') const strInput = multiWidgetNode.addInput('str', 'string') - // @ts-expect-error Abstract class instantiation - const widget1 = new BaseWidget({ + const widget1 = new TestWidget({ name: 'widget1', type: 'number', value: 10, @@ -342,8 +382,7 @@ describe.skip('SubgraphWidgetPromotion', () => { tooltip: 'Number widget tooltip' }) - // @ts-expect-error Abstract class instantiation - const widget2 = new BaseWidget({ + const widget2 = new TestWidget({ name: 'widget2', type: 'string', value: 'hello', diff --git a/src/lib/litegraph/src/widgets/BaseWidget.ts b/src/lib/litegraph/src/widgets/BaseWidget.ts index a580fc69a61..f96b683f5bf 100644 --- a/src/lib/litegraph/src/widgets/BaseWidget.ts +++ b/src/lib/litegraph/src/widgets/BaseWidget.ts @@ -120,29 +120,33 @@ export abstract class BaseWidget< // `node` has no setter - Object.assign will throw. // TODO: Resolve this workaround. Ref: https://github.com/Comfy-Org/litegraph.js/issues/1022 + // Destructure known properties that could conflict with class getters/properties. + // These are typed as `unknown` to handle custom widgets that may include them. const { node: _, - // @ts-expect-error Prevent naming conflicts with custom nodes. - outline_color, - // @ts-expect-error Prevent naming conflicts with custom nodes. - background_color, - // @ts-expect-error Prevent naming conflicts with custom nodes. - height, - // @ts-expect-error Prevent naming conflicts with custom nodes. - text_color, - // @ts-expect-error Prevent naming conflicts with custom nodes. - secondary_text_color, - // @ts-expect-error Prevent naming conflicts with custom nodes. - disabledTextColor, - // @ts-expect-error Prevent naming conflicts with custom nodes. - displayName, - // @ts-expect-error Prevent naming conflicts with custom nodes. - displayValue, - // @ts-expect-error Prevent naming conflicts with custom nodes. - labelBaseline, + outline_color: _outline_color, + background_color: _background_color, + height: _height, + text_color: _text_color, + secondary_text_color: _secondary_text_color, + disabledTextColor: _disabledTextColor, + displayName: _displayName, + displayValue: _displayValue, + labelBaseline: _labelBaseline, promoted, ...safeValues - } = widget + } = widget as TWidget & { + node: LGraphNode + outline_color?: unknown + background_color?: unknown + height?: unknown + text_color?: unknown + secondary_text_color?: unknown + disabledTextColor?: unknown + displayName?: unknown + displayValue?: unknown + labelBaseline?: unknown + } Object.assign(this, safeValues) } @@ -341,8 +345,11 @@ export abstract class BaseWidget< * Correctly and safely typing this is currently not possible (practical?) in TypeScript 5.8. */ createCopyForNode(node: LGraphNode): this { - // @ts-expect-error - Constructor type casting for widget cloning - const cloned: this = new (this.constructor as typeof this)(this, node) + const WidgetConstructor = this.constructor as new ( + widget: TWidget, + node: LGraphNode + ) => this + const cloned = new WidgetConstructor(this as unknown as TWidget, node) cloned.value = this.value return cloned } diff --git a/src/lib/litegraph/src/widgets/ComboWidget.ts b/src/lib/litegraph/src/widgets/ComboWidget.ts index 2be40749941..6f46828e4dc 100644 --- a/src/lib/litegraph/src/widgets/ComboWidget.ts +++ b/src/lib/litegraph/src/widgets/ComboWidget.ts @@ -112,11 +112,12 @@ export class ComboWidget // avoids double click event options.canvas.last_mouseclick = 0 + // Handle both string and non-string values for indexOf lookup + const currentValue = this.value const foundIndex = typeof values === 'object' - ? indexedValues.indexOf(String(this.value)) + delta - : // @ts-expect-error handle non-string values - indexedValues.indexOf(this.value) + delta + ? indexedValues.indexOf(String(currentValue)) + delta + : indexedValues.indexOf(currentValue as string) + delta const index = clamp(foundIndex, 0, indexedValues.length - 1) diff --git a/src/platform/settings/components/SettingItem.vue b/src/platform/settings/components/SettingItem.vue index 91f92b2e94f..e569d16736e 100644 --- a/src/platform/settings/components/SettingItem.vue +++ b/src/platform/settings/components/SettingItem.vue @@ -40,11 +40,16 @@ const props = defineProps<{ }>() const { t } = useI18n() -function translateOptions(options: (SettingOption | string)[]) { +const settingStore = useSettingStore() +const settingValue = computed(() => settingStore.get(props.setting.id)) + +function translateOptions( + options: + | (SettingOption | string)[] + | ((value: unknown) => (SettingOption | string)[]) +) { if (typeof options === 'function') { - // @ts-expect-error: Audit and deprecate usage of legacy options type: - // (value) => [string | {text: string, value: string}] - return translateOptions(options(props.setting.value ?? '')) + return translateOptions(options(settingValue.value ?? '')) } return options.map((option) => { @@ -75,8 +80,6 @@ const formItem = computed(() => { } }) -const settingStore = useSettingStore() -const settingValue = computed(() => settingStore.get(props.setting.id)) const updateSettingValue = async ( newValue: Settings[K] ) => { diff --git a/src/platform/settings/types.ts b/src/platform/settings/types.ts index 021fac3750e..e25986d423a 100644 --- a/src/platform/settings/types.ts +++ b/src/platform/settings/types.ts @@ -49,6 +49,12 @@ export interface SettingParams extends FormItem { hideInVueNodes?: boolean } +/** + * Legacy options function type for dynamic options. + * @deprecated Use static options array instead. + */ +type LegacyOptionsFunction = (value: unknown) => Array + /** * The base form item for rendering in a form. */ @@ -57,7 +63,7 @@ export interface FormItem { type: SettingInputType | SettingCustomRenderer tooltip?: string attrs?: Record - options?: Array + options?: Array | LegacyOptionsFunction } export interface ISettingGroup { diff --git a/src/platform/updates/common/useFrontendVersionMismatchWarning.test.ts b/src/platform/updates/common/useFrontendVersionMismatchWarning.test.ts index 11a7fcbb07d..f86d6ea5439 100644 --- a/src/platform/updates/common/useFrontendVersionMismatchWarning.test.ts +++ b/src/platform/updates/common/useFrontendVersionMismatchWarning.test.ts @@ -6,8 +6,11 @@ import { useToastStore } from '@/platform/updates/common/toastStore' import { useFrontendVersionMismatchWarning } from '@/platform/updates/common/useFrontendVersionMismatchWarning' import { useVersionCompatibilityStore } from '@/platform/updates/common/versionCompatibilityStore' +declare const global: typeof globalThis & { + __COMFYUI_FRONTEND_VERSION__: string +} + // Mock globals -//@ts-expect-error Define global for the test global.__COMFYUI_FRONTEND_VERSION__ = '1.0.0' // Mock config first - this needs to be before any imports diff --git a/src/platform/workflow/validation/schemas/workflowSchema.test.ts b/src/platform/workflow/validation/schemas/workflowSchema.test.ts index b075e3ebe26..c0e9aabf665 100644 --- a/src/platform/workflow/validation/schemas/workflowSchema.test.ts +++ b/src/platform/workflow/validation/schemas/workflowSchema.test.ts @@ -69,13 +69,13 @@ describe('parseComfyWorkflow', () => { // Should automatically transform the legacy format object to array. workflow.nodes[0].pos = { '0': 3, '1': 4 } let validatedWorkflow = await validateComfyWorkflow(workflow) - // @ts-expect-error fixme ts strict error - expect(validatedWorkflow.nodes[0].pos).toEqual([3, 4]) + expect(validatedWorkflow).not.toBeNull() + expect(validatedWorkflow!.nodes[0].pos).toEqual([3, 4]) workflow.nodes[0].pos = { 0: 3, 1: 4 } validatedWorkflow = await validateComfyWorkflow(workflow) - // @ts-expect-error fixme ts strict error - expect(validatedWorkflow.nodes[0].pos).toEqual([3, 4]) + expect(validatedWorkflow).not.toBeNull() + expect(validatedWorkflow!.nodes[0].pos).toEqual([3, 4]) // Should accept the legacy bugged format object. // https://github.com/Comfy-Org/ComfyUI_frontend/issues/710 @@ -92,8 +92,8 @@ describe('parseComfyWorkflow', () => { '9': 0 } validatedWorkflow = await validateComfyWorkflow(workflow) - // @ts-expect-error fixme ts strict error - expect(validatedWorkflow.nodes[0].pos).toEqual([600, 340]) + expect(validatedWorkflow).not.toBeNull() + expect(validatedWorkflow!.nodes[0].pos).toEqual([600, 340]) }) it('workflow.nodes.widget_values', async () => { @@ -111,8 +111,8 @@ describe('parseComfyWorkflow', () => { // dynamic widgets display. workflow.nodes[0].widgets_values = { foo: 'bar' } const validatedWorkflow = await validateComfyWorkflow(workflow) - // @ts-expect-error fixme ts strict error - expect(validatedWorkflow.nodes[0].widgets_values).toEqual({ foo: 'bar' }) + expect(validatedWorkflow).not.toBeNull() + expect(validatedWorkflow!.nodes[0].widgets_values).toEqual({ foo: 'bar' }) }) it('workflow.links', async () => { diff --git a/src/renderer/core/layout/store/layoutStore.test.ts b/src/renderer/core/layout/store/layoutStore.test.ts index ff7ee1fa06b..fdbc8ef77ba 100644 --- a/src/renderer/core/layout/store/layoutStore.test.ts +++ b/src/renderer/core/layout/store/layoutStore.test.ts @@ -383,8 +383,8 @@ describe('layoutStore CRDT operations', () => { }) const originalTitleHeight = LiteGraph.NODE_TITLE_HEIGHT - // @ts-expect-error – intentionally simulate undefined runtime value - LiteGraph.NODE_TITLE_HEIGHT = undefined + // Intentionally simulate undefined runtime value via Object.assign + Object.assign(LiteGraph, { NODE_TITLE_HEIGHT: undefined }) try { layoutStore.setSource(LayoutSource.DOM) diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue index d4989a5e359..670a85480f4 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue @@ -64,7 +64,6 @@ function draw() { } containerHeight.value = height // Set node.canvasHeight for legacy widgets that use it (e.g., Impact Pack) - // @ts-expect-error canvasHeight is a custom property used by some extensions node.canvasHeight = height widgetInstance.y = 0 canvasEl.value.height = (height + 2) * scaleFactor diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.test.ts index ee788cc4db0..4930601c1ed 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.test.ts @@ -376,13 +376,10 @@ Another line with more content.` const vm = wrapper.vm as InstanceType // Test that the component creates a textarea reference when entering edit mode - // @ts-expect-error - isEditing is not exposed expect(vm.isEditing).toBe(false) - // @ts-expect-error - startEditing is not exposed await vm.startEditing() - // @ts-expect-error - isEditing is not exposed expect(vm.isEditing).toBe(true) await wrapper.vm.$nextTick() diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.vue index 28247859ed0..39666ddf411 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.vue @@ -29,6 +29,7 @@ diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useImageUploadWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useImageUploadWidget.ts index 78f9d0c97d7..1a776569bb9 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useImageUploadWidget.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useImageUploadWidget.ts @@ -18,14 +18,16 @@ const ACCEPTED_VIDEO_TYPES = 'video/webm,video/mp4' type InternalFile = string | ResultItem type InternalValue = InternalFile | InternalFile[] type ExposedValue = string | string[] +type WritableComboWidget = Omit & { value: ExposedValue } const isImageFile = (file: File) => file.type.startsWith('image/') const isVideoFile = (file: File) => file.type.startsWith('video/') -const findFileComboWidget = (node: LGraphNode, inputName: string) => - node.widgets!.find((w) => w.name === inputName) as IComboWidget & { - value: ExposedValue - } +const findFileComboWidget = ( + node: LGraphNode, + inputName: string +): IComboWidget | undefined => + node.widgets?.find((w): w is IComboWidget => w.name === inputName) export const useImageUploadWidget = () => { const widgetConstructor: ComfyWidgetConstructor = ( @@ -51,6 +53,9 @@ export const useImageUploadWidget = () => { const fileFilter = isVideo ? isVideoFile : isImageFile const fileComboWidget = findFileComboWidget(node, imageInputName) + if (!fileComboWidget) { + throw new Error(`Widget "${imageInputName}" not found on node`) + } const initialFile = `${fileComboWidget.value}` const formatPath = (value: InternalFile) => createAnnotatedPath(value, { rootFolder: image_folder }) @@ -80,10 +85,9 @@ export const useImageUploadWidget = () => { output.forEach((path) => addToComboValues(fileComboWidget, path)) // Create a NEW array to ensure Vue reactivity detects the change - const newValue = allow_batch ? [...output] : output[0] - - // @ts-expect-error litegraph combo value type does not support arrays yet - fileComboWidget.value = newValue + // Value property is redefined via Object.defineProperty to support batch uploads + const newValue: ExposedValue = allow_batch ? [...output] : output[0] + ;(fileComboWidget as unknown as WritableComboWidget).value = newValue fileComboWidget.callback?.(newValue) } }) @@ -103,9 +107,9 @@ export const useImageUploadWidget = () => { // Add our own callback to the combo widget to render an image when it changes fileComboWidget.callback = function () { - nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, { - isAnimated - }) + // Image upload widget value is always a string path, never a number + const value = fileComboWidget.value as string | string[] + nodeOutputStore.setNodeOutputs(node, value, { isAnimated }) node.graph?.setDirtyCanvas(true) } @@ -113,9 +117,8 @@ export const useImageUploadWidget = () => { // The value isn't set immediately so we need to wait a moment // No change callbacks seem to be fired on initial setting of the value requestAnimationFrame(() => { - nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, { - isAnimated - }) + const value = fileComboWidget.value as string | string[] + nodeOutputStore.setNodeOutputs(node, value, { isAnimated }) showPreview({ block: false }) }) diff --git a/src/schemas/nodeDef/migration.test.ts b/src/schemas/nodeDef/migration.test.ts index 7250918f118..b087ae94687 100644 --- a/src/schemas/nodeDef/migration.test.ts +++ b/src/schemas/nodeDef/migration.test.ts @@ -221,9 +221,9 @@ describe('NodeDef Migration', () => { } const result = transformNodeDefV1ToV2(nodeDef) + const inputWithHidden = plainObject as { hidden?: Record } - // @ts-expect-error fixme ts strict error - expect(result.hidden).toEqual(plainObject.hidden) + expect(result.hidden).toEqual(inputWithHidden.hidden) expect(result.hidden?.someHiddenValue).toBe(42) expect(result.hidden?.anotherHiddenValue).toEqual({ nested: 'object' }) }) diff --git a/src/schemas/nodeDefSchema.validation.test.ts b/src/schemas/nodeDefSchema.validation.test.ts index 707eabd3e4a..b313650ff68 100644 --- a/src/schemas/nodeDefSchema.validation.test.ts +++ b/src/schemas/nodeDefSchema.validation.test.ts @@ -37,15 +37,13 @@ describe('validateNodeDef', () => { 'validateComfyNodeDef with various input spec formats', (inputSpec, expected) => { it(`should accept input spec format: ${JSON.stringify(inputSpec)}`, async () => { - expect( - // @ts-expect-error fixme ts strict error - validateComfyNodeDef({ - ...EXAMPLE_NODE_DEF, - input: { - required: inputSpec - } - }).input.required.ckpt_name - ).toEqual(expected) + const result = validateComfyNodeDef({ + ...EXAMPLE_NODE_DEF, + input: { + required: inputSpec + } + }) + expect(result?.input?.required?.ckpt_name).toEqual(expected) }) } ) diff --git a/src/scripts/app.ts b/src/scripts/app.ts index edcc64517e0..1a258f6a4a4 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -37,6 +37,7 @@ import type { NodeExecutionOutput, ResultItem } from '@/schemas/apiSchema' +import type { WorkflowAsGraph } from '@/types/workflowSchemaTypes' import { type ComfyNodeDef as ComfyNodeDefV1, isComboInputSpecV1, @@ -149,7 +150,7 @@ export class ComfyApp { static clipspace_invalidate_handler: (() => void) | null = null static open_maskeditor: (() => void) | null = null static maskeditor_is_opended: (() => void) | null = null - static clipspace_return_node = null + static clipspace_return_node: LGraphNode | null = null vueAppReady: boolean api: ComfyApi @@ -162,8 +163,8 @@ export class ComfyApp { // TODO: Migrate internal usage to the /** @deprecated Use {@link rootGraph} instead */ - get graph(): unknown { - return this.rootGraphInternal! + get graph(): LGraph | undefined { + return this.rootGraphInternal } get rootGraph(): LGraph { @@ -1194,8 +1195,8 @@ export class ComfyApp { } try { - // @ts-expect-error Discrepancies between zod and litegraph - in progress - this.rootGraph.configure(graphData) + // Type cast: ComfyWorkflowJSON → WorkflowAsGraph (see WorkflowAsGraph docs) + this.rootGraph.configure(graphData as WorkflowAsGraph) // Save original renderer version before scaling (it gets modified during scaling) const originalMainGraphRenderer = diff --git a/src/scripts/domWidget.ts b/src/scripts/domWidget.ts index a1b57496dc1..0e83ee777f3 100644 --- a/src/scripts/domWidget.ts +++ b/src/scripts/domWidget.ts @@ -197,17 +197,24 @@ abstract class BaseDOMWidgetImpl useDomWidgetStore().unregisterWidget(this.id) } - override createCopyForNode(node: LGraphNode): this { - // @ts-expect-error - const cloned: this = new (this.constructor as typeof this)({ - node: node, + /** + * Creates the constructor arguments for cloning this widget. + * Subclasses should override to add their specific properties. + */ + protected getCloneArgs(node: LGraphNode): Record { + return { + node, name: this.name, type: this.type, options: this.options - }) + } + } + + override createCopyForNode(node: LGraphNode): this { + // Safe: subclasses override getCloneArgs to return constructor-compatible args + const Ctor = this.constructor as new (args: Record) => this + const cloned = new Ctor(this.getCloneArgs(node)) cloned.value = this.value - // Preserve the Y position from the original widget to maintain proper positioning - // when widgets are promoted through subgraph nesting cloned.y = this.y return cloned } @@ -230,20 +237,11 @@ export class DOMWidgetImpl this.element = obj.element } - override createCopyForNode(node: LGraphNode): this { - // @ts-expect-error - const cloned: this = new (this.constructor as typeof this)({ - node: node, - name: this.name, - type: this.type, - element: this.element, // Include the element! - options: this.options - }) - cloned.value = this.value - // Preserve the Y position from the original widget to maintain proper positioning - // when widgets are promoted through subgraph nesting - cloned.y = this.y - return cloned + protected override getCloneArgs(node: LGraphNode): Record { + return { + ...super.getCloneArgs(node), + element: this.element + } } /** Extract DOM widget size info */ @@ -318,6 +316,15 @@ export class ComponentWidgetImpl< this.props = obj.props } + protected override getCloneArgs(node: LGraphNode): Record { + return { + ...super.getCloneArgs(node), + component: this.component, + inputSpec: this.inputSpec, + props: this.props + } + } + override computeLayoutSize() { const minHeight = this.options.getMinHeight?.() ?? 50 const maxHeight = this.options.getMaxHeight?.() diff --git a/src/scripts/metadata/avif.ts b/src/scripts/metadata/avif.ts index c0d747d9e29..9f9270d5ff3 100644 --- a/src/scripts/metadata/avif.ts +++ b/src/scripts/metadata/avif.ts @@ -210,7 +210,7 @@ function findBox( return null } -function parseAvifMetadata(buffer: ArrayBuffer): ComfyMetadata { +function parseAvifMetadata(buffer: ArrayBuffer): Partial { const metadata: ComfyMetadata = {} const dataView = new DataView(buffer) @@ -318,14 +318,18 @@ function parseAvifMetadata(buffer: ArrayBuffer): ComfyMetadata { return metadata } -// @ts-expect-error fixme ts strict error -function parseExifData(exifData) { +function parseExifData( + exifData: Uint8Array +): Record { // Check for the correct TIFF header (0x4949 for little-endian or 0x4D4D for big-endian) const isLittleEndian = String.fromCharCode(...exifData.slice(0, 2)) === 'II' // Function to read 16-bit and 32-bit integers from binary data - // @ts-expect-error fixme ts strict error - function readInt(offset, isLittleEndian, length) { + function readInt( + offset: number, + isLittleEndian: boolean, + length: number + ): number | undefined { let arr = exifData.slice(offset, offset + length) if (length === 2) { return new DataView(arr.buffer, arr.byteOffset, arr.byteLength).getUint16( @@ -343,12 +347,12 @@ function parseExifData(exifData) { // Read the offset to the first IFD (Image File Directory) const ifdOffset = readInt(4, isLittleEndian, 4) - // @ts-expect-error fixme ts strict error - function parseIFD(offset) { + function parseIFD(offset: number) { const numEntries = readInt(offset, isLittleEndian, 2) - const result = {} + const result: Record = {} + + if (numEntries === undefined) return result - // @ts-expect-error fixme ts strict error for (let i = 0; i < numEntries; i++) { const entryOffset = offset + 2 + i * 12 const tag = readInt(entryOffset, isLittleEndian, 2) @@ -358,22 +362,23 @@ function parseExifData(exifData) { // Read the value(s) based on the data type let value - if (type === 2) { + if (type === 2 && valueOffset !== undefined && numValues !== undefined) { // ASCII string value = new TextDecoder('utf-8').decode( - // @ts-expect-error fixme ts strict error exifData.subarray(valueOffset, valueOffset + numValues - 1) ) } - // @ts-expect-error fixme ts strict error - result[tag] = value + if (tag !== undefined) { + result[tag] = value + } } return result } // Parse the first IFD + if (ifdOffset === undefined) return {} const ifdData = parseIFD(ifdOffset) return ifdData } diff --git a/src/scripts/metadata/flac.ts b/src/scripts/metadata/flac.ts index 5a3efa6ac69..99aca52e7fc 100644 --- a/src/scripts/metadata/flac.ts +++ b/src/scripts/metadata/flac.ts @@ -6,8 +6,7 @@ export function getFromFlacBuffer(buffer: ArrayBuffer): Record { const signature = String.fromCharCode(...new Uint8Array(buffer, 0, 4)) if (signature !== 'fLaC') { console.error('Not a valid FLAC file') - // @ts-expect-error fixme ts strict error - return + return {} } // Parse metadata blocks @@ -30,17 +29,19 @@ export function getFromFlacBuffer(buffer: ArrayBuffer): Record { if (isLastBlock) break } - // @ts-expect-error fixme ts strict error - return vorbisComment + return vorbisComment ?? {} } export function getFromFlacFile(file: File): Promise> { return new Promise((r) => { const reader = new FileReader() reader.onload = function (event) { - // @ts-expect-error fixme ts strict error - const arrayBuffer = event.target.result as ArrayBuffer - r(getFromFlacBuffer(arrayBuffer)) + if (event.target?.result instanceof ArrayBuffer) { + r(getFromFlacBuffer(event.target.result)) + } else { + console.error('FileReader returned a non-ArrayBuffer result') + r({}) + } } reader.readAsArrayBuffer(file) }) @@ -50,14 +51,11 @@ export function getFromFlacFile(file: File): Promise> { function parseVorbisComment(dataView: DataView): Record { let offset = 0 const vendorLength = dataView.getUint32(offset, true) - offset += 4 - // @ts-expect-error unused variable - const vendorString = getString(dataView, offset, vendorLength) - offset += vendorLength + offset += 4 + vendorLength const userCommentListLength = dataView.getUint32(offset, true) offset += 4 - const comments = {} + const comments: Record = {} for (let i = 0; i < userCommentListLength; i++) { const commentLength = dataView.getUint32(offset, true) offset += 4 @@ -67,7 +65,6 @@ function parseVorbisComment(dataView: DataView): Record { const ind = comment.indexOf('=') const key = comment.substring(0, ind) - // @ts-expect-error fixme ts strict error comments[key] = comment.substring(ind + 1) } diff --git a/src/scripts/metadata/png.ts b/src/scripts/metadata/png.ts index 2e911796a91..e87f33a9155 100644 --- a/src/scripts/metadata/png.ts +++ b/src/scripts/metadata/png.ts @@ -46,8 +46,9 @@ export function getFromPngFile(file: File) { return new Promise>((r) => { const reader = new FileReader() reader.onload = (event) => { - // @ts-expect-error fixme ts strict error - r(getFromPngBuffer(event.target.result as ArrayBuffer)) + if (event.target?.result instanceof ArrayBuffer) { + r(getFromPngBuffer(event.target.result)) + } } reader.readAsArrayBuffer(file) diff --git a/src/scripts/ui.ts b/src/scripts/ui.ts index 6a428c974ef..07da9462ae3 100644 --- a/src/scripts/ui.ts +++ b/src/scripts/ui.ts @@ -94,17 +94,19 @@ export function $el( return element as ElementType } -// @ts-expect-error fixme ts strict error -function dragElement(dragEl): () => void { +function dragElement(dragEl: HTMLElement): () => void { var posDiffX = 0, posDiffY = 0, posStartX = 0, posStartY = 0, newPosX = 0, newPosY = 0 - if (dragEl.getElementsByClassName('drag-handle')[0]) { + const handle = dragEl.getElementsByClassName('drag-handle')[0] as + | HTMLElement + | undefined + if (handle) { // if present, the handle is where you move the DIV from: - dragEl.getElementsByClassName('drag-handle')[0].onmousedown = dragMouseDown + handle.onmousedown = dragMouseDown } else { // otherwise, move the DIV from anywhere inside the DIV: dragEl.onmousedown = dragMouseDown @@ -151,7 +153,6 @@ function dragElement(dragEl): () => void { dragEl.style.top = newPosY + 'px' dragEl.style.bottom = 'unset' - // @ts-expect-error fixme ts strict error if (savePos) { localStorage.setItem( 'Comfy.MenuPosition', @@ -174,13 +175,11 @@ function dragElement(dragEl): () => void { } } - // @ts-expect-error fixme ts strict error - let savePos = undefined + let savePos: boolean | undefined = undefined restorePos() savePos = true - // @ts-expect-error fixme ts strict error - function dragMouseDown(e) { + function dragMouseDown(e: MouseEvent) { e = e || window.event e.preventDefault() // get the mouse cursor position at startup: @@ -191,8 +190,7 @@ function dragElement(dragEl): () => void { document.onmousemove = elementDrag } - // @ts-expect-error fixme ts strict error - function elementDrag(e) { + function elementDrag(e: MouseEvent) { e = e || window.event e.preventDefault() @@ -230,16 +228,15 @@ function dragElement(dragEl): () => void { } class ComfyList { - #type - #text - #reverse + #type: 'queue' | 'history' + #text: string + #reverse: boolean element: HTMLDivElement button?: HTMLButtonElement - // @ts-expect-error fixme ts strict error - constructor(text, type?, reverse?) { + constructor(text: string, type?: 'queue' | 'history', reverse?: boolean) { this.#text = text - this.#type = type || text.toLowerCase() + this.#type = type || (text.toLowerCase() as 'queue' | 'history') this.#reverse = reverse || false this.element = $el('div.comfy-list') as HTMLDivElement this.element.style.display = 'none' @@ -257,46 +254,45 @@ class ComfyList { textContent: section }), $el('div.comfy-list-items', [ - // @ts-expect-error fixme ts strict error - ...(this.#reverse ? items[section].reverse() : items[section]).map( - (item: TaskItem) => { - // Allow items to specify a custom remove action (e.g. for interrupt current prompt) - const removeAction = - 'remove' in item - ? item.remove - : { - name: 'Delete', - cb: () => api.deleteItem(this.#type, item.prompt[1]) - } - return $el('div', { textContent: item.prompt[0] + ': ' }, [ - $el('button', { - textContent: 'Load', - onclick: async () => { - await app.loadGraphData( - // @ts-expect-error fixme ts strict error - item.prompt[3].extra_pnginfo.workflow, - true, - false - ) - if ('outputs' in item) { - app.nodeOutputs = {} - for (const [key, value] of Object.entries(item.outputs)) { - const realKey = item['meta']?.[key]?.display_node ?? key - app.nodeOutputs[realKey] = value - } - } + ...(this.#reverse + ? (items[section as keyof typeof items] as TaskItem[]).reverse() + : (items[section as keyof typeof items] as TaskItem[]) + ).map((item: TaskItem) => { + // Allow items to specify a custom remove action (e.g. for interrupt current prompt) + const removeAction = + 'remove' in item + ? item.remove + : { + name: 'Delete', + cb: () => api.deleteItem(this.#type, item.prompt[1]) } - }), - $el('button', { - textContent: removeAction.name, - onclick: async () => { - await removeAction.cb() - await this.update() + return $el('div', { textContent: item.prompt[0] + ': ' }, [ + $el('button', { + textContent: 'Load', + onclick: async () => { + await app.loadGraphData( + item.prompt[3].extra_pnginfo?.workflow, + true, + false + ) + if ('outputs' in item) { + app.nodeOutputs = {} + for (const [key, value] of Object.entries(item.outputs)) { + const realKey = item['meta']?.[key]?.display_node ?? key + app.nodeOutputs[realKey] = value + } } - }) - ]) - } - ) + } + }), + $el('button', { + textContent: removeAction.name, + onclick: async () => { + await removeAction.cb() + await this.update() + } + }) + ]) + }) ]) ]), $el('div.comfy-list-actions', [ @@ -320,16 +316,14 @@ class ComfyList { async show() { this.element.style.display = 'block' - // @ts-expect-error fixme ts strict error - this.button.textContent = 'Close' + if (this.button) this.button.textContent = 'Close' await this.load() } hide() { this.element.style.display = 'none' - // @ts-expect-error fixme ts strict error - this.button.textContent = 'View ' + this.#text + if (this.button) this.button.textContent = 'View ' + this.#text } toggle() { @@ -351,23 +345,15 @@ export class ComfyUI { lastQueueSize: number queue: ComfyList history: ComfyList - // @ts-expect-error fixme ts strict error - autoQueueMode: string - // @ts-expect-error fixme ts strict error - graphHasChanged: boolean - // @ts-expect-error fixme ts strict error - autoQueueEnabled: boolean - // @ts-expect-error fixme ts strict error - menuContainer: HTMLDivElement - // @ts-expect-error fixme ts strict error - queueSize: Element - // @ts-expect-error fixme ts strict error - restoreMenuPosition: () => void - // @ts-expect-error fixme ts strict error - loadFile: () => void - - // @ts-expect-error fixme ts strict error - constructor(app) { + autoQueueMode!: string + graphHasChanged!: boolean + autoQueueEnabled: boolean = false + menuContainer!: HTMLDivElement + queueSize!: Element + restoreMenuPosition!: () => void + loadFile!: () => void + + constructor(app: ComfyApp) { this.app = app this.dialog = new ComfyDialog() this.settings = new ComfySettingsDialog(app) @@ -417,9 +403,8 @@ export class ComfyUI { } ], { - // @ts-expect-error fixme ts strict error onChange: (value) => { - this.autoQueueMode = value.item.value + this.autoQueueMode = value.item.value ?? value.item.text } } ) @@ -486,14 +471,13 @@ export class ComfyUI { $el('label', { innerHTML: 'Extra options' }, [ $el('input', { type: 'checkbox', - // @ts-expect-error fixme ts strict error - onchange: (i) => { - // @ts-expect-error fixme ts strict error - document.getElementById('extraOptions').style.display = i - .srcElement.checked - ? 'block' - : 'none' - this.batchCount = i.srcElement.checked + onchange: (e: Event) => { + const extraOptions = document.getElementById('extraOptions') + const target = e.target + if (!(target instanceof HTMLInputElement) || !extraOptions) + return + extraOptions.style.display = target.checked ? 'block' : 'none' + this.batchCount = target.checked ? Number.parseInt( ( document.getElementById( @@ -524,18 +508,15 @@ export class ComfyUI { value: this.batchCount, min: '1', style: { width: '35%', marginLeft: '0.4em' }, - // @ts-expect-error fixme ts strict error - oninput: (i) => { - this.batchCount = i.target.value - /* Even though an element with a type of range logically represents a number (since - it's used for numeric input), the value it holds is still treated as a string in HTML and - JavaScript. This behavior is consistent across all elements regardless of their type - (like text, number, or range), where the .value property is always a string. */ - ;( - document.getElementById( - 'batchCountInputRange' - ) as HTMLInputElement - ).value = this.batchCount.toString() + oninput: (e: Event) => { + if (!(e.target instanceof HTMLInputElement)) return + this.batchCount = Number.parseInt(e.target.value) || 1 + const rangeInput = document.getElementById( + 'batchCountInputRange' + ) + if (rangeInput instanceof HTMLInputElement) { + rangeInput.value = this.batchCount.toString() + } } }), $el('input', { @@ -544,15 +525,15 @@ export class ComfyUI { min: '1', max: '100', value: this.batchCount, - // @ts-expect-error fixme ts strict error - oninput: (i) => { - this.batchCount = i.srcElement.value - // Note - ;( - document.getElementById( - 'batchCountInputNumber' - ) as HTMLInputElement - ).value = i.srcElement.value + oninput: (e: Event) => { + if (!(e.target instanceof HTMLInputElement)) return + this.batchCount = Number.parseInt(e.target.value) || 1 + const numberInput = document.getElementById( + 'batchCountInputNumber' + ) + if (numberInput instanceof HTMLInputElement) { + numberInput.value = e.target.value + } } }) ]), @@ -566,8 +547,8 @@ export class ComfyUI { type: 'checkbox', checked: false, title: 'Automatically queue prompt when the queue size hits 0', - // @ts-expect-error fixme ts strict error - onchange: (e) => { + onchange: (e: Event) => { + if (!(e.target instanceof HTMLInputElement)) return this.autoQueueEnabled = e.target.checked autoQueueModeEl.style.display = this.autoQueueEnabled ? '' @@ -682,8 +663,8 @@ export class ComfyUI { this.restoreMenuPosition = dragElement(this.menuContainer) - // @ts-expect-error - this.setStatus({ exec_info: { queue_remaining: 'X' } }) + // Initialize with placeholder text before first status update + this.queueSize.textContent = 'Queue size: X' } setStatus(status: StatusWsMessageStatus | null) { diff --git a/src/scripts/ui/components/asyncDialog.ts b/src/scripts/ui/components/asyncDialog.ts index 1da1a5a4efe..457d040ad0f 100644 --- a/src/scripts/ui/components/asyncDialog.ts +++ b/src/scripts/ui/components/asyncDialog.ts @@ -1,28 +1,30 @@ import { $el } from '../../ui' import { ComfyDialog } from '../dialog' -export class ComfyAsyncDialog extends ComfyDialog { - // @ts-expect-error fixme ts strict error - #resolve: (value: any) => void +type DialogAction = string | { value?: T; text: string } - constructor(actions?: Array) { +export class ComfyAsyncDialog< + T = string +> extends ComfyDialog { + #resolve!: (value: T | string | null) => void + + constructor(actions?: Array>) { super( 'dialog.comfy-dialog.comfyui-dialog', - // @ts-expect-error fixme ts strict error actions?.map((opt) => { - if (typeof opt === 'string') { - opt = { text: opt } - } + const option = typeof opt === 'string' ? { text: opt } : opt return $el('button.comfyui-button', { type: 'button', - textContent: opt.text, - onclick: () => this.close(opt.value ?? opt.text) + textContent: option.text, + onclick: () => this.close(option.value ?? option.text) }) }) ) } - override show(html: string | HTMLElement | HTMLElement[]) { + override show( + html: string | HTMLElement | HTMLElement[] + ): Promise { this.element.addEventListener('close', () => { this.close() }) @@ -34,7 +36,9 @@ export class ComfyAsyncDialog extends ComfyDialog { }) } - showModal(html: string | HTMLElement | HTMLElement[]) { + showModal( + html: string | HTMLElement | HTMLElement[] + ): Promise { this.element.addEventListener('close', () => { this.close() }) @@ -47,22 +51,22 @@ export class ComfyAsyncDialog extends ComfyDialog { }) } - override close(result = null) { + override close(result: T | string | null = null): void { this.#resolve(result) this.element.close() super.close() } - static async prompt({ + static async prompt({ title = null, message, actions }: { title: string | null message: string - actions: Array - }) { - const dialog = new ComfyAsyncDialog(actions) + actions: Array> + }): Promise { + const dialog = new ComfyAsyncDialog(actions) const content = [$el('span', message)] if (title) { content.unshift($el('h3', title)) diff --git a/src/scripts/ui/components/button.ts b/src/scripts/ui/components/button.ts index 7a4aae6b676..b29545ac902 100644 --- a/src/scripts/ui/components/button.ts +++ b/src/scripts/ui/components/button.ts @@ -26,18 +26,17 @@ export class ComfyButton implements ComfyComponent { isOver = false iconElement = $el('i.mdi') contentElement = $el('span') - // @ts-expect-error fixme ts strict error - popup: ComfyPopup + popup: ComfyPopup | null = null element: HTMLElement - overIcon: string - iconSize: number - content: string | HTMLElement - icon: string - tooltip: string - classList: ClassList - hidden: boolean - enabled: boolean - action: (e: Event, btn: ComfyButton) => void + overIcon: string | undefined + iconSize: number | undefined + content: string | HTMLElement | undefined + icon: string | undefined + tooltip: string | undefined + classList: ClassList | undefined + hidden: boolean | undefined + enabled: boolean | undefined + action: ((e: Event, btn: ComfyButton) => void) | undefined constructor({ icon, @@ -70,22 +69,18 @@ export class ComfyButton implements ComfyComponent { [this.iconElement, this.contentElement] ) - // @ts-expect-error fixme ts strict error this.icon = prop( this, 'icon', icon, toggleElement(this.iconElement, { onShow: this.updateIcon }) ) - // @ts-expect-error fixme ts strict error this.overIcon = prop(this, 'overIcon', overIcon, () => { if (this.isOver) { this.updateIcon() } }) - // @ts-expect-error fixme ts strict error this.iconSize = prop(this, 'iconSize', iconSize, this.updateIcon) - // @ts-expect-error fixme ts strict error this.content = prop( this, 'content', @@ -94,14 +89,13 @@ export class ComfyButton implements ComfyComponent { onShow: (el, v) => { if (typeof v === 'string') { el.textContent = v - } else { + } else if (v) { el.replaceChildren(v) } } }) ) - // @ts-expect-error fixme ts strict error this.tooltip = prop(this, 'tooltip', tooltip, (v) => { if (v) { this.element.title = v @@ -118,7 +112,6 @@ export class ComfyButton implements ComfyComponent { this.updateClasses() ;(this.element as HTMLButtonElement).disabled = !this.enabled }) - // @ts-expect-error fixme ts strict error this.action = prop(this, 'action', action) this.element.addEventListener('click', (e) => { if (this.popup) { @@ -130,14 +123,12 @@ export class ComfyButton implements ComfyComponent { this.action?.(e, this) }) - if (visibilitySetting?.id) { + if (visibilitySetting?.id && app?.ui?.settings) { const settingUpdated = () => { this.hidden = - // @ts-expect-error fixme ts strict error app.ui.settings.getSettingValue(visibilitySetting.id) !== visibilitySetting.showValue } - // @ts-expect-error fixme ts strict error app.ui.settings.addEventListener( visibilitySetting.id + '.change', settingUpdated @@ -163,19 +154,21 @@ export class ComfyButton implements ComfyComponent { internalClasses.push('popup-closed') } } - applyClasses(this.element, this.classList, ...internalClasses) + if (this.classList !== undefined) { + applyClasses(this.element, this.classList, ...internalClasses) + } } withPopup(popup: ComfyPopup, mode: 'click' | 'hover' = 'click') { this.popup = popup if (mode === 'hover') { - for (const el of [this.element, this.popup.element]) { + for (const el of [this.element, popup.element]) { el.addEventListener('mouseenter', () => { - this.popup.open = !!++this.#over + popup.open = !!++this.#over }) el.addEventListener('mouseleave', () => { - this.popup.open = !!--this.#over + popup.open = !!--this.#over }) } } diff --git a/src/scripts/ui/components/buttonGroup.ts b/src/scripts/ui/components/buttonGroup.ts index 06a1782f9e0..7cbb4ddbe0a 100644 --- a/src/scripts/ui/components/buttonGroup.ts +++ b/src/scripts/ui/components/buttonGroup.ts @@ -32,7 +32,8 @@ export class ComfyButtonGroup { } update() { - // @ts-expect-error fixme ts strict error - this.element.replaceChildren(...this.buttons.map((b) => b['element'] ?? b)) + this.element.replaceChildren( + ...this.buttons.map((b) => ('element' in b ? b.element : b)) + ) } } diff --git a/src/scripts/ui/components/popup.ts b/src/scripts/ui/components/popup.ts index 04bb38149c1..d6ae2baf5fb 100644 --- a/src/scripts/ui/components/popup.ts +++ b/src/scripts/ui/components/popup.ts @@ -89,8 +89,7 @@ export class ComfyPopup extends EventTarget { this.dispatchEvent(new CustomEvent('change')) } - // @ts-expect-error fixme ts strict error - #escHandler = (e) => { + #escHandler = (e: KeyboardEvent) => { if (e.key === 'Escape') { this.open = false e.preventDefault() @@ -98,9 +97,8 @@ export class ComfyPopup extends EventTarget { } } - // @ts-expect-error fixme ts strict error - #clickHandler = (e) => { - /** @type {any} */ + #clickHandler = (e: MouseEvent) => { + if (!(e.target instanceof Node)) return const target = e.target if ( !this.element.contains(target) && diff --git a/src/scripts/ui/dialog.ts b/src/scripts/ui/dialog.ts index 23d43c2bd3d..2a72f9f0faa 100644 --- a/src/scripts/ui/dialog.ts +++ b/src/scripts/ui/dialog.ts @@ -4,11 +4,10 @@ export class ComfyDialog< T extends HTMLElement = HTMLElement > extends EventTarget { element: T - // @ts-expect-error fixme ts strict error - textElement: HTMLElement - #buttons: HTMLButtonElement[] | null + textElement!: HTMLElement + #buttons: HTMLElement[] | null - constructor(type = 'div', buttons = null) { + constructor(type = 'div', buttons: HTMLElement[] | null = null) { super() this.#buttons = buttons this.element = $el(type + '.comfy-modal', { parent: document.body }, [ @@ -35,11 +34,10 @@ export class ComfyDialog< this.element.style.display = 'none' } - // @ts-expect-error fixme ts strict error - show(html) { + show(html?: string | HTMLElement | HTMLElement[]) { if (typeof html === 'string') { this.textElement.innerHTML = html - } else { + } else if (html) { this.textElement.replaceChildren( ...(html instanceof Array ? html : [html]) ) diff --git a/src/scripts/ui/draggableList.ts b/src/scripts/ui/draggableList.ts index 895d153b7a1..626c2a34339 100644 --- a/src/scripts/ui/draggableList.ts +++ b/src/scripts/ui/draggableList.ts @@ -40,87 +40,107 @@ styleElement.textContent = ` document.head.append(styleElement) export class DraggableList extends EventTarget { - listContainer - // @ts-expect-error fixme ts strict error - draggableItem - // @ts-expect-error fixme ts strict error - pointerStartX - // @ts-expect-error fixme ts strict error - pointerStartY - // @ts-expect-error fixme ts strict error - scrollYMax + listContainer: HTMLElement + draggableItem: HTMLElement | null = null + pointerStartX: number = 0 + pointerStartY: number = 0 + scrollYMax: number = 0 itemsGap = 0 - items = [] - itemSelector + items: HTMLElement[] = [] + itemSelector: string handleClass = 'drag-handle' - off = [] - offDrag = [] + off: (() => void)[] = [] + offDrag: (() => void)[] = [] - // @ts-expect-error fixme ts strict error - constructor(element, itemSelector) { + constructor(element: HTMLElement, itemSelector: string) { super() this.listContainer = element this.itemSelector = itemSelector if (!this.listContainer) return - // @ts-expect-error fixme ts strict error this.off.push(this.on(this.listContainer, 'mousedown', this.dragStart)) - // @ts-expect-error fixme ts strict error this.off.push(this.on(this.listContainer, 'touchstart', this.dragStart)) - // @ts-expect-error fixme ts strict error this.off.push(this.on(document, 'mouseup', this.dragEnd)) - // @ts-expect-error fixme ts strict error this.off.push(this.on(document, 'touchend', this.dragEnd)) } - getAllItems() { + getAllItems(): HTMLElement[] { if (!this.items?.length) { this.items = Array.from( - this.listContainer.querySelectorAll(this.itemSelector) + this.listContainer.querySelectorAll(this.itemSelector) ) this.items.forEach((element) => { - // @ts-expect-error fixme ts strict error element.classList.add('is-idle') }) } return this.items } - getIdleItems() { + getIdleItems(): HTMLElement[] { return this.getAllItems().filter((item) => - // @ts-expect-error fixme ts strict error item.classList.contains('is-idle') ) } - // @ts-expect-error fixme ts strict error - isItemAbove(item) { + isItemAbove(item: HTMLElement): boolean { return item.hasAttribute('data-is-above') } - // @ts-expect-error fixme ts strict error - isItemToggled(item) { + isItemToggled(item: HTMLElement): boolean { return item.hasAttribute('data-is-toggled') } - // @ts-expect-error fixme ts strict error - on(source, event, listener, options?) { - listener = listener.bind(this) - source.addEventListener(event, listener, options) - return () => source.removeEventListener(event, listener) + on( + source: Document, + event: K, + listener: (e: DocumentEventMap[K]) => void, + options?: AddEventListenerOptions + ): () => void + on( + source: HTMLElement, + event: K, + listener: (e: HTMLElementEventMap[K]) => void, + options?: AddEventListenerOptions + ): () => void + on( + source: Document | HTMLElement, + event: string, + listener: (e: Event) => void, + options?: AddEventListenerOptions + ): () => void { + const boundListener = listener.bind(this) + source.addEventListener(event, boundListener, options) + return () => source.removeEventListener(event, boundListener) } - // @ts-expect-error fixme ts strict error - dragStart(e) { - if (e.target.classList.contains(this.handleClass)) { - this.draggableItem = e.target.closest(this.itemSelector) + getPointerCoordinates( + e: MouseEvent | TouchEvent + ): { clientX: number; clientY: number } | null { + if ('clientX' in e) { + return { clientX: e.clientX, clientY: e.clientY } } + const touch = e.touches?.[0] ?? e.changedTouches?.[0] + if (!touch) return null + return { clientX: touch.clientX, clientY: touch.clientY } + } + + dragStart(e: MouseEvent | TouchEvent) { + const target = e.target + if (!(target instanceof HTMLElement)) return + if (!target.classList.contains(this.handleClass)) return + + this.draggableItem = target.closest(this.itemSelector) if (!this.draggableItem) return - this.pointerStartX = e.clientX || e.touches[0].clientX - this.pointerStartY = e.clientY || e.touches[0].clientY + const coords = this.getPointerCoordinates(e) + if (!coords) return + + const { clientX, clientY } = coords + + this.pointerStartX = clientX + this.pointerStartY = clientY this.scrollYMax = this.listContainer.scrollHeight - this.listContainer.clientHeight @@ -128,10 +148,8 @@ export class DraggableList extends EventTarget { this.initDraggableItem() this.initItemsState() - // @ts-expect-error fixme ts strict error this.offDrag.push(this.on(document, 'mousemove', this.drag)) this.offDrag.push( - // @ts-expect-error fixme ts strict error this.on(document, 'touchmove', this.drag, { passive: false }) ) @@ -139,7 +157,6 @@ export class DraggableList extends EventTarget { new CustomEvent('dragstart', { detail: { element: this.draggableItem, - // @ts-expect-error fixme ts strict error position: this.getAllItems().indexOf(this.draggableItem) } }) @@ -155,37 +172,37 @@ export class DraggableList extends EventTarget { const item1 = this.getIdleItems()[0] const item2 = this.getIdleItems()[1] - // @ts-expect-error fixme ts strict error const item1Rect = item1.getBoundingClientRect() - // @ts-expect-error fixme ts strict error const item2Rect = item2.getBoundingClientRect() this.itemsGap = Math.abs(item1Rect.bottom - item2Rect.top) } initItemsState() { + const draggable = this.draggableItem + if (!draggable) return this.getIdleItems().forEach((item, i) => { - // @ts-expect-error fixme ts strict error - if (this.getAllItems().indexOf(this.draggableItem) > i) { - // @ts-expect-error fixme ts strict error + if (this.getAllItems().indexOf(draggable) > i) { item.dataset.isAbove = '' } }) } initDraggableItem() { + if (!this.draggableItem) return this.draggableItem.classList.remove('is-idle') this.draggableItem.classList.add('is-draggable') } - // @ts-expect-error fixme ts strict error - drag(e) { + drag(e: MouseEvent | TouchEvent) { if (!this.draggableItem) return + const coords = this.getPointerCoordinates(e) + if (!coords) return + e.preventDefault() - const clientX = e.clientX || e.touches[0].clientX - const clientY = e.clientY || e.touches[0].clientY + const { clientX, clientY } = coords const listRect = this.listContainer.getBoundingClientRect() @@ -207,28 +224,24 @@ export class DraggableList extends EventTarget { } updateIdleItemsStateAndPosition() { + if (!this.draggableItem) return const draggableItemRect = this.draggableItem.getBoundingClientRect() const draggableItemY = draggableItemRect.top + draggableItemRect.height / 2 // Update state this.getIdleItems().forEach((item) => { - // @ts-expect-error fixme ts strict error const itemRect = item.getBoundingClientRect() const itemY = itemRect.top + itemRect.height / 2 if (this.isItemAbove(item)) { if (draggableItemY <= itemY) { - // @ts-expect-error fixme ts strict error item.dataset.isToggled = '' } else { - // @ts-expect-error fixme ts strict error delete item.dataset.isToggled } } else { if (draggableItemY >= itemY) { - // @ts-expect-error fixme ts strict error item.dataset.isToggled = '' } else { - // @ts-expect-error fixme ts strict error delete item.dataset.isToggled } } @@ -238,10 +251,8 @@ export class DraggableList extends EventTarget { this.getIdleItems().forEach((item) => { if (this.isItemToggled(item)) { const direction = this.isItemAbove(item) ? 1 : -1 - // @ts-expect-error fixme ts strict error item.style.transform = `translateY(${direction * (draggableItemRect.height + this.itemsGap)}px)` } else { - // @ts-expect-error fixme ts strict error item.style.transform = '' } }) @@ -255,7 +266,8 @@ export class DraggableList extends EventTarget { } applyNewItemsOrder() { - const reorderedItems = [] + if (!this.draggableItem) return + const reorderedItems: HTMLElement[] = [] let oldPosition = -1 this.getAllItems().forEach((item, index) => { @@ -282,7 +294,6 @@ export class DraggableList extends EventTarget { this.listContainer.appendChild(item) }) - // @ts-expect-error fixme ts strict error this.items = reorderedItems this.dispatchEvent( @@ -302,13 +313,13 @@ export class DraggableList extends EventTarget { this.unsetDraggableItem() this.unsetItemState() - // @ts-expect-error fixme ts strict error this.offDrag.forEach((f) => f()) this.offDrag = [] } unsetDraggableItem() { - this.draggableItem.style = null + if (!this.draggableItem) return + this.draggableItem.style.transform = '' this.draggableItem.classList.remove('is-draggable') this.draggableItem.classList.add('is-idle') this.draggableItem = null @@ -316,17 +327,13 @@ export class DraggableList extends EventTarget { unsetItemState() { this.getIdleItems().forEach((item) => { - // @ts-expect-error fixme ts strict error delete item.dataset.isAbove - // @ts-expect-error fixme ts strict error delete item.dataset.isToggled - // @ts-expect-error fixme ts strict error item.style.transform = '' }) } dispose() { - // @ts-expect-error fixme ts strict error this.off.forEach((f) => f()) } } diff --git a/src/scripts/ui/imagePreview.ts b/src/scripts/ui/imagePreview.ts index da8516283a0..d70c4646d38 100644 --- a/src/scripts/ui/imagePreview.ts +++ b/src/scripts/ui/imagePreview.ts @@ -3,13 +3,15 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import { app } from '../app' import { $el } from '../ui' +interface ImageLike { + naturalWidth: number + naturalHeight: number +} + export function calculateImageGrid( - // @ts-expect-error fixme ts strict error - imgs, - // @ts-expect-error fixme ts strict error - dw, - // @ts-expect-error fixme ts strict error - dh + imgs: ImageLike[], + dw: number, + dh: number ): { cellWidth: number cellHeight: number @@ -22,7 +24,11 @@ export function calculateImageGrid( let h = imgs[0].naturalHeight const numImages = imgs.length - let cellWidth, cellHeight, cols, rows, shiftX + let cellWidth = 0 + let cellHeight = 0 + let cols = 1 + let rows = 1 + let shiftX = 0 // compact style for (let c = 1; c <= numImages; c++) { const r = Math.ceil(numImages / c) @@ -46,63 +52,47 @@ export function calculateImageGrid( } } - // @ts-expect-error fixme ts strict error return { cellWidth, cellHeight, cols, rows, shiftX } } /** @knipIgnoreUnusedButUsedByCustomNodes */ export function createImageHost(node: LGraphNode) { const el = $el('div.comfy-img-preview') - // @ts-expect-error fixme ts strict error - let currentImgs + let currentImgs: HTMLImageElement[] | null = null let first = true function updateSize() { - let w = null - let h = null + if (!currentImgs) return - // @ts-expect-error fixme ts strict error - if (currentImgs) { - let elH = el.clientHeight - if (first) { - first = false - // On first run, if we are small then grow a bit - if (elH < 190) { - elH = 190 - } - el.style.setProperty('--comfy-widget-min-height', elH.toString()) - } else { - el.style.setProperty('--comfy-widget-min-height', null) + let elH = el.clientHeight + if (first) { + first = false + // On first run, if we are small then grow a bit + if (elH < 190) { + elH = 190 } + el.style.setProperty('--comfy-widget-min-height', elH.toString()) + } else { + el.style.removeProperty('--comfy-widget-min-height') + } - const nw = node.size[0] - ;({ cellWidth: w, cellHeight: h } = calculateImageGrid( - currentImgs, - nw - 20, - elH - )) - // @ts-expect-error fixme ts strict error - w += 'px' - // @ts-expect-error fixme ts strict error - h += 'px' + const nw = node.size[0] + const { cellWidth, cellHeight } = calculateImageGrid( + currentImgs, + nw - 20, + elH + ) - // @ts-expect-error fixme ts strict error - el.style.setProperty('--comfy-img-preview-width', w) - // @ts-expect-error fixme ts strict error - el.style.setProperty('--comfy-img-preview-height', h) - } + el.style.setProperty('--comfy-img-preview-width', `${cellWidth}px`) + el.style.setProperty('--comfy-img-preview-height', `${cellHeight}px`) } return { el, getCurrentImage() { - // @ts-expect-error fixme ts strict error return currentImgs?.[0] }, - // @ts-expect-error fixme ts strict error - updateImages(imgs) { - // @ts-expect-error fixme ts strict error + updateImages(imgs: HTMLImageElement[]) { if (imgs !== currentImgs) { - // @ts-expect-error fixme ts strict error if (currentImgs == null) { requestAnimationFrame(() => { updateSize() @@ -126,10 +116,9 @@ export function createImageHost(node: LGraphNode) { ) el.style.pointerEvents = 'none' - if (!over) return + if (!over || !currentImgs) return // Set the overIndex so Open Image etc work - // @ts-expect-error fixme ts strict error - const idx = currentImgs.indexOf(over) + const idx = currentImgs.indexOf(over as HTMLImageElement) node.overIndex = idx } } diff --git a/src/scripts/ui/toggleSwitch.ts b/src/scripts/ui/toggleSwitch.ts index 0d16c672779..c87ce3a6c16 100644 --- a/src/scripts/ui/toggleSwitch.ts +++ b/src/scripts/ui/toggleSwitch.ts @@ -1,46 +1,47 @@ import { $el } from '../ui' -/** - * @typedef { { text: string, value?: string, tooltip?: string } } ToggleSwitchItem - */ +interface ToggleSwitchItem { + text: string + value?: string + tooltip?: string + selected?: boolean +} + /** * Creates a toggle switch element - * @param { string } name - * @param { Array | ToggleSwitchItem } items - * @param { Object } [opts] - * @param { (e: { item: ToggleSwitchItem, prev?: ToggleSwitchItem }) => void } [opts.onChange] */ -// @ts-expect-error fixme ts strict error -export function toggleSwitch(name, items, e?) { +export function toggleSwitch( + name: string, + items: (string | ToggleSwitchItem)[], + e?: { + onChange?: (e: { item: ToggleSwitchItem; prev?: ToggleSwitchItem }) => void + } +) { const onChange = e?.onChange - // @ts-expect-error fixme ts strict error - let selectedIndex - // @ts-expect-error fixme ts strict error - let elements + const normalizedItems: ToggleSwitchItem[] = items.map((item) => { + if (typeof item === 'string') { + return { text: item, value: item } + } + return { ...item, value: item.value ?? item.text } + }) + + let selectedIndex: number | null = null + let elements: HTMLLabelElement[] - // @ts-expect-error fixme ts strict error - function updateSelected(index) { - // @ts-expect-error fixme ts strict error + function updateSelected(index: number) { if (selectedIndex != null) { - // @ts-expect-error fixme ts strict error elements[selectedIndex].classList.remove('comfy-toggle-selected') } onChange?.({ - item: items[index], - // @ts-expect-error fixme ts strict error - prev: selectedIndex == null ? undefined : items[selectedIndex] + item: normalizedItems[index], + prev: selectedIndex == null ? undefined : normalizedItems[selectedIndex] }) selectedIndex = index - // @ts-expect-error fixme ts strict error elements[selectedIndex].classList.add('comfy-toggle-selected') } - // @ts-expect-error fixme ts strict error - elements = items.map((item, i) => { - if (typeof item === 'string') item = { text: item } - if (!item.value) item.value = item.text - + elements = normalizedItems.map((item, i) => { const toggle = $el( 'label', { @@ -66,7 +67,10 @@ export function toggleSwitch(name, items, e?) { const container = $el('div.comfy-toggle-switch', elements) if (selectedIndex == null) { - elements[0].children[0].checked = true + const firstInput = elements[0].children[0] + if (firstInput instanceof HTMLInputElement) { + firstInput.checked = true + } updateSelected(0) } diff --git a/src/scripts/ui/utils.ts b/src/scripts/ui/utils.ts index a3fece0b4e7..040402ca64f 100644 --- a/src/scripts/ui/utils.ts +++ b/src/scripts/ui/utils.ts @@ -26,21 +26,19 @@ export function applyClasses( } } -export function toggleElement( +export function toggleElement( element: HTMLElement, { onHide, onShow }: { onHide?: (el: HTMLElement) => void - // @ts-expect-error fixme ts strict error - onShow?: (el: HTMLElement, value) => void + onShow?: (el: HTMLElement, value: T) => void } = {} ) { let placeholder: HTMLElement | Comment let hidden: boolean - // @ts-expect-error fixme ts strict error - return (value) => { + return (value: T) => { if (value) { if (hidden) { hidden = false diff --git a/src/scripts/utils.ts b/src/scripts/utils.ts index 6fbdc7dcc11..ec2b1931a01 100644 --- a/src/scripts/utils.ts +++ b/src/scripts/utils.ts @@ -76,15 +76,12 @@ export function prop( name: string ) => void ): T { - // @ts-expect-error fixme ts strict error - let currentValue + let currentValue: T = defaultValue Object.defineProperty(target, name, { get() { - // @ts-expect-error fixme ts strict error return currentValue }, set(newValue) { - // @ts-expect-error fixme ts strict error const prevValue = currentValue currentValue = newValue onChanged?.(currentValue, prevValue, target, name) diff --git a/src/scripts/widgets.ts b/src/scripts/widgets.ts index 50dabe011f3..bd846c7a5a0 100644 --- a/src/scripts/widgets.ts +++ b/src/scripts/widgets.ts @@ -3,6 +3,7 @@ import { type LGraphNode, isComboWidget } from '@/lib/litegraph/src/litegraph' import type { IBaseWidget, IComboWidget, + INumericWidget, IStringWidget } from '@/lib/litegraph/src/types/widgets' import { useSettingStore } from '@/platform/settings/settingStore' @@ -27,6 +28,24 @@ import type { ComfyApp } from './app' import './domWidget' import './errorNodeWidgets' +type ComboValuesType = IComboWidget['options']['values'] + +/** + * Normalizes combo widget values to an array. + * Handles the case where values may be a dictionary (Record) + * or a legacy function type. + */ +function getComboValuesArray( + values: ComboValuesType | undefined, + widget?: IComboWidget, + node?: LGraphNode +): string[] { + if (!values) return [] + if (typeof values === 'function') return values(widget, node) + if (Array.isArray(values)) return values + return Object.keys(values) +} + export type ComfyWidgetConstructorV2 = ( node: LGraphNode, inputSpec: InputSpecV2 @@ -143,9 +162,11 @@ export function addValueControlWidgets( const isCombo = isComboWidget(targetWidget) let comboFilter: IStringWidget - if (isCombo && valueControl.options.values) { - // @ts-expect-error Combo widget values may be a dictionary or legacy function type - valueControl.options.values.push('increment-wrap') + if (isCombo) { + const controlValues = valueControl.options.values + if (Array.isArray(controlValues)) { + controlValues.push('increment-wrap') + } } if (isCombo && options.addFilterList !== false) { comboFilter = node.addWidget( @@ -165,13 +186,18 @@ export function addValueControlWidgets( } const applyWidgetControl = () => { - var v = valueControl.value + const v = valueControl.value if (isCombo && v !== 'fixed') { - let values = targetWidget.options.values ?? [] + const comboWidget = targetWidget as IComboWidget + let values = getComboValuesArray( + comboWidget.options.values, + comboWidget, + node + ) const filter = comboFilter?.value if (filter) { - let check + let check: ((item: string) => boolean) | undefined if (filter.startsWith('/') && filter.endsWith('/')) { try { const regex = new RegExp(filter.substring(1, filter.length - 1)) @@ -188,18 +214,23 @@ export function addValueControlWidgets( const lower = filter.toLocaleLowerCase() check = (item: string) => item.toLocaleLowerCase().includes(lower) } - // @ts-expect-error Combo widget values may be a dictionary or legacy function type - values = values.filter((item: string) => check(item)) - if (!values.length && targetWidget.options.values?.length) { - console.warn( - 'Filter for node ' + node.id + ' has filtered out all items', - filter + values = values.filter(check) + if (!values.length && comboWidget.options.values) { + const originalValues = getComboValuesArray( + comboWidget.options.values, + comboWidget, + node ) + if (originalValues.length) { + console.warn( + 'Filter for node ' + node.id + ' has filtered out all items', + filter + ) + } } } - // @ts-expect-error targetWidget.value can be number or string - let current_index = values.indexOf(targetWidget.value) - let current_length = values.length + let current_index = values.indexOf(String(comboWidget.value)) + const current_length = values.length switch (v) { case 'increment': @@ -215,54 +246,45 @@ export function addValueControlWidgets( current_index -= 1 break case 'randomize': - // @ts-expect-error Combo widget values may be a dictionary or legacy function type current_index = Math.floor(Math.random() * current_length) break default: break } current_index = Math.max(0, current_index) - // @ts-expect-error Combo widget values may be a dictionary or legacy function type current_index = Math.min(current_length - 1, current_index) if (current_index >= 0) { - // @ts-expect-error Combo widget values may be a dictionary or legacy function type - let value = values[current_index] - targetWidget.value = value - targetWidget.callback?.(value) + const value = values[current_index] + comboWidget.value = value + comboWidget.callback?.(value) } - } else { - //number - let { min = 0, max = 1, step2 = 1 } = targetWidget.options - // limit to something that javascript can handle + } else if (!isCombo) { + const numericWidget = targetWidget as INumericWidget + let currentValue = numericWidget.value ?? 0 + let { min = 0, max = 1, step2 = 1 } = numericWidget.options max = Math.min(1125899906842624, max) min = Math.max(-1125899906842624, min) - let range = (max - min) / step2 + const range = (max - min) / step2 - //adjust values based on valueControl Behaviour switch (v) { case 'fixed': break case 'increment': - // @ts-expect-error targetWidget.value can be number or string - targetWidget.value += step2 + currentValue += step2 break case 'decrement': - // @ts-expect-error targetWidget.value can be number or string - targetWidget.value -= step2 + currentValue -= step2 break case 'randomize': - targetWidget.value = Math.floor(Math.random() * range) * step2 + min + currentValue = Math.floor(Math.random() * range) * step2 + min break default: break } - /*check if values are over or under their respective - * ranges and set them to min or max.*/ - // @ts-expect-error targetWidget.value can be number or string - if (targetWidget.value < min) targetWidget.value = min - // @ts-expect-error targetWidget.value can be number or string - if (targetWidget.value > max) targetWidget.value = max - targetWidget.callback?.(targetWidget.value) + if (currentValue < min) currentValue = min + if (currentValue > max) currentValue = max + numericWidget.value = currentValue + numericWidget.callback?.(currentValue) } } diff --git a/src/services/litegraphService.ts b/src/services/litegraphService.ts index e7e35458ab6..dec589ba620 100644 --- a/src/services/litegraphService.ts +++ b/src/services/litegraphService.ts @@ -201,9 +201,10 @@ export const useLitegraphService = () => { */ function addInputs(node: LGraphNode, inputs: Record) { // Use input_order if available to ensure consistent widget ordering - //@ts-expect-error was ComfyNode.nodeData as ComfyNodeDefImpl - const nodeDefImpl = node.constructor.nodeData as ComfyNodeDefImpl - const orderedInputSpecs = getOrderedInputSpecs(nodeDefImpl, inputs) + const orderedInputSpecs = getOrderedInputSpecs( + node.constructor.nodeData, + inputs + ) // Create sockets and widgets in the determined order for (const inputSpec of orderedInputSpecs) addInputSocket(node, inputSpec) @@ -512,8 +513,8 @@ export const useLitegraphService = () => { const url = new URL(img.src) url.searchParams.delete('preview') - // @ts-expect-error fixme ts strict error - const writeImage = async (blob) => { + const writeImage = async (blob: Blob | null) => { + if (!blob) return await navigator.clipboard.write([ new ClipboardItem({ [blob.type]: blob @@ -534,32 +535,28 @@ export const useLitegraphService = () => { height: img.naturalHeight }) as HTMLCanvasElement const ctx = canvas.getContext('2d') - // @ts-expect-error fixme ts strict error - let image + if (!ctx) throw new Error('Failed to get canvas context') + + let image: HTMLImageElement | ImageBitmap if (typeof window.createImageBitmap === 'undefined') { - image = new Image() - const p = new Promise((resolve, reject) => { - // @ts-expect-error fixme ts strict error - image.onload = resolve - // @ts-expect-error fixme ts strict error - image.onerror = reject + const htmlImage = new Image() + const objectUrl = URL.createObjectURL(blob) + await new Promise((resolve, reject) => { + htmlImage.onload = () => resolve() + htmlImage.onerror = () => reject() }).finally(() => { - // @ts-expect-error fixme ts strict error - URL.revokeObjectURL(image.src) + URL.revokeObjectURL(objectUrl) }) - image.src = URL.createObjectURL(blob) - await p + htmlImage.src = objectUrl + image = htmlImage } else { image = await createImageBitmap(blob) } try { - // @ts-expect-error fixme ts strict error ctx.drawImage(image, 0, 0) canvas.toBlob(writeImage, 'image/png') } finally { - // @ts-expect-error fixme ts strict error - if (typeof image.close === 'function') { - // @ts-expect-error fixme ts strict error + if ('close' in image && typeof image.close === 'function') { image.close() } } @@ -569,11 +566,10 @@ export const useLitegraphService = () => { throw error } } catch (error) { + const message = + error instanceof Error ? error.message : String(error) toastStore.addAlert( - t('toastMessages.errorCopyImage', { - // @ts-expect-error fixme ts strict error - error: error.message ?? error - }) + t('toastMessages.errorCopyImage', { error: message }) ) } } @@ -776,33 +772,29 @@ export const useLitegraphService = () => { const origNodeOnKeyDown = node.prototype.onKeyDown node.prototype.onKeyDown = function (e) { - // @ts-expect-error fixme ts strict error - if (origNodeOnKeyDown && origNodeOnKeyDown.apply(this, e) === false) { + if (origNodeOnKeyDown?.call(this, e) === false) { return false } - if (this.flags.collapsed || !this.imgs || this.imageIndex === null) { + if (this.flags.collapsed || !this.imgs || this.imageIndex == null) { return } let handled = false if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + let imageIndex = this.imageIndex if (e.key === 'ArrowLeft') { - // @ts-expect-error fixme ts strict error - this.imageIndex -= 1 + imageIndex -= 1 } else if (e.key === 'ArrowRight') { - // @ts-expect-error fixme ts strict error - this.imageIndex += 1 + imageIndex += 1 } - // @ts-expect-error fixme ts strict error - this.imageIndex %= this.imgs.length + imageIndex %= this.imgs.length - // @ts-expect-error fixme ts strict error - if (this.imageIndex < 0) { - // @ts-expect-error fixme ts strict error - this.imageIndex = this.imgs.length + this.imageIndex + if (imageIndex < 0) { + imageIndex = this.imgs.length + imageIndex } + this.imageIndex = imageIndex handled = true } else if (e.key === 'Escape') { this.imageIndex = null @@ -847,12 +839,13 @@ export const useLitegraphService = () => { nodeDef.display_name, options ) + if (!node) { + throw new Error(`Failed to create node: ${nodeDef.name}`) + } - const graph = useWorkflowStore().activeSubgraph ?? app.graph + const graph = useWorkflowStore().activeSubgraph ?? app.rootGraph - // @ts-expect-error fixme ts strict error graph.add(node) - // @ts-expect-error fixme ts strict error return node } diff --git a/src/services/newUserService.test.ts b/src/services/newUserService.test.ts index 43fcc92cddb..b0b101d15fc 100644 --- a/src/services/newUserService.test.ts +++ b/src/services/newUserService.test.ts @@ -16,9 +16,6 @@ vi.mock('@/config/version', () => ({ __COMFYUI_FRONTEND_VERSION__: '1.24.0' })) -//@ts-expect-error Define global for the test -global.__COMFYUI_FRONTEND_VERSION__ = '1.24.0' - import type { newUserService as NewUserServiceType } from '@/services/newUserService' describe('newUserService', () => { diff --git a/src/stores/imagePreviewStore.test.ts b/src/stores/imagePreviewStore.test.ts index d2bb8d639c2..b1245474659 100644 --- a/src/stores/imagePreviewStore.test.ts +++ b/src/stores/imagePreviewStore.test.ts @@ -37,8 +37,7 @@ describe('imagePreviewStore getPreviewParam', () => { it('should return empty string if node.animatedImages is true', () => { const store = useNodeOutputStore() - // @ts-expect-error `animatedImages` property is not typed - const node = createMockNode({ animatedImages: true }) + const node = Object.assign(createMockNode(), { animatedImages: true }) const outputs = createMockOutputs([{ filename: 'img.png' }]) expect(store.getPreviewParam(node, outputs)).toBe('') expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled() diff --git a/src/stores/workspace/searchBoxStore.ts b/src/stores/workspace/searchBoxStore.ts index f5acff936fc..a8711b7e13f 100644 --- a/src/stores/workspace/searchBoxStore.ts +++ b/src/stores/workspace/searchBoxStore.ts @@ -6,6 +6,22 @@ import type NodeSearchBoxPopover from '@/components/searchbox/NodeSearchBoxPopov import type { CanvasPointerEvent } from '@/lib/litegraph/src/litegraph' import { useSettingStore } from '@/platform/settings/settingStore' +function createSyntheticCanvasPointerEvent( + clientX: number, + clientY: number +): CanvasPointerEvent { + const event = new PointerEvent('click', { clientX, clientY }) + return Object.assign(event, { + layerY: clientY, + canvasX: clientX, + canvasY: clientY, + deltaX: 0, + deltaY: 0, + safeOffsetX: clientX, + safeOffsetY: clientY + }) as CanvasPointerEvent +} + export const useSearchBoxStore = defineStore('searchBox', () => { const settingStore = useSettingStore() const { x, y } = useMouse() @@ -31,14 +47,8 @@ export const useSearchBoxStore = defineStore('searchBox', () => { return } if (!popoverRef.value) return - popoverRef.value.showSearchBox( - new MouseEvent('click', { - clientX: x.value, - clientY: y.value, - // @ts-expect-error layerY is a nonstandard property - layerY: y.value - }) as unknown as CanvasPointerEvent - ) + const event = createSyntheticCanvasPointerEvent(x.value, y.value) + popoverRef.value.showSearchBox(event) } return { diff --git a/src/types/index.ts b/src/types/index.ts index 25f8e20900a..e16103ca294 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,14 @@ +import type { + ContextMenu, + DragAndScale, + LGraph, + LGraphBadge, + LGraphCanvas, + LGraphGroup, + LGraphNode, + LiteGraphGlobal, + LLink +} from '@/lib/litegraph/src/litegraph' import type { DeviceStats, EmbeddingsResponse, @@ -71,5 +82,16 @@ declare global { /** For use by extensions and in the browser console. Where possible, import `app` and access via `app.graph` instead. */ graph?: unknown + + /** LiteGraph global namespace - for extension compatibility */ + LiteGraph?: LiteGraphGlobal + LGraph?: typeof LGraph + LLink?: typeof LLink + LGraphNode?: typeof LGraphNode + LGraphGroup?: typeof LGraphGroup + DragAndScale?: typeof DragAndScale + LGraphCanvas?: typeof LGraphCanvas + ContextMenu?: typeof ContextMenu + LGraphBadge?: typeof LGraphBadge } } diff --git a/src/types/litegraph-augmentation.d.ts b/src/types/litegraph-augmentation.d.ts index 0b283b1586c..beeaa4b631e 100644 --- a/src/types/litegraph-augmentation.d.ts +++ b/src/types/litegraph-augmentation.d.ts @@ -113,7 +113,17 @@ declare module '@/lib/litegraph/src/litegraph' { ): ExecutableLGraphNode[] /** @deprecated groupNode */ convertToNodes?(): LGraphNode[] - recreate?(): Promise + /** + * @deprecated Widget to socket conversion is no longer necessary as they co-exist now. + * This method is a no-op stub for backward compatibility with extensions. + */ + convertWidgetToInput?(): boolean + /** + * Recreates this node, typically used to refresh node state after definition changes. + * Callers should await the result and handle the null case (recreation failed or was cancelled). + * @returns A promise resolving to the new node instance, or null if recreation failed. + */ + recreate?(): Promise refreshComboInNode?(defs: Record) /** @deprecated groupNode */ updateLink?(link: LLink): LLink | null @@ -143,6 +153,8 @@ declare module '@/lib/litegraph/src/litegraph' { index?: number runningInternalNodeId?: NodeId + /** @deprecated Used by PrimitiveNode for group node value propagation */ + primitiveValue?: unknown comfyClass?: string @@ -190,6 +202,11 @@ declare module '@/lib/litegraph/src/litegraph' { pasteFile?(file: File): void /** Callback for pasting multiple files into the node */ pasteFiles?(files: File[]): void + /** + * Custom property used by some extensions (e.g., Impact Pack) to store + * the canvas height for legacy widget rendering. + */ + canvasHeight?: number } /** * Only used by the Primitive node. Primitive node is using the widget property diff --git a/src/types/workflowSchemaTypes.ts b/src/types/workflowSchemaTypes.ts new file mode 100644 index 00000000000..20cbe650ecf --- /dev/null +++ b/src/types/workflowSchemaTypes.ts @@ -0,0 +1,13 @@ +import type { ISerialisedGraph } from '@/lib/litegraph/src/types/serialisation' +import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' + +/** + * ComfyWorkflowJSON and ISerialisedGraph are structurally compatible + * at runtime. This type alias documents the intentional cast between them. + * + * ComfyWorkflowJSON is the Zod-validated workflow schema from the frontend. + * ISerialisedGraph is the LiteGraph serialization format. + * + * TODO: Align these schemas to eliminate the need for this cast. + */ +export type WorkflowAsGraph = ComfyWorkflowJSON & ISerialisedGraph diff --git a/src/utils/colorUtil.test.ts b/src/utils/colorUtil.test.ts index 0f21d5d15ed..a8da6c35ece 100644 --- a/src/utils/colorUtil.test.ts +++ b/src/utils/colorUtil.test.ts @@ -180,9 +180,7 @@ describe('colorUtil - adjustColor', () => { }) it('returns the original value for null or undefined inputs', () => { - // @ts-expect-error fixme ts strict error expect(adjustColor(null, { opacity: targetOpacity })).toBe(null) - // @ts-expect-error fixme ts strict error expect(adjustColor(undefined, { opacity: targetOpacity })).toBe(undefined) }) diff --git a/src/utils/colorUtil.ts b/src/utils/colorUtil.ts index b9f2a0a3c16..8b211af73dc 100644 --- a/src/utils/colorUtil.ts +++ b/src/utils/colorUtil.ts @@ -322,10 +322,11 @@ function parseToHSLA(color: string, format: ColorFormatInternal): HSLA | null { } } -const applyColorAdjustments = ( - color: string, +function applyColorAdjustments( + color: T, options: ColorAdjustOptions -): string => { +): T | string { + if (color == null) return color if (!Object.keys(options).length) return color const format = identifyColorFormat(color) @@ -351,11 +352,11 @@ const applyColorAdjustments = ( return `hsla(${hsla.h}, ${hsla.s}%, ${hsla.l}%, ${hsla.a})` } -export const adjustColor: ( - color: string, +export const adjustColor: ( + color: T, options: ColorAdjustOptions -) => string = memoize( +) => T | string = memoize( applyColorAdjustments, - (color: string, options: ColorAdjustOptions): string => + (color: string | null | undefined, options: ColorAdjustOptions): string => `${color}-${JSON.stringify(options)}` ) diff --git a/src/utils/executionUtil.ts b/src/utils/executionUtil.ts index 3f7f982c4fb..b3374636d54 100644 --- a/src/utils/executionUtil.ts +++ b/src/utils/executionUtil.ts @@ -120,8 +120,7 @@ export const graphToPrompt = async ( inputs[input.name] = [ String(resolvedInput.origin_id), - // @ts-expect-error link.origin_slot is already number. - parseInt(resolvedInput.origin_slot) + resolvedInput.origin_slot ] } diff --git a/src/utils/litegraphUtil.ts b/src/utils/litegraphUtil.ts index 898b3ef237f..26f076a0686 100644 --- a/src/utils/litegraphUtil.ts +++ b/src/utils/litegraphUtil.ts @@ -44,10 +44,9 @@ export function isAudioNode(node: LGraphNode | undefined): boolean { export function addToComboValues(widget: IComboWidget, value: string) { if (!widget.options) widget.options = { values: [] } if (!widget.options.values) widget.options.values = [] - // @ts-expect-error Combo widget values may be a dictionary or legacy function type - if (!widget.options.values.includes(value)) { - // @ts-expect-error Combo widget values may be a dictionary or legacy function type - widget.options.values.push(value) + const { values } = widget.options + if (Array.isArray(values) && !values.includes(value)) { + values.push(value) } } diff --git a/src/utils/nodeDefUtil.test.ts b/src/utils/nodeDefUtil.test.ts index 749c818d20f..69c48a139a7 100644 --- a/src/utils/nodeDefUtil.test.ts +++ b/src/utils/nodeDefUtil.test.ts @@ -1,14 +1,56 @@ import { describe, expect, it } from 'vitest' import type { + ComboInputOptions, ComboInputSpec, ComboInputSpecV2, FloatInputSpec, InputSpec, - IntInputSpec + IntInputSpec, + NumericInputOptions +} from '@/schemas/nodeDefSchema' +import { + isComboInputSpecV2, + isFloatInputSpec, + isIntInputSpec } from '@/schemas/nodeDefSchema' import { mergeInputSpec } from '@/utils/nodeDefUtil' +type NumericResultSpec = ['INT' | 'FLOAT', NumericInputOptions] +type ComboResultSpec = ['COMBO', ComboInputOptions] + +function isNumericResultSpec( + result: InputSpec | null +): result is NumericResultSpec { + return ( + result !== null && + (isIntInputSpec(result) || isFloatInputSpec(result)) && + result[1] !== undefined + ) +} + +function expectNumericResult( + result: InputSpec | null +): asserts result is NumericResultSpec { + expect(result).not.toBeNull() + expect(isNumericResultSpec(result)).toBe(true) +} + +function isComboResultSpec( + result: InputSpec | null +): result is ComboResultSpec { + return ( + result !== null && isComboInputSpecV2(result) && result[1] !== undefined + ) +} + +function expectComboResult( + result: InputSpec | null +): asserts result is ComboResultSpec { + expect(result).not.toBeNull() + expect(isComboResultSpec(result)).toBe(true) +} + describe('nodeDefUtil', () => { describe('mergeInputSpec', () => { // Test numeric input specs (INT and FLOAT) @@ -19,12 +61,9 @@ describe('nodeDefUtil', () => { const result = mergeInputSpec(spec1, spec2) - expect(result).not.toBeNull() - expect(result?.[0]).toBe('INT') - // @ts-expect-error fixme ts strict error - expect(result?.[1].min).toBe(5) - // @ts-expect-error fixme ts strict error - expect(result?.[1].max).toBe(10) + expectNumericResult(result) + expect(result[1].min).toBe(5) + expect(result[1].max).toBe(10) }) it('should return null for INT specs with non-overlapping ranges', () => { @@ -42,12 +81,9 @@ describe('nodeDefUtil', () => { const result = mergeInputSpec(spec1, spec2) - expect(result).not.toBeNull() - expect(result?.[0]).toBe('FLOAT') - // @ts-expect-error fixme ts strict error - expect(result?.[1].min).toBe(5.5) - // @ts-expect-error fixme ts strict error - expect(result?.[1].max).toBe(10.5) + expectNumericResult(result) + expect(result[1].min).toBe(5.5) + expect(result[1].max).toBe(10.5) }) it('should handle specs with undefined min/max values', () => { @@ -56,12 +92,9 @@ describe('nodeDefUtil', () => { const result = mergeInputSpec(spec1, spec2) - expect(result).not.toBeNull() - expect(result?.[0]).toBe('FLOAT') - // @ts-expect-error fixme ts strict error - expect(result?.[1].min).toBe(0.5) - // @ts-expect-error fixme ts strict error - expect(result?.[1].max).toBe(15.5) + expectNumericResult(result) + expect(result[1].min).toBe(0.5) + expect(result[1].max).toBe(15.5) }) it('should merge step values using least common multiple', () => { @@ -70,10 +103,8 @@ describe('nodeDefUtil', () => { const result = mergeInputSpec(spec1, spec2) - expect(result).not.toBeNull() - expect(result?.[0]).toBe('INT') - // @ts-expect-error fixme ts strict error - expect(result?.[1].step).toBe(6) // LCM of 2 and 3 is 6 + expectNumericResult(result) + expect(result[1].step).toBe(6) // LCM of 2 and 3 is 6 }) it('should use default step of 1 when step is not specified', () => { @@ -82,10 +113,8 @@ describe('nodeDefUtil', () => { const result = mergeInputSpec(spec1, spec2) - expect(result).not.toBeNull() - expect(result?.[0]).toBe('INT') - // @ts-expect-error fixme ts strict error - expect(result?.[1].step).toBe(4) // LCM of 1 and 4 is 4 + expectNumericResult(result) + expect(result[1].step).toBe(4) // LCM of 1 and 4 is 4 }) it('should handle step values for FLOAT specs', () => { @@ -94,10 +123,8 @@ describe('nodeDefUtil', () => { const result = mergeInputSpec(spec1, spec2) - expect(result).not.toBeNull() - expect(result?.[0]).toBe('FLOAT') - // @ts-expect-error fixme ts strict error - expect(result?.[1].step).toBe(0.5) + expectNumericResult(result) + expect(result[1].step).toBe(0.5) }) }) @@ -109,10 +136,8 @@ describe('nodeDefUtil', () => { const result = mergeInputSpec(spec1, spec2) - expect(result).not.toBeNull() - expect(result?.[0]).toBe('COMBO') - // @ts-expect-error fixme ts strict error - expect(result?.[1].options).toEqual(['B', 'C']) + expectComboResult(result) + expect(result[1].options).toEqual(['B', 'C']) }) it('should return null for COMBO specs with no overlapping options', () => { @@ -144,16 +169,11 @@ describe('nodeDefUtil', () => { const result = mergeInputSpec(spec1, spec2) - expect(result).not.toBeNull() - expect(result?.[0]).toBe('COMBO') - // @ts-expect-error fixme ts strict error - expect(result?.[1].options).toEqual(['B', 'C']) - // @ts-expect-error fixme ts strict error - expect(result?.[1].default).toBe('B') - // @ts-expect-error fixme ts strict error - expect(result?.[1].tooltip).toBe('Select an option') - // @ts-expect-error fixme ts strict error - expect(result?.[1].multiline).toBe(true) + expectComboResult(result) + expect(result[1].options).toEqual(['B', 'C']) + expect(result[1].default).toBe('B') + expect(result[1].tooltip).toBe('Select an option') + expect(result[1].multiline).toBe(true) }) it('should handle v1 and v2 combo specs', () => { @@ -162,10 +182,8 @@ describe('nodeDefUtil', () => { const result = mergeInputSpec(spec1, spec2) - expect(result).not.toBeNull() - expect(result?.[0]).toBe('COMBO') - // @ts-expect-error fixme ts strict error - expect(result?.[1].options).toEqual(['C', 'D']) + expectComboResult(result) + expect(result[1].options).toEqual(['C', 'D']) }) }) @@ -202,12 +220,9 @@ describe('nodeDefUtil', () => { expect(result).not.toBeNull() expect(result?.[0]).toBe('STRING') - // @ts-expect-error fixme ts strict error - expect(result?.[1].default).toBe('value2') - // @ts-expect-error fixme ts strict error - expect(result?.[1].tooltip).toBe('Tooltip 2') - // @ts-expect-error fixme ts strict error - expect(result?.[1].step).toBe(1) + expect(result?.[1]?.default).toBe('value2') + expect(result?.[1]?.tooltip).toBe('Tooltip 2') + expect(result?.[1]?.step).toBe(1) }) it('should return null if non-ignored properties differ', () => { diff --git a/src/utils/vintageClipboard.ts b/src/utils/vintageClipboard.ts index c83354364b4..821b0be2330 100644 --- a/src/utils/vintageClipboard.ts +++ b/src/utils/vintageClipboard.ts @@ -1,10 +1,25 @@ import type { + ISerialisedNode, LGraph, LGraphCanvas, - LGraphNode + LGraphNode, + NodeId } from '@/lib/litegraph/src/litegraph' import { LiteGraph } from '@/lib/litegraph/src/litegraph' +type VintageLink = [ + outNodeRelativeId: number | undefined, + originSlot: number, + inNodeRelativeId: number | undefined, + targetSlot: number, + outNodeId: NodeId +] + +interface VintageSerialisable { + nodes: ISerialisedNode[] + links: VintageLink[] +} + /** * Serialises an array of nodes using a modified version of the old Litegraph copy (& paste) function * @param nodes All nodes to be serialised @@ -13,7 +28,7 @@ import { LiteGraph } from '@/lib/litegraph/src/litegraph' * @deprecated Format not in use anywhere else. */ export function serialise(nodes: LGraphNode[], graph: LGraph): string { - const serialisable = { + const serialisable: VintageSerialisable = { nodes: [], links: [] } @@ -35,7 +50,6 @@ export function serialise(nodes: LGraphNode[], graph: LGraph): string { continue } - // @ts-expect-error fixme ts strict error serialisable.nodes.push(cloned.serialize()) if (!node.inputs?.length) continue @@ -50,7 +64,6 @@ export function serialise(nodes: LGraphNode[], graph: LGraph): string { if (!outNode) continue // Special format for old Litegraph copy & paste only - // @ts-expect-error fixme ts strict error serialisable.links.push([ outNode._relative_id, link.origin_slot, @@ -73,12 +86,13 @@ export function deserialiseAndCreate(data: string, canvas: LGraphCanvas): void { if (!data) return const { graph, graph_mouse } = canvas + if (!graph) return + canvas.emitBeforeChange() try { - // @ts-expect-error fixme ts strict error graph.beforeChange() - const deserialised = JSON.parse(data) + const deserialised = JSON.parse(data) as VintageSerialisable // Find the top left point of the boundary of all pasted nodes const topLeft = [Infinity, Infinity] @@ -105,7 +119,6 @@ export function deserialiseAndCreate(data: string, canvas: LGraphCanvas): void { node.pos[0] += graph_mouse[0] - topLeft[0] node.pos[1] += graph_mouse[1] - topLeft[1] - // @ts-expect-error fixme ts strict error graph.add(node, true) nodes.push(node) } @@ -115,14 +128,14 @@ export function deserialiseAndCreate(data: string, canvas: LGraphCanvas): void { const relativeId = info[0] const outNode = relativeId != null ? nodes[relativeId] : undefined - const inNode = nodes[info[2]] + const inNodeId = info[2] + const inNode = inNodeId != null ? nodes[inNodeId] : undefined if (outNode && inNode) outNode.connect(info[1], inNode, info[3]) else console.warn('Warning, nodes missing on pasting') } canvas.selectNodes(nodes) - // @ts-expect-error fixme ts strict error graph.afterChange() } finally { canvas.emitAfterChange() diff --git a/src/workbench/extensions/manager/components/manager/infoPanel/InfoTabs.vue b/src/workbench/extensions/manager/components/manager/infoPanel/InfoTabs.vue index 29ae4f986df..08df30a8463 100644 --- a/src/workbench/extensions/manager/components/manager/infoPanel/InfoTabs.vue +++ b/src/workbench/extensions/manager/components/manager/infoPanel/InfoTabs.vue @@ -67,9 +67,10 @@ const importFailedContext = inject(ImportFailedKey) const importFailed = importFailedContext?.importFailed const nodeNames = computed(() => { - // @ts-expect-error comfy_nodes is an Algolia-specific field - const { comfy_nodes } = nodePack - return comfy_nodes ?? [] + const packWithAlgolia = nodePack as typeof nodePack & { + comfy_nodes?: string[] + } + return packWithAlgolia.comfy_nodes ?? [] }) const activeTab = ref('description') diff --git a/src/workbench/extensions/manager/composables/nodePack/useMissingNodes.test.ts b/src/workbench/extensions/manager/composables/nodePack/useMissingNodes.test.ts index a467bf09803..3f3145ad4db 100644 --- a/src/workbench/extensions/manager/composables/nodePack/useMissingNodes.test.ts +++ b/src/workbench/extensions/manager/composables/nodePack/useMissingNodes.test.ts @@ -2,12 +2,26 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick, ref } from 'vue' import type { LGraphNode, LGraph } from '@/lib/litegraph/src/litegraph' +import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore' import { useNodeDefStore } from '@/stores/nodeDefStore' import { collectAllNodes } from '@/utils/graphTraversalUtil' import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes' import { useWorkflowPacks } from '@/workbench/extensions/manager/composables/nodePack/useWorkflowPacks' import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore' +type NodeDefStore = ReturnType +type PartialManagerStore = Pick< + ReturnType, + 'isPackInstalled' +> + +function createMockNodeDefStore(names: string[]): NodeDefStore { + const nodeDefsByName: Record = Object.fromEntries( + names.map((name) => [name, { name } as ComfyNodeDefImpl]) + ) + return { nodeDefsByName } as unknown as NodeDefStore +} + vi.mock('@vueuse/core', async () => { const actual = await vi.importActual('@vueuse/core') return { @@ -81,11 +95,12 @@ describe('useMissingNodes', () => { // Default setup: pack-3 is installed, others are not mockIsPackInstalled.mockImplementation((id: string) => id === 'pack-3') - // @ts-expect-error - Mocking partial ComfyManagerStore for testing. - // We only need isPackInstalled method for these tests. - mockUseComfyManagerStore.mockReturnValue({ + const partialManagerStore: PartialManagerStore = { isPackInstalled: mockIsPackInstalled - }) + } + mockUseComfyManagerStore.mockReturnValue( + partialManagerStore as ReturnType + ) mockUseWorkflowPacks.mockReturnValue({ workflowPacks: ref([]), @@ -97,11 +112,7 @@ describe('useMissingNodes', () => { }) // Reset node def store mock - // @ts-expect-error - Mocking partial NodeDefStore for testing. - // We only need nodeDefsByName for these tests. - mockUseNodeDefStore.mockReturnValue({ - nodeDefsByName: {} - }) + mockUseNodeDefStore.mockReturnValue(createMockNodeDefStore([])) // Reset app.rootGraph.nodes mockApp.rootGraph = { nodes: [] } @@ -265,8 +276,7 @@ describe('useMissingNodes', () => { expect(missingNodePacks.value).toEqual([]) // Update workflow packs - // @ts-expect-error - mockWorkflowPacks is a simplified version without full WorkflowPack interface. - workflowPacksRef.value = mockWorkflowPacks + Object.assign(workflowPacksRef, { value: mockWorkflowPacks }) await nextTick() // Should update missing packs (2 missing since pack-3 is installed) @@ -323,13 +333,9 @@ describe('useMissingNodes', () => { // Mock collectAllNodes to return only the filtered nodes (missing core nodes) mockCollectAllNodes.mockReturnValue([coreNode1, coreNode2]) - mockUseNodeDefStore.mockReturnValue({ - nodeDefsByName: { - // @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing. - // Only including required properties for our test assertions. - RegisteredNode: { name: 'RegisteredNode' } - } - }) + mockUseNodeDefStore.mockReturnValue( + createMockNodeDefStore(['RegisteredNode']) + ) const { missingCoreNodes } = useMissingNodes() @@ -347,10 +353,7 @@ describe('useMissingNodes', () => { // Mock collectAllNodes to return these nodes mockCollectAllNodes.mockReturnValue([node120, node130, nodeNoVer]) - // @ts-expect-error - Mocking partial NodeDefStore for testing. - mockUseNodeDefStore.mockReturnValue({ - nodeDefsByName: {} - }) + mockUseNodeDefStore.mockReturnValue(createMockNodeDefStore([])) const { missingCoreNodes } = useMissingNodes() @@ -366,10 +369,7 @@ describe('useMissingNodes', () => { // Mock collectAllNodes to return only the filtered nodes (core nodes only) mockCollectAllNodes.mockReturnValue([coreNode]) - // @ts-expect-error - Mocking partial NodeDefStore for testing. - mockUseNodeDefStore.mockReturnValue({ - nodeDefsByName: {} - }) + mockUseNodeDefStore.mockReturnValue(createMockNodeDefStore([])) const { missingCoreNodes } = useMissingNodes() @@ -382,15 +382,9 @@ describe('useMissingNodes', () => { // Mock collectAllNodes to return empty array (no missing nodes after filtering) mockCollectAllNodes.mockReturnValue([]) - mockUseNodeDefStore.mockReturnValue({ - nodeDefsByName: { - // @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing. - // Only including required properties for our test assertions. - RegisteredNode1: { name: 'RegisteredNode1' }, - // @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing. - RegisteredNode2: { name: 'RegisteredNode2' } - } - }) + mockUseNodeDefStore.mockReturnValue( + createMockNodeDefStore(['RegisteredNode1', 'RegisteredNode2']) + ) const { missingCoreNodes } = useMissingNodes() @@ -404,8 +398,6 @@ describe('useMissingNodes', () => { packId?: string, version?: string ): LGraphNode => - // @ts-expect-error - Creating a partial mock of LGraphNode for testing. - // We only need specific properties for our tests, not the full LGraphNode interface. ({ type, properties: { cnr_id: packId, ver: version }, @@ -418,7 +410,7 @@ describe('useMissingNodes', () => { mode: 0, inputs: [], outputs: [] - }) + }) as unknown as LGraphNode it('detects missing core nodes from subgraphs via collectAllNodes', () => { const mainNode = createMockNode('MainNode', 'comfy-core', '1.0.0') @@ -441,10 +433,7 @@ describe('useMissingNodes', () => { ]) // Mock none of the nodes as registered - // @ts-expect-error - Mocking partial NodeDefStore for testing. - mockUseNodeDefStore.mockReturnValue({ - nodeDefsByName: {} - }) + mockUseNodeDefStore.mockReturnValue(createMockNodeDefStore([])) const { missingCoreNodes } = useMissingNodes() @@ -480,12 +469,9 @@ describe('useMissingNodes', () => { const mockGraph = { nodes: [], subgraphs: new Map() } mockApp.rootGraph = mockGraph - mockUseNodeDefStore.mockReturnValue({ - nodeDefsByName: { - // @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing. - RegisteredCore: { name: 'RegisteredCore' } - } - }) + mockUseNodeDefStore.mockReturnValue( + createMockNodeDefStore(['RegisteredCore']) + ) let capturedFilterFunction: ((node: LGraphNode) => boolean) | undefined @@ -574,12 +560,9 @@ describe('useMissingNodes', () => { mockApp.rootGraph = mockMainGraph - mockUseNodeDefStore.mockReturnValue({ - nodeDefsByName: { - // @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing. - SubgraphRegistered: { name: 'SubgraphRegistered' } - } - }) + mockUseNodeDefStore.mockReturnValue( + createMockNodeDefStore(['SubgraphRegistered']) + ) const { missingCoreNodes } = useMissingNodes() diff --git a/src/workbench/utils/nodeDefOrderingUtil.ts b/src/workbench/utils/nodeDefOrderingUtil.ts index 2a39fa70c18..9f391b89b43 100644 --- a/src/workbench/utils/nodeDefOrderingUtil.ts +++ b/src/workbench/utils/nodeDefOrderingUtil.ts @@ -1,29 +1,32 @@ import type { TWidgetValue } from '@/lib/litegraph/src/litegraph' import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' -import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore' + +interface HasInputOrder { + input_order?: { required?: string[]; optional?: string[] } +} /** * Gets an ordered array of InputSpec objects based on input_order. * This is designed to work with V2 format used by litegraphService. * - * @param nodeDefImpl - The ComfyNodeDefImpl containing both V1 and V2 formats + * @param nodeDef - An object containing optional input_order * @param inputs - The V2 format inputs (flat Record) * @returns Array of InputSpec objects in the correct order */ export function getOrderedInputSpecs( - nodeDefImpl: ComfyNodeDefImpl, + nodeDef: HasInputOrder | undefined, inputs: Record ): InputSpec[] { const orderedInputSpecs: InputSpec[] = [] // If no input_order, return default Object.values order - if (!nodeDefImpl.input_order) { + if (!nodeDef?.input_order) { return Object.values(inputs) } // Process required inputs in specified order - if (nodeDefImpl.input_order.required) { - for (const name of nodeDefImpl.input_order.required) { + if (nodeDef.input_order.required) { + for (const name of nodeDef.input_order.required) { const inputSpec = inputs[name] if (inputSpec && !inputSpec.isOptional) { orderedInputSpecs.push(inputSpec) @@ -32,8 +35,8 @@ export function getOrderedInputSpecs( } // Process optional inputs in specified order - if (nodeDefImpl.input_order.optional) { - for (const name of nodeDefImpl.input_order.optional) { + if (nodeDef.input_order.optional) { + for (const name of nodeDef.input_order.optional) { const inputSpec = inputs[name] if (inputSpec && inputSpec.isOptional) { orderedInputSpecs.push(inputSpec)