Compare commits

..

1 Commits

Author SHA1 Message Date
huang47
9d67c50578 test: cover queue models and execution-store slices 2026-06-30 22:37:13 -07:00
70 changed files with 3247 additions and 2660 deletions

View File

@@ -15,7 +15,7 @@ const { categories } = defineProps<{
const activeSection = ref(categories[0]?.value ?? '')
const HEADER_OFFSET_PX = -144
const HEADER_OFFSET = -144
const BOTTOM_THRESHOLD_PX = 4
const SCROLL_SAFETY_MS = 1500
@@ -52,7 +52,7 @@ function scrollToSection(id: string) {
const el = document.getElementById(id)
if (el) {
scrollTo(el, {
offset: HEADER_OFFSET_PX,
offset: HEADER_OFFSET,
duration: 0.8,
immediate: prefersReducedMotion(),
onComplete: clearScrollLock

View File

@@ -1,5 +1,5 @@
<li
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow"
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow before:content-['']"
>
<slot />
</li>

View File

@@ -1,45 +0,0 @@
{
"last_node_id": 9,
"last_link_id": 9,
"nodes": [
{
"id": 9,
"type": "SaveImage",
"pos": {
"0": 64,
"1": 104
},
"size": {
"0": 210,
"1": 58
},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": null
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
},
"linearData": {
"inputs": [],
"outputs": ["9"]
}
},
"version": 0.4
}

View File

@@ -6,7 +6,7 @@
"nodes": [
{
"id": 3,
"type": "4e7c1a2b-3d5f-4a6b-8c9d-0e1f2a3b4c5d",
"type": "outer-subgraph-with-promoted-missing-model",
"pos": [10, 250],
"size": [400, 200],
"flags": {},
@@ -20,7 +20,7 @@
},
{
"id": 4,
"type": "4e7c1a2b-3d5f-4a6b-8c9d-0e1f2a3b4c5d",
"type": "outer-subgraph-with-promoted-missing-model",
"pos": [450, 250],
"size": [400, 200],
"flags": {},
@@ -38,7 +38,7 @@
"definitions": {
"subgraphs": [
{
"id": "4e7c1a2b-3d5f-4a6b-8c9d-0e1f2a3b4c5d",
"id": "outer-subgraph-with-promoted-missing-model",
"version": 1,
"state": {
"lastGroupId": 0,
@@ -71,7 +71,7 @@
"nodes": [
{
"id": 2,
"type": "5f8d2b3c-4e6a-4b7c-9d0e-1f2a3b4c5d6e",
"type": "inner-subgraph-with-promoted-missing-model",
"pos": [250, 180],
"size": [400, 200],
"flags": {},
@@ -105,7 +105,7 @@
]
},
{
"id": "5f8d2b3c-4e6a-4b7c-9d0e-1f2a3b4c5d6e",
"id": "inner-subgraph-with-promoted-missing-model",
"version": 1,
"state": {
"lastGroupId": 0,

View File

@@ -34,10 +34,6 @@ export class AppModeHelper {
public readonly outputPlaceholder: Locator
/** The linear-mode widget list container (visible in app mode). */
public readonly linearWidgets: Locator
/** The validation warning shown above the app mode run button. */
public readonly validationWarning: Locator
/** The action that opens graph mode errors from the validation warning. */
public readonly viewErrorsInGraphButton: Locator
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
public readonly imagePickerPopover: Locator
/** The Run button in the app mode footer. */
@@ -96,19 +92,13 @@ export class AppModeHelper {
this.outputPlaceholder = this.page.getByTestId(
TestIds.builder.outputPlaceholder
)
this.linearWidgets = this.page.getByTestId(TestIds.linear.widgetContainer)
this.validationWarning = this.page.getByTestId(
TestIds.linear.validationWarning
)
this.viewErrorsInGraphButton = this.validationWarning.getByTestId(
TestIds.linear.viewErrorsInGraph
)
this.linearWidgets = this.page.getByTestId('linear-widgets')
this.imagePickerPopover = this.page
.getByRole('dialog')
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
.first()
this.runButton = this.page
.getByTestId(TestIds.linear.runButton)
.getByTestId('linear-run-button')
.getByRole('button', { name: /run/i })
this.welcome = this.page.getByTestId(TestIds.appMode.welcome)
this.emptyWorkflowText = this.page.getByTestId(

View File

@@ -172,9 +172,6 @@ export const TestIds = {
mobileNavigation: 'linear-mobile-navigation',
mobileWorkflows: 'linear-mobile-workflows',
outputInfo: 'linear-output-info',
runButton: 'linear-run-button',
validationWarning: 'linear-validation-warning',
viewErrorsInGraph: 'linear-view-errors',
widgetContainer: 'linear-widgets'
},
builder: {

View File

@@ -1,106 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { NodeError, PromptResponse } from '@/schemas/apiSchema'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { enableErrorsOverlay } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import { TestIds } from '@e2e/fixtures/selectors'
const SAVE_IMAGE_NODE_ID = '9'
function buildSaveImageRequiredInputError(): NodeError {
return {
class_type: 'SaveImage',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing: images',
details: '',
extra_info: { input_name: 'images' }
}
]
}
}
test.describe(
'App mode validation warning',
{ tag: ['@ui', '@workflow'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await enableErrorsOverlay(comfyPage)
await comfyPage.workflow.loadWorkflow('linear-validation-warning')
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
})
test('opens graph errors from the app mode validation warning', async ({
comfyPage
}) => {
await expect(comfyPage.appMode.validationWarning).toBeHidden()
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
})
await comfyPage.appMode.runButton.click()
const appModeOverlay = comfyPage.appMode.centerPanel.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(appModeOverlay).toBeHidden()
await expect(comfyPage.appMode.validationWarning).toBeVisible()
await expect(comfyPage.appMode.validationWarning).toContainText(
/Required input missing/i
)
await expect(comfyPage.appMode.viewErrorsInGraphButton).toBeVisible()
await comfyPage.appMode.viewErrorsInGraphButton.click()
await expect(comfyPage.appMode.linearWidgets).toBeHidden()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeVisible()
})
test('keeps the app mode run button enabled when the warning is visible', async ({
comfyPage
}) => {
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
})
await comfyPage.appMode.runButton.click()
await expect(comfyPage.appMode.validationWarning).toBeVisible()
await expect(comfyPage.appMode.runButton).toBeEnabled()
let promptQueued = false
const mockResponse: PromptResponse = {
prompt_id: 'test-id',
node_errors: {},
error: ''
}
await comfyPage.page.route(
'**/api/prompt',
async (route) => {
promptQueued = true
await route.fulfill({
status: 200,
body: JSON.stringify(mockResponse)
})
},
{ times: 1 }
)
await comfyPage.appMode.runButton.click()
await expect.poll(() => promptQueued).toBe(true)
})
}
)

View File

@@ -1,6 +1,5 @@
import { expect } from '@playwright/test'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
@@ -14,12 +13,11 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
// Ref: https://github.com/Comfy-Org/ComfyUI_frontend/issues/3348
test('Fix link input slots', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/input_order_swap')
const linkId = toLinkId(1)
await expect
.poll(() =>
comfyPage.page.evaluate((linkId) => {
return window.app!.graph!.links.get(linkId)?.target_slot
}, linkId)
comfyPage.page.evaluate(() => {
return window.app!.graph!.links.get(1)?.target_slot
})
)
.toBe(1)
})

View File

@@ -3,7 +3,6 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('Linear Mode', { tag: '@ui' }, () => {
test('Displays linear controls when app mode active', async ({
@@ -17,9 +16,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
test('Run button visible in linear mode', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(
comfyPage.page.getByTestId(TestIds.linear.runButton)
).toBeVisible()
await expect(comfyPage.page.getByTestId('linear-run-button')).toBeVisible()
})
test('Workflow info section visible', async ({ comfyPage }) => {

View File

@@ -8,32 +8,25 @@ test('@vue-nodes In App Mode, widget width updates with panel size', async ({
comfyPage,
comfyMouse
}) => {
let legacyNodeId = toNodeId(10)
await test.step('setup', async () => {
const legacyNode = await comfyPage.nodeOps.addNode(
'DevToolsNodeWithLegacyWidget',
undefined,
{
x: 0,
y: 0
}
)
legacyNodeId = legacyNode.id
await comfyPage.appMode.enterAppModeWithInputs([
[String(legacyNodeId), 'legacy_widget']
])
await comfyPage.nodeOps.addNode('DevToolsNodeWithLegacyWidget', undefined, {
x: 0,
y: 0
})
await comfyPage.appMode.enterAppModeWithInputs([['10', 'legacy_widget']])
})
const getWidth = async () =>
(await comfyPage.appMode.linearWidgets.locator('canvas').boundingBox())
?.width ?? 0
const getWidth = () =>
comfyPage.page.evaluate(
(nodeId) => graph!.getNodeById(nodeId)!.widgets![0].width ?? 0,
toNodeId(10)
)
await test.step('Mouse clicks resolve to button regions', async () => {
const legacyWidget = comfyPage.appMode.linearWidgets.locator('canvas')
const { width, height } = (await legacyWidget.boundingBox())!
const nodeRef = await comfyPage.nodeOps.getNodeRefById(legacyNodeId)
const nodeRef = await comfyPage.nodeOps.getNodeRefById(10)
const legacyWidgetRef = await nodeRef.getWidget(0)
expect(await legacyWidgetRef.getValue()).toBe(0)
await legacyWidget.click({ position: { x: 20, y: height / 2 } })
@@ -43,8 +36,8 @@ test('@vue-nodes In App Mode, widget width updates with panel size', async ({
})
await test.step('Resize to update width', async () => {
await expect.poll(getWidth).toBeGreaterThan(0)
const initialWidth = await getWidth()
expect(initialWidth).toBeGreaterThan(0)
const gutter = comfyPage.page.getByRole('separator')

View File

@@ -3,43 +3,31 @@ import {
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { TestGraphAccess } from '@e2e/types/globals'
import { toNodeId } from '@/types/nodeId'
test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
test('Should display added widgets', async ({ comfyPage }) => {
const nodeId = toNodeId(
await comfyPage.page.evaluate(() => {
const node = window.app!.graph.nodes.find(
(node) => (node.widgets?.length ?? 0) === 1
)
if (!node) throw new Error('Node with one widget not found')
return String(node.id)
})
const loadCheckpointNode = comfyPage.page.locator(
'css=[data-testid="node-body-4"] > .lg-node-widgets > div'
)
const widgets = comfyPage.vueNodes
.getNodeLocator(nodeId)
.locator('.lg-node-widget')
await expect(widgets).toHaveCount(1)
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(nodeId)
if (!node) throw new Error(`Node ${nodeId} not found`)
await expect(loadCheckpointNode).toHaveCount(1)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.addWidget('text', 'extra_widget_a', '', () => {})
}, nodeId)
await expect(widgets).toHaveCount(2)
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(nodeId)
if (!node) throw new Error(`Node ${nodeId} not found`)
})
await expect(loadCheckpointNode).toHaveCount(2)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.addWidget('text', 'extra_widget_b', '', () => {})
}, nodeId)
await expect(widgets).toHaveCount(3)
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(nodeId)
if (!node) throw new Error(`Node ${nodeId} not found`)
})
await expect(loadCheckpointNode).toHaveCount(3)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.addWidget('text', 'extra_widget_c', '', () => {})
}, nodeId)
await expect(widgets).toHaveCount(4)
})
await expect(loadCheckpointNode).toHaveCount(4)
})
test('Should hide removed widgets', async ({ comfyPage }) => {

View File

@@ -11,7 +11,6 @@ import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { WidgetId } from '@/types/widgetId'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { extractWidgetStringValue } from '@/composables/maskeditor/useMaskEditorLoader'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
@@ -20,7 +19,6 @@ import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.v
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { parseImageWidgetValue } from '@/utils/imageUtil'
import { cn } from '@comfyorg/tailwind-utils'
@@ -31,8 +29,9 @@ import { promptRenameWidget } from '@/utils/widgetUtil'
interface WidgetEntry {
key: string
persistedHeight: number | undefined
nodeData: ReturnType<typeof nodeToNodeData>
widgetIds: readonly WidgetId[]
nodeData: ReturnType<typeof nodeToNodeData> & {
widgets: NonNullable<ReturnType<typeof nodeToNodeData>['widgets']>
}
action: { widget: IBaseWidget; node: LGraphNode }
}
@@ -44,7 +43,6 @@ const { mobile = false, builderMode = false } = defineProps<{
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const appModeStore = useAppModeStore()
const widgetValueStore = useWidgetValueStore()
const maskEditor = useMaskEditor()
const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
@@ -55,72 +53,49 @@ provide(HideLayoutFieldKey, true)
const resolvedInputs = useResolvedSelectedInputs()
function isDOMBackedWidget(widget: IBaseWidget): boolean {
if ('isDOMWidget' in widget && typeof widget.isDOMWidget === 'boolean') {
return widget.isDOMWidget
}
return (
('element' in widget && !!widget.element) ||
('component' in widget && !!widget.component)
)
}
function ensureSelectedWidgetState(
widgetId: WidgetId,
widget: IBaseWidget
): void {
if (widgetValueStore.getWidget(widgetId)) return
widgetValueStore.registerWidget(widgetId, {
type: widget.type,
value: widget.value,
options: widget.options,
label: widget.label,
serialize: widget.serialize,
disabled: widget.disabled
})
widgetValueStore.registerWidgetRenderState(widgetId, {
advanced: widget.options?.advanced ?? widget.advanced,
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
isDOMWidget: isDOMBackedWidget(widget),
tooltip: widget.tooltip
})
}
const mappedSelections = computed((): WidgetEntry[] => {
const nodeDataByNode = new Map<
LGraphNode,
ReturnType<typeof nodeToNodeData>
>()
return resolvedInputs.value.flatMap((entry) => {
if (entry.status !== 'resolved') return []
const { widgetId, node, widget, config } = entry
if (node.mode !== LGraphEventMode.ALWAYS) return []
ensureSelectedWidgetState(widgetId, widget)
const fullNodeData = nodeToNodeData(node, widgetId)
if (
node.inputs?.some(
(input) => input.widget?.name === widget.name && input.link != null
)
) {
return []
if (!nodeDataByNode.has(node)) {
nodeDataByNode.set(node, nodeToNodeData(node))
}
const fullNodeData = nodeDataByNode.get(node)!
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
if (vueWidget.slotMetadata?.linked) return false
return vueWidget.widgetId === widgetId
})
if (!matchingWidget) return []
matchingWidget.slotMetadata = undefined
matchingWidget.nodeId = node.id
return [
{
key: widgetId,
persistedHeight: config?.height,
nodeData: fullNodeData,
widgetIds: [widgetId],
nodeData: {
...fullNodeData,
widgets: [matchingWidget]
},
action: { widget, node }
}
]
})
})
function getDropIndicator(node: LGraphNode, id: WidgetId) {
function getDropIndicator(node: LGraphNode) {
if (node.type !== 'LoadImage') return undefined
const stringValue = extractWidgetStringValue(
widgetValueStore.getWidget(id)?.value
)
const stringValue = extractWidgetStringValue(node.widgets?.[0]?.value)
const { filename, subfolder, type } = stringValue
? parseImageWidgetValue(stringValue)
@@ -144,8 +119,8 @@ function getDropIndicator(node: LGraphNode, id: WidgetId) {
}
}
function nodeToNodeData(node: LGraphNode, id: WidgetId) {
const dropIndicator = getDropIndicator(node, id)
function nodeToNodeData(node: LGraphNode) {
const dropIndicator = getDropIndicator(node)
const nodeData = extractVueNodeData(node)
return {
@@ -172,13 +147,7 @@ defineExpose({ handleDragDrop })
</script>
<template>
<div
v-for="{
key,
persistedHeight,
nodeData,
widgetIds,
action
} in mappedSelections"
v-for="{ key, persistedHeight, nodeData, action } in mappedSelections"
:key
:class="
cn(
@@ -265,7 +234,6 @@ defineExpose({ handleDragDrop })
>
<NodeWidgets
:node-data
:widget-ids="widgetIds"
:class="
cn(
'gap-y-3 rounded-lg py-1 [&_textarea]:resize-y **:[.col-span-2]:grid-cols-1 not-md:**:[.h-7]:h-10',

View File

@@ -37,7 +37,7 @@
size="unset"
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
data-testid="error-overlay-see-errors"
@click="viewErrorsInGraph"
@click="seeErrors"
>
{{
appMode
@@ -67,18 +67,31 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
import { useViewErrorsInGraph } from '@/composables/useViewErrorsInGraph'
const { appMode = false } = defineProps<{ appMode?: boolean }>()
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const { viewErrorsInGraph } = useViewErrorsInGraph()
const rightSidePanelStore = useRightSidePanelStore()
const canvasStore = useCanvasStore()
const { isVisible, overlayMessage, overlayTitle } = useErrorOverlayState()
function dismiss() {
executionErrorStore.dismissErrorOverlay()
}
function seeErrors() {
canvasStore.linearMode = false
if (canvasStore.canvas) {
canvasStore.canvas.deselectAll()
canvasStore.updateSelectedItems()
}
rightSidePanelStore.openPanel('errors')
executionErrorStore.dismissErrorOverlay()
}
</script>

View File

@@ -13,8 +13,8 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import { isWidgetPromotedOnSubgraphNode } from '@/core/graph/subgraph/promotionUtils'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -255,10 +255,7 @@ function clearWidgetErrors(
source.sourceWidgetName,
source.sourceWidgetName,
value,
{
min: source.sourceWidget.options?.min,
max: source.sourceWidget.options?.max
}
options
)
}

View File

@@ -2,17 +2,14 @@ import { createTestingPinia } from '@pinia/testing'
import { render } from '@testing-library/vue'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
import WidgetItem from './WidgetItem.vue'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
const { mockGetInputSpecForWidget, StubWidgetComponent } = vi.hoisted(() => ({
@@ -207,60 +204,5 @@ describe('WidgetItem', () => {
expect(stub.value).toBe('model_a.safetensors')
})
it('passes null from widget state to the widget component', () => {
const id = widgetId('test-graph-id', toNodeId(1), 'ckpt_name')
const widget = createMockWidget({ widgetId: id, value: 'source value' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
value: null,
options: {}
})
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
expect(stub.value).toBe('null')
})
it('updates disabled options when the widget input is linked', async () => {
const graphEvents = new EventTarget()
const node = createMockNode(
fromAny<Partial<LGraphNode>, unknown>({
graph: {
rootGraph: { id: 'test-graph-id' },
events: graphEvents
},
inputs: [
{
name: 'seed',
type: 'INT',
widget: { name: 'seed' },
link: null,
boundingRect: [0, 0, 0, 0]
}
]
})
)
const widget = createMockWidget({ name: 'seed', options: {} })
const { container } = renderWidgetItem(widget, node)
expect(getStubWidget(container).options.disabled).toBeUndefined()
node.inputs![0].link = toLinkId(1)
graphEvents.dispatchEvent(
new CustomEvent('node:slot-links:changed', {
detail: {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: true,
linkId: 1
}
})
)
await nextTick()
expect(getStubWidget(container).options.disabled).toBe(true)
})
})
})

View File

@@ -1,13 +1,13 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { computed, customRef, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import { getControlWidget } from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
@@ -21,11 +21,7 @@ import {
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getControlWidget } from '@/types/simplifiedWidget'
import type {
SimplifiedWidget,
WidgetValue as SimplifiedWidgetValue
} from '@/types/simplifiedWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { widgetId } from '@/types/widgetId'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@comfyorg/tailwind-utils'
@@ -72,28 +68,13 @@ const widgetComponent = computed(() => {
return component || WidgetLegacy
})
const linkRevision = ref(0)
useEventListener(
() => node.graph?.events,
'node:slot-links:changed',
(event) => {
const detail = (
event as CustomEvent<{ nodeId: unknown; slotType: NodeSlotType }>
).detail
if (
String(detail.nodeId) === String(node.id) &&
detail.slotType === NodeSlotType.INPUT
) {
linkRevision.value++
}
}
)
const isLinked = computed(() => {
void linkRevision.value
return !!node.inputs?.some(
(input) => input.widget?.name === widget.name && input.link != null
)
const safeWidget = useVueNodeLifecycle()
.nodeManager.value?.vueNodeData.get(node.id)
?.widgets?.find((w) => w.name === widget.name)
return safeWidget?.slotMetadata
? !!safeWidget.slotMetadata.linked
: !!node.inputs?.find((inp) => inp.widget?.name === widget.name)?.link
})
const simplifiedWidget = computed((): SimplifiedWidget => {
@@ -112,9 +93,7 @@ const simplifiedWidget = computed((): SimplifiedWidget => {
return {
name: widgetName,
type: widgetType,
value: (widgetState
? widgetState.value
: widget.value) as SimplifiedWidgetValue,
value: widgetState?.value ?? widget.value,
label: widgetState?.label ?? widget.label,
options: { ...baseOptions, disabled },
spec: nodeDefStore.getInputSpecForWidget(node, widgetName),

View File

@@ -87,7 +87,7 @@ describe('Node Reactivity', () => {
})
})
describe('Widget input link reactivity', () => {
describe('Widget slotMetadata reactivity on link disconnect', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
@@ -96,8 +96,10 @@ describe('Widget input link reactivity', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
// Add a widget and an associated input slot (simulates "widget converted to input")
node.addWidget('string', 'prompt', 'hello', () => undefined, {})
const input = node.addInput('prompt', 'STRING')
// Associate the input slot with the widget (as widgetInputs extension does)
input.widget = { name: 'prompt' }
graph.add(node)
@@ -110,26 +112,31 @@ describe('Widget input link reactivity', () => {
return { graph, node, upstream, linkId: link.id }
}
it('exposes linked widget input slots through Vue node inputs', () => {
it('sets slotMetadata.linked to true when input has a link', () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(nodeData?.inputs?.[0]?.widget?.name).toBe('prompt')
expect(nodeData?.inputs?.[0]?.link).not.toBeNull()
expect(widgetData?.slotMetadata).toBeDefined()
expect(widgetData?.slotMetadata?.linked).toBe(true)
})
it('updates input link state after link disconnect event', async () => {
it('updates slotMetadata.linked to false after link disconnect event', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(nodeData?.inputs?.[0]?.link).not.toBeNull()
// Verify initially linked
expect(widgetData?.slotMetadata?.linked).toBe(true)
// Simulate link disconnection (as LiteGraph does before firing the event)
node.inputs[0].link = null
// Fire the trigger event that LiteGraph fires on disconnect
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
@@ -140,19 +147,32 @@ describe('Widget input link reactivity', () => {
await nextTick()
expect(nodeData?.inputs?.[0]?.link).toBeNull()
// slotMetadata.linked should now be false
expect(widgetData?.slotMetadata?.linked).toBe(false)
})
it('keeps widget input link state current after disconnect', async () => {
it('reactively updates disabled state in a derived computed after disconnect', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)!
expect(
nodeData.inputs?.find((slot) => slot.widget?.name === 'prompt')?.link
).not.toBeNull()
// Mimic what processedWidgets does in NodeWidgets.vue:
// derive disabled from slotMetadata.linked
const derivedDisabled = computed(() => {
const widgets = nodeData.widgets ?? []
const widget = widgets.find((w) => w.name === 'prompt')
return widget?.slotMetadata?.linked ? true : false
})
// Initially linked → disabled
expect(derivedDisabled.value).toBe(true)
// Track changes
const onChange = vi.fn()
watch(derivedDisabled, onChange)
// Simulate disconnect
node.inputs[0].link = null
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
@@ -164,9 +184,9 @@ describe('Widget input link reactivity', () => {
await nextTick()
expect(
nodeData.inputs?.find((slot) => slot.widget?.name === 'prompt')?.link
).toBeNull()
// The derived computed should now return false
expect(derivedDisabled.value).toBe(false)
expect(onChange).toHaveBeenCalledTimes(1)
})
it('marks a widget input slot as linked when connected to a SubgraphInput', () => {
@@ -185,11 +205,15 @@ describe('Widget input link reactivity', () => {
const { vueNodeData } = useGraphNodeManager(subgraph)
const nodeData = vueNodeData.get(node.id)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(nodeData?.inputs?.[0]?.link).not.toBeNull()
expect(widgetData?.slotMetadata?.linked).toBe(true)
})
it('registers promoted widget render state separately from value state', () => {
it('names promoted widgets after the subgraph input slot and exposes the interior source name', () => {
// Subgraph input named "value" promotes an interior "prompt" widget. The
// projected widget's name is the input slot name "value"; the interior
// source widget name "prompt" is carried separately for backend lookups.
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'STRING' }]
})
@@ -205,34 +229,23 @@ describe('Widget input link reactivity', () => {
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
useGraphNodeManager(graph)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNode.id)
const id = widgetId(graph.id, subgraphNode.id, 'value')
const store = useWidgetValueStore()
const valueState = store.getWidget(id)
const renderState = store.getWidgetRenderState(id)
expect(valueState?.name).toBe('value')
expect(valueState?.value).toBe('hello')
expect(renderState).toMatchObject({
hasLayoutSize: false,
isDOMWidget: false
})
expect(renderState).not.toHaveProperty('sourceWidgetName')
expect(subgraphNode.inputs[0].widget?.name).toBe('value')
const widgetData = nodeData?.widgets?.find((w) => w.name === 'value')
expect(widgetData).toBeDefined()
expect(widgetData?.sourceWidgetName).toBe('prompt')
expect(widgetData?.slotMetadata).toBeDefined()
})
it('reflects input/widget renames after link refresh', async () => {
it('clears stale slotMetadata when input no longer matches widget', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)!
const widgetData = nodeData.widgets!.find((w) => w.name === 'prompt')!
expect(
nodeData.inputs?.some(
(slot) => slot.name === 'prompt' && slot.widget?.name === 'prompt'
)
).toBe(true)
expect(widgetData.slotMetadata?.linked).toBe(true)
node.inputs[0].name = 'other'
node.inputs[0].widget = { name: 'other' }
@@ -248,11 +261,7 @@ describe('Widget input link reactivity', () => {
await nextTick()
expect(
nodeData.inputs?.some(
(slot) => slot.name === 'prompt' && slot.widget?.name === 'prompt'
)
).toBe(false)
expect(widgetData.slotMetadata).toBeUndefined()
})
})
@@ -359,13 +368,15 @@ describe('Nested promoted widget mapping', () => {
const graph = subgraphNodeB.graph as LGraph
graph.add(subgraphNodeB)
useGraphNodeManager(graph)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNodeB.id)
const mappedWidget = nodeData?.widgets?.[0]
const id = widgetId(graph.id, subgraphNodeB.id, 'b_input')
const state = useWidgetValueStore().getWidget(id)
expect(state?.type).toBe('combo')
expect(subgraphNodeB.widgets[0]?.widgetId).toBe(id)
expect(mappedWidget).toBeDefined()
expect(mappedWidget?.type).toBe('combo')
expect(mappedWidget?.widgetId).toBe(
widgetId(graph.id, subgraphNodeB.id, 'b_input')
)
})
it('preserves distinct store identity for duplicate-named promoted widgets', () => {
@@ -394,23 +405,27 @@ describe('Nested promoted widget mapping', () => {
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
useGraphNodeManager(graph)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNode.id)
const widgets = nodeData?.widgets
const ids = subgraphNode.widgets.map((widget) => widget.widgetId)
expect(ids).toStrictEqual([
widgetId(graph.id, subgraphNode.id, 'first_seed'),
expect(widgets).toHaveLength(2)
expect(widgets?.[0]?.widgetId).toBe(
widgetId(graph.id, subgraphNode.id, 'first_seed')
)
expect(widgets?.[1]?.widgetId).toBe(
widgetId(graph.id, subgraphNode.id, 'second_seed')
])
expect(ids[0]).not.toBe(ids[1])
)
expect(widgets?.[0]?.widgetId).not.toBe(widgets?.[1]?.widgetId)
})
})
describe('Promoted widget render state', () => {
describe('Promoted widget sourceExecutionId', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('registers plain render metadata for promoted widgets', () => {
it('sets sourceExecutionId to the interior node execution ID for promoted widgets', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'ckpt_input', type: '*' }]
})
@@ -436,21 +451,22 @@ describe('Promoted widget render state', () => {
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
useGraphNodeManager(graph)
const renderState = useWidgetValueStore().getWidgetRenderState(
widgetId(graph.id, subgraphNode.id, 'ckpt_input')
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNode.id)
const promotedWidget = nodeData?.widgets?.find(
(w) => w.name === 'ckpt_input'
)
expect(renderState).toMatchObject({
hasLayoutSize: false,
isDOMWidget: false
})
expect(renderState).not.toHaveProperty('sourceWidgetName')
expect(renderState).not.toHaveProperty('sourceExecutionId')
expect(promotedWidget).toBeDefined()
expect(promotedWidget?.sourceWidgetName).toBe('ckpt_name')
// The interior node is inside subgraphNode (id=65),
// so its execution ID should be "65:<interiorNodeId>"
expect(promotedWidget?.sourceExecutionId).toBe(
`${subgraphNode.id}:${interiorNode.id}`
)
})
it('registers plain render metadata for non-promoted widgets', () => {
it('does not set sourceExecutionId for non-promoted widgets', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addWidget('number', 'steps', 20, () => undefined, {})
@@ -458,14 +474,12 @@ describe('Promoted widget render state', () => {
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
useGraphNodeManager(graph)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)
const widget = nodeData?.widgets?.find((w) => w.name === 'steps')
const renderState = useWidgetValueStore().getWidgetRenderState(
widgetId(graph.id, node.id, 'steps')
)
expect(renderState).toBeDefined()
expect(renderState).not.toHaveProperty('sourceExecutionId')
expect(widget).toBeDefined()
expect(widget?.sourceExecutionId).toBeUndefined()
})
})

View File

@@ -1,6 +1,14 @@
/**
* Vue node lifecycle management for LiteGraph integration
* Provides event-driven reactivity with performance optimizations
*/
import { reactiveComputed } from '@vueuse/core'
import cloneDeep from 'es-toolkit/compat/cloneDeep'
import { reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { promotedInputWidgets } from '@/core/graph/subgraph/promotedInputWidget'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import type {
INodeInputSlot,
INodeOutputSlot
@@ -11,6 +19,16 @@ import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import { toNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isDOMWidget } from '@/scripts/domWidget'
import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
import { normalizeControlOption } from '@/types/simplifiedWidget'
import { getWidgetIdForNode } from '@/utils/litegraphUtil'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import type { WidgetId } from '@/types/widgetId'
import type {
LGraph,
@@ -18,13 +36,70 @@ import type {
LGraphNode,
LGraphTriggerAction,
LGraphTriggerEvent,
LGraphTriggerParam
LGraphTriggerParam,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { app } from '@/scripts/app'
export interface WidgetSlotMetadata {
index: number
linked: boolean
originNodeId?: NodeId
originOutputName?: string
type: string
}
type Badges = (LGraphBadge | (() => LGraphBadge))[]
/**
* Minimal render-specific widget data extracted from LiteGraph widgets.
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
*/
export interface SafeWidgetData {
widgetId?: WidgetId
nodeId?: NodeId
name: string
type: string
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
callback?: ((value: unknown) => void) | undefined
/** Control widget for seed randomization/increment/decrement */
controlWidget?: SafeControlWidget
/** Whether widget has custom layout size computation */
hasLayoutSize?: boolean
/** Whether widget is a DOM widget */
isDOMWidget?: boolean
/**
* Widget options needed for render decisions.
* Note: Most metadata should be accessed via widgetValueStore.getWidget().
*/
options?: {
canvasOnly?: boolean
advanced?: boolean
hidden?: boolean
read_only?: boolean
values?: unknown
}
/** Input specification from node definition */
spec?: InputSpec
/** Input slot metadata (index and link status) */
slotMetadata?: WidgetSlotMetadata
/**
* Execution ID of the interior node that owns the source widget.
* Only set for promoted widgets where the source node differs from the host
* subgraph node. Retained for source-scoped validation errors.
*/
sourceExecutionId?: NodeExecutionId
/**
* Interior source widget name. Only set for promoted widgets, where `name` is
* the host input slot name and the source widget name can differ.
*/
sourceWidgetName?: string
/** Tooltip text from the resolved widget. */
tooltip?: string
}
export interface VueNodeData {
executing: boolean
id: NodeId
@@ -49,24 +124,260 @@ export interface VueNodeData {
showAdvanced?: boolean
subgraphId?: string | null
titleMode?: TitleMode
widgets?: SafeWidgetData[]
}
export interface GraphNodeManager {
// Reactive state - safe data extracted from LiteGraph nodes
vueNodeData: ReadonlyMap<NodeId, VueNodeData>
// Access to original LiteGraph nodes (non-reactive)
getNode(id: NodeId): LGraphNode | undefined
// Lifecycle methods
cleanup(): void
}
function makeReactiveNodeArrays(node: LGraphNode): {
inputs: INodeInputSlot[]
outputs: INodeOutputSlot[]
} {
export function getControlWidget(
widget: IBaseWidget
): SafeControlWidget | undefined {
const cagWidget = widget.linkedWidgets?.find((w) => w[IS_CONTROL_WIDGET])
if (!cagWidget) return
return {
value: normalizeControlOption(cagWidget.value),
update: (value) => (cagWidget.value = normalizeControlOption(value))
}
}
interface SharedWidgetEnhancements {
controlWidget?: SafeControlWidget
spec?: InputSpec
}
function getSharedWidgetEnhancements(
node: LGraphNode,
widget: IBaseWidget
): SharedWidgetEnhancements {
const nodeDefStore = useNodeDefStore()
return {
controlWidget: getControlWidget(widget),
spec: nodeDefStore.getInputSpecForWidget(node, widget.name)
}
}
/**
* Validates that a value is a valid WidgetValue type
*/
function normalizeWidgetValue(value: unknown): WidgetValue {
if (value === null || value === undefined || value === void 0) {
return undefined
}
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value
}
if (typeof value === 'object') {
// Check if it's a File array
if (
Array.isArray(value) &&
value.length > 0 &&
value.every((item): item is File => item instanceof File)
) {
return value
}
// Otherwise it's a generic object
return value
}
// If none of the above, return undefined
console.warn(`Invalid widget value type: ${typeof value}`, value)
return undefined
}
function extractWidgetDisplayOptions(
widget: IBaseWidget
): SafeWidgetData['options'] {
if (!widget.options) return undefined
return {
canvasOnly: widget.options.canvasOnly,
advanced: widget.options?.advanced ?? widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
}
}
function isDOMBackedWidget(widget: IBaseWidget): boolean {
return (
('element' in widget && !!widget.element) ||
('component' in widget && !!widget.component)
)
}
interface PromotedWidgetMetadata {
controlWidget?: SafeControlWidget
isDOMWidget: boolean
sourceExecutionId?: NodeExecutionId
sourceWidgetName?: string
}
/**
* Resolves the interior source of a promoted subgraph input to derive the
* metadata that backend lookups key by (execution ID, interior widget name)
* plus the source widget's control + DOM nature. Also seeds host widget state
* if it is somehow missing. Returns undefined when the widget is not promoted.
*/
function resolvePromotedMetadata(
node: SubgraphNode,
widget: IBaseWidget
): PromotedWidgetMetadata | undefined {
const source = resolvePromotedWidgetSource(app.rootGraph, node, widget)
if (!source) return undefined
ensurePromotedHostWidgetState(
source.input.widgetId,
source.input,
source.sourceWidget
)
return {
controlWidget: getControlWidget(source.sourceWidget),
isDOMWidget: isDOMBackedWidget(source.sourceWidget),
sourceExecutionId: source.sourceExecutionId,
sourceWidgetName: source.sourceWidgetName
}
}
function safeWidgetMapper(
node: LGraphNode,
slotMetadata: Map<string, WidgetSlotMetadata>
): (widget: IBaseWidget) => SafeWidgetData {
const duplicateIndexByKey = new Map<string, number>()
return function (widget) {
try {
const duplicateKey = `${widget.name}:${widget.type}`
const duplicateIndex = duplicateIndexByKey.get(duplicateKey) ?? 0
duplicateIndexByKey.set(duplicateKey, duplicateIndex + 1)
const slotInfo = slotMetadata.get(widget.name)
// Wrapper callback specific to Nodes 2.0 rendering
const callback = (v: unknown) => {
const value = normalizeWidgetValue(v)
widget.value = value ?? undefined
// Match litegraph callback signature: (value, canvas, node, pos, event)
// Some extensions (e.g., Impact Pack) expect node as the 3rd parameter
widget.callback?.(value, app.canvas, node)
// Trigger redraw for all legacy widgets on this node (e.g., mask preview)
// This ensures widgets that depend on other widget values get updated
node.widgets?.forEach((w) => w.triggerDraw?.())
}
const promoted = node.isSubgraphNode()
? resolvePromotedMetadata(node, widget)
: undefined
return {
widgetId: getWidgetIdForNode(node, widget, duplicateIndex),
name: widget.name,
type: widget.type,
...getSharedWidgetEnhancements(node, widget),
...(promoted?.controlWidget && {
controlWidget: promoted.controlWidget
}),
callback,
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
isDOMWidget: promoted?.isDOMWidget ?? isDOMWidget(widget),
options: extractWidgetDisplayOptions(widget),
slotMetadata: slotInfo,
sourceExecutionId: promoted?.sourceExecutionId,
sourceWidgetName: promoted?.sourceWidgetName,
tooltip: widget.tooltip
}
} catch (error) {
console.warn(
'[safeWidgetMapper] Failed to map widget:',
widget.name,
error
)
return {
name: widget.name || 'unknown',
type: widget.type || 'text'
}
}
}
}
function ensurePromotedHostWidgetState(
id: WidgetId,
input: INodeInputSlot,
sourceWidget: IBaseWidget | undefined
): void {
if (!sourceWidget) return
const store = useWidgetValueStore()
if (store.getWidget(id)) return
store.registerWidget(id, {
type: sourceWidget.type,
value: sourceWidget.value,
options: cloneDeep(sourceWidget.options ?? {}),
label: input.label ?? input.name,
serialize: sourceWidget.serialize,
disabled: sourceWidget.disabled
})
}
function buildSlotMetadata(
inputs: INodeInputSlot[] | undefined,
graphRef: LGraph | null | undefined
): Map<string, WidgetSlotMetadata> {
const metadata = new Map<string, WidgetSlotMetadata>()
inputs?.forEach((input, index) => {
let originNodeId: NodeId | undefined
let originOutputName: string | undefined
if (input.link != null && graphRef) {
const link = graphRef.getLink(input.link)
const originNode = link ? graphRef.getNodeById(link.origin_id) : null
if (link && originNode) {
originNodeId = link.origin_id
originOutputName = originNode.outputs?.[link.origin_slot]?.name
}
}
const slotInfo: WidgetSlotMetadata = {
index,
linked: input.link != null,
originNodeId,
originOutputName,
type: String(input.type)
}
if (input.name) metadata.set(input.name, slotInfo)
if (input.widget?.name) metadata.set(input.widget.name, slotInfo)
})
return metadata
}
// Extract safe data from LiteGraph node for Vue consumption
export function extractVueNodeData(node: LGraphNode): VueNodeData {
// Determine subgraph ID - null for root graph, string for subgraphs
const subgraphId =
node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph
? String(node.graph.id)
: null
// Extract safe widget data
const slotMetadata = new Map<string, WidgetSlotMetadata>()
const existingWidgetsDescriptor = Object.getOwnPropertyDescriptor(
node,
'widgets'
)
const reactiveWidgets = shallowReactive<IBaseWidget[]>(node.widgets ?? [])
if (existingWidgetsDescriptor?.get) {
// Node has a custom widgets getter (e.g. SubgraphNode's synthetic getter).
// Preserve it but sync results into a reactive array for Vue.
const originalGetter = existingWidgetsDescriptor.get
Object.defineProperty(node, 'widgets', {
get() {
@@ -95,7 +406,6 @@ function makeReactiveNodeArrays(node: LGraphNode): {
enumerable: true
})
}
const reactiveInputs = shallowReactive<INodeInputSlot[]>(node.inputs ?? [])
Object.defineProperty(node, 'inputs', {
get() {
@@ -107,7 +417,6 @@ function makeReactiveNodeArrays(node: LGraphNode): {
configurable: true,
enumerable: true
})
const reactiveOutputs = shallowReactive<INodeOutputSlot[]>(node.outputs ?? [])
Object.defineProperty(node, 'outputs', {
get() {
@@ -120,16 +429,19 @@ function makeReactiveNodeArrays(node: LGraphNode): {
enumerable: true
})
return { inputs: reactiveInputs, outputs: reactiveOutputs }
}
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
const freshMetadata = buildSlotMetadata(node.inputs, node.graph)
slotMetadata.clear()
for (const [key, value] of freshMetadata) {
slotMetadata.set(key, value)
}
export function extractVueNodeData(node: LGraphNode): VueNodeData {
const subgraphId =
node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph
? String(node.graph.id)
: null
const widgets = node.isSubgraphNode()
? promotedInputWidgets(node)
: (node.widgets ?? [])
return widgets.map(safeWidgetMapper(node, slotMetadata))
})
const { inputs, outputs } = makeReactiveNodeArrays(node)
const nodeType =
node.type ||
node.constructor?.comfyClass ||
@@ -137,6 +449,9 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
node.constructor?.name ||
'Unknown'
const apiNode = node.constructor?.nodeData?.api_node ?? false
const badges = node.badges
return {
id: node.id,
title: typeof node.title === 'string' ? node.title : '',
@@ -144,13 +459,14 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
mode: node.mode || 0,
titleMode: node.title_mode,
selected: node.selected || false,
executing: false,
executing: false, // Will be updated separately based on execution state
subgraphId,
apiNode: node.constructor?.nodeData?.api_node ?? false,
badges: node.badges,
apiNode,
badges,
hasErrors: !!node.has_errors,
inputs,
outputs,
widgets: safeWidgets,
inputs: reactiveInputs,
outputs: reactiveOutputs,
flags: node.flags ? { ...node.flags } : undefined,
color: node.color || undefined,
bgcolor: node.bgcolor || undefined,
@@ -161,26 +477,39 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
}
export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Get layout mutations composable
const { createNode, deleteNode, setSource } = useLayoutMutations()
// Safe reactive data extracted from LiteGraph nodes
const vueNodeData = reactive(new Map<NodeId, VueNodeData>())
// Non-reactive storage for original LiteGraph nodes
const nodeRefs = new Map<NodeId, LGraphNode>()
const refreshNodeInputs = (nodeId: NodeId) => {
const refreshNodeSlots = (nodeId: NodeId) => {
const nodeRef = nodeRefs.get(nodeId)
const currentData = vueNodeData.get(nodeId)
if (!nodeRef?.inputs || !currentData) return
nodeRef.inputs = [...nodeRef.inputs]
vueNodeData.set(nodeId, { ...currentData, inputs: nodeRef.inputs })
if (!nodeRef || !currentData) return
const slotMetadata = buildSlotMetadata(nodeRef.inputs, graph)
// Update only widgets with new slot metadata, keeping other widget data intact
for (const widget of currentData.widgets ?? []) {
widget.slotMetadata = slotMetadata.get(widget.name)
}
}
const getNode = (id: NodeId): LGraphNode | undefined => nodeRefs.get(id)
// Get access to original LiteGraph node (non-reactive)
const getNode = (id: NodeId): LGraphNode | undefined => {
return nodeRefs.get(id)
}
const syncWithGraph = () => {
if (!graph?._nodes) return
const currentNodes = new Set(graph._nodes.map((n) => n.id))
// Remove deleted nodes
for (const id of Array.from(vueNodeData.keys())) {
if (!currentNodes.has(id)) {
nodeRefs.delete(id)
@@ -188,49 +517,76 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
}
// Add/update existing nodes
graph._nodes.forEach((node) => {
const id = node.id
// Store non-reactive reference
nodeRefs.set(id, node)
// Extract and store safe data for Vue
vueNodeData.set(id, extractVueNodeData(node))
})
}
/**
* Handles node addition to the graph - sets up Vue state and spatial indexing
* Defers position extraction until after potential configure() calls
*/
const handleNodeAdded = (
node: LGraphNode,
originalCallback?: (node: LGraphNode) => void
) => {
const id = node.id
// Store non-reactive reference to original node
nodeRefs.set(id, node)
// Extract initial data for Vue (may be incomplete during graph configure)
vueNodeData.set(id, extractVueNodeData(node))
const initializeVueNodeLayout = () => {
// Check if the node was removed mid-sequence
if (!nodeRefs.has(id)) return
// Extract actual positions after configure() has potentially updated them
const nodePosition = { x: node.pos[0], y: node.pos[1] }
const nodeSize = { width: node.size[0], height: node.size[1] }
// Skip layout creation if it already exists
// (e.g. in-place node replacement where the old node's layout is reused for the new node with the same ID).
const existingLayout = layoutStore.getNodeLayoutRef(id).value
if (existingLayout) return
// Add node to layout store with final positions
setSource(LayoutSource.Canvas)
void createNode(id, {
position: { x: node.pos[0], y: node.pos[1] },
size: { width: node.size[0], height: node.size[1] },
position: nodePosition,
size: nodeSize,
zIndex: node.order || 0,
visible: true
})
}
// Check if we're in the middle of configuring the graph (workflow loading)
if (window.app?.configuringGraph) {
// During workflow loading - defer layout initialization until configure completes
// Chain our callback with any existing onAfterGraphConfigured callback
node.onAfterGraphConfigured = useChainCallback(
node.onAfterGraphConfigured,
() => {
// Re-extract data now that configure() has populated title/slots/widgets/etc.
vueNodeData.set(id, extractVueNodeData(node))
initializeVueNodeLayout()
}
)
} else {
// Not during workflow loading - initialize layout immediately
// This handles individual node additions during normal operation
initializeVueNodeLayout()
}
// Call original callback if provided
if (originalCallback) {
void originalCallback(node)
}
@@ -247,13 +603,16 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
) => {
const id = node.id
// Remove node from layout store
setSource(LayoutSource.Canvas)
void deleteNode(id)
dropNodeReferences(id)
for (const nodeId of nodeRefs.keys()) refreshNodeInputs(nodeId)
originalCallback?.(node)
}
/**
* Creates cleanup function for event listeners and state
*/
const createCleanupFunction = (
originalOnNodeAdded: ((node: LGraphNode) => void) | undefined,
originalOnNodeRemoved: ((node: LGraphNode) => void) | undefined,
@@ -261,6 +620,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
beforeNodeRemovedListener: (e: CustomEvent<{ node: LGraphNode }>) => void
) => {
return () => {
// Restore original callbacks
graph.onNodeAdded = originalOnNodeAdded || undefined
graph.onNodeRemoved = originalOnNodeRemoved || undefined
graph.onTrigger = originalOnTrigger || undefined
@@ -270,16 +630,19 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
beforeNodeRemovedListener
)
// Clear all state maps
nodeRefs.clear()
vueNodeData.clear()
}
}
const setupEventListeners = (): (() => void) => {
// Store original callbacks
const originalOnNodeAdded = graph.onNodeAdded
const originalOnNodeRemoved = graph.onNodeRemoved
const originalOnTrigger = graph.onTrigger
// Set up graph event handlers
graph.onNodeAdded = (node: LGraphNode) => {
handleNodeAdded(node, originalOnNodeAdded)
}
@@ -398,11 +761,11 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
},
'node:slot-errors:changed': (slotErrorsEvent) => {
refreshNodeInputs(toNodeId(slotErrorsEvent.nodeId))
refreshNodeSlots(toNodeId(slotErrorsEvent.nodeId))
},
'node:slot-links:changed': (slotLinksEvent) => {
if (slotLinksEvent.slotType === NodeSlotType.INPUT) {
refreshNodeInputs(toNodeId(slotLinksEvent.nodeId))
refreshNodeSlots(toNodeId(slotLinksEvent.nodeId))
}
},
'node:slot-label:changed': (slotLabelEvent) => {
@@ -410,12 +773,16 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
const nodeRef = nodeRefs.get(nodeId)
if (!nodeRef) return
// Force shallowReactive to detect the deep property change
// by re-assigning the affected array through the defineProperty setter.
if (slotLabelEvent.slotType !== NodeSlotType.OUTPUT && nodeRef.inputs) {
nodeRef.inputs = [...nodeRef.inputs]
}
if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) {
nodeRef.outputs = [...nodeRef.outputs]
}
// Re-extract widget data so the label reflects the rename
vueNodeData.set(nodeId, extractVueNodeData(nodeRef))
}
}
@@ -435,9 +802,11 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
break
}
// Chain to original handler
originalOnTrigger?.(event)
}
// Initialize state
syncWithGraph()
return createCleanupFunction(
@@ -448,8 +817,10 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
)
}
// Set up event listeners immediately
const cleanup = setupEventListeners()
// Process any existing nodes after event listeners are set up
if (graph._nodes && graph._nodes.length > 0) {
graph._nodes.forEach((node: LGraphNode) => {
if (graph.onNodeAdded) {

View File

@@ -1,105 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { LGraph, LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useViewErrorsInGraph } from './useViewErrorsInGraph'
const apiMock = vi.hoisted(() => ({
getSettings: vi.fn(),
storeSetting: vi.fn(),
storeSettings: vi.fn()
}))
vi.mock('@/scripts/api', () => ({
api: apiMock
}))
const appMock = vi.hoisted(() => ({
ui: {
settings: {
dispatchChange: vi.fn()
}
},
rootGraph: {
events: new EventTarget(),
nodes: []
}
}))
vi.mock('@/scripts/app', () => ({
app: appMock
}))
function createSelectedCanvas() {
const graph = new LGraph()
const canvasElement = document.createElement('canvas')
canvasElement.width = 800
canvasElement.height = 600
canvasElement.getContext = vi
.fn()
.mockReturnValue(createMockCanvasRenderingContext2D())
const canvas = new LGraphCanvas(canvasElement, graph, {
skip_events: true,
skip_render: true
})
const node = new LGraphNode('Selected Node')
graph.add(node)
canvas.selectedItems.add(node)
node.selected = true
return { canvas, node }
}
describe('useViewErrorsInGraph', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
apiMock.getSettings.mockResolvedValue({})
apiMock.storeSetting.mockResolvedValue(undefined)
apiMock.storeSettings.mockResolvedValue(undefined)
})
it('opens graph errors and clears app-mode error UI state', () => {
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
const workflowStore = useWorkflowStore()
const { canvas, node } = createSelectedCanvas()
workflowStore.activeWorkflow = {
activeMode: 'app'
} as typeof workflowStore.activeWorkflow
canvasStore.canvas = canvas
canvasStore.selectedItems = [node]
executionErrorStore.showErrorOverlay()
useViewErrorsInGraph().viewErrorsInGraph()
expect(node.selected).toBe(false)
expect(canvasStore.linearMode).toBe(false)
expect(canvasStore.selectedItems).toEqual([])
expect(rightSidePanelStore.activeTab).toBe('errors')
expect(rightSidePanelStore.isOpen).toBe(true)
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
})
it('opens graph errors when the canvas is not initialized', () => {
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
canvasStore.canvas = null
executionErrorStore.showErrorOverlay()
expect(() => useViewErrorsInGraph().viewErrorsInGraph()).not.toThrow()
expect(rightSidePanelStore.activeTab).toBe('errors')
expect(rightSidePanelStore.isOpen).toBe(true)
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
})
})

View File

@@ -1,22 +0,0 @@
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
export function useViewErrorsInGraph() {
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
function viewErrorsInGraph() {
canvasStore.linearMode = false
if (canvasStore.canvas) {
canvasStore.canvas.deselectAll()
canvasStore.updateSelectedItems()
}
rightSidePanelStore.openPanel('errors')
executionErrorStore.dismissErrorOverlay()
}
return { viewErrorsInGraph }
}

View File

@@ -145,13 +145,6 @@ function applySubgraphInputOrder(
})
reorderSubgraphInputs(subgraphNode, orderedIndices)
useWidgetValueStore().setNodeWidgetOrder(
subgraphNode.rootGraph.id,
subgraphNode.id,
subgraphNode.inputs.flatMap((input) =>
input.widgetId ? [input.widgetId] : []
)
)
for (const [newIndex, oldIndex] of orderedIndices.entries()) {
const value = widgetValues[oldIndex]
@@ -288,21 +281,21 @@ function seedNestedPromotedInputState(
)
if (!hostInput || hostInput.widgetId) return
const store = useWidgetValueStore()
const sourceState = store.getWidget(sourceSlot.widgetId)
const sourceState = useWidgetValueStore().getWidget(sourceSlot.widgetId)
if (!sourceState) return
const id = widgetId(subgraphNode.rootGraph.id, subgraphNode.id, inputName)
hostInput.widget ??= { name: inputName }
hostInput.widget.name = inputName
hostInput.widgetId = id
store.registerWidget(id, {
useWidgetValueStore().registerWidget(id, {
type: sourceState.type,
value: sourceState.value,
options: cloneDeep(sourceState.options ?? {}),
label: hostInput.label ?? sourceSlot.label ?? inputName,
serialize: sourceState.serialize,
disabled: sourceState.disabled
disabled: sourceState.disabled,
isDOMWidget: sourceState.isDOMWidget
})
}

View File

@@ -11,10 +11,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LLink } from '@/lib/litegraph/src/LLink'
import { commonType } from '@/lib/litegraph/src/utils/type'
import {
getWidgetIds,
resolveNodeRootGraphId
} from '@/lib/litegraph/src/utils/widget'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/utils/widget'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -51,16 +48,6 @@ type AutogrowNode = LGraphNode &
}
}
function syncNodeWidgetOrder(node: LGraphNode) {
const graphId = resolveNodeRootGraphId(node)
if (!graphId || !node.widgets) return
useWidgetValueStore().setNodeWidgetOrder(
graphId,
node.id,
getWidgetIds(node.widgets)
)
}
function ensureWidgetForInput(node: LGraphNode, input: INodeInputSlot) {
node.widgets ??= []
const { widget } = input
@@ -118,10 +105,7 @@ function dynamicComboWidget(
if (widget.widgetId) deleteWidget(widget.widgetId)
}
if (!newSpec) {
syncNodeWidgetOrder(node)
return
}
if (!newSpec) return
const insertionPoint = node.widgets.findIndex((w) => w === widget) + 1
const startingLength = node.widgets.length
@@ -156,7 +140,6 @@ function dynamicComboWidget(
node.inputs.findIndex((i) => i.name === widget.name) + 1
const addedWidgets = node.widgets.splice(startingLength)
node.widgets.splice(insertionPoint, 0, ...addedWidgets)
syncNodeWidgetOrder(node)
if (inputInsertionPoint === 0) {
if (
addedWidgets.length === 0 &&
@@ -558,11 +541,8 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
for (const input of toRemove) {
const widgetName = input?.widget?.name
if (!widgetName) continue
for (const widget of remove(node.widgets, (w) => w.name === widgetName)) {
for (const widget of remove(node.widgets, (w) => w.name === widgetName))
widget.onRemove?.()
if (widget.widgetId) useWidgetValueStore().deleteWidget(widget.widgetId)
}
syncNodeWidgetOrder(node)
}
node.size[1] = node.computeSize([...node.size])[1]
}

View File

@@ -1,5 +1,3 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph } from '@/lib/litegraph/src/litegraph'
@@ -63,8 +61,6 @@ async function createNodeWithFilenamePrefix(
describe('Comfy.SaveImageExtraOutput', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
const graph = new LGraph()
graph.add({
properties: { 'Node name for S&R': 'Sampler' },

View File

@@ -59,7 +59,6 @@ import {
snapPoint
} from './measure'
import { warnDeprecated } from './utils/feedback'
import { getWidgetIds } from './utils/widget'
import { SubgraphInput } from './subgraph/SubgraphInput'
import { SubgraphInputNode } from './subgraph/SubgraphInputNode'
import { SubgraphOutput } from './subgraph/SubgraphOutput'
@@ -1007,15 +1006,9 @@ export class LGraph
// Register all widgets with the WidgetValueStore now that node has a
// valid ID and graph reference.
if (node.widgets) {
const widgetValueStore = useWidgetValueStore()
for (const widget of node.widgets) {
if (isNodeBindable(widget)) widget.setNodeId(node.id)
}
widgetValueStore.setNodeWidgetOrder(
this.rootGraph.id,
node.id,
getWidgetIds(node.widgets)
)
}
this._nodes.push(node)

View File

@@ -93,7 +93,7 @@ describe('drawConnections widget-input slot positioning', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
setActivePinia(createTestingPinia())
canvasElement = document.createElement('canvas')
canvasElement.width = 800

View File

@@ -9,7 +9,6 @@ import type { SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotC
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { toLinkId } from '@/types/linkId'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { UNASSIGNED_NODE_ID, toNodeId, serializeNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import { adjustColor } from '@/utils/colorUtil'
@@ -97,7 +96,6 @@ import type {
} from './types/widgets'
import { findFreeSlotOfType } from './utils/collections'
import { warnDeprecated } from './utils/feedback'
import { getWidgetIds } from './utils/widget'
import { distributeSpace } from './utils/spaceDistribution'
import { truncateText } from './utils/textUtils'
import { BaseWidget } from './widgets/BaseWidget'
@@ -2057,17 +2055,6 @@ export class LGraphNode
widget.onRemove?.()
this.widgets.splice(widgetIndex, 1)
const graphId = this.graph?.rootGraph.id
if (graphId) {
const widgetValueStore = useWidgetValueStore()
if (widget.widgetId) widgetValueStore.deleteWidget(widget.widgetId)
widgetValueStore.setNodeWidgetOrder(
graphId,
this.id,
getWidgetIds(this.widgets)
)
}
}
ensureWidgetRemoved(widget: IBaseWidget): void {

View File

@@ -53,16 +53,6 @@ workflowSvg.src =
const workflowBitmapCache = createBitmapCache(workflowSvg, 32)
function isDOMBackedWidget(widget: Readonly<IBaseWidget>): boolean {
if ('isDOMWidget' in widget && typeof widget.isDOMWidget === 'boolean') {
return widget.isDOMWidget
}
return (
('element' in widget && !!widget.element) ||
('component' in widget && !!widget.component)
)
}
export class SubgraphNode extends LGraphNode implements BaseLGraph {
declare inputs: (INodeInputSlot & Partial<ISubgraphInput>)[]
@@ -653,25 +643,23 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (inputWidget) Object.setPrototypeOf(input.widget, inputWidget)
const id = widgetId(this.rootGraph.id, this.id, subgraphInput.name)
const store = useWidgetValueStore()
input.widgetId = id
store.registerWidget(id, {
useWidgetValueStore().registerWidget(id, {
type: interiorWidget.type,
value: interiorWidget.value,
options: cloneDeep(interiorWidget.options ?? {}),
label: input.label ?? subgraphInput.name,
serialize: interiorWidget.serialize,
disabled: interiorWidget.disabled
disabled: interiorWidget.disabled,
isDOMWidget:
'isDOMWidget' in interiorWidget &&
typeof interiorWidget.isDOMWidget === 'boolean'
? interiorWidget.isDOMWidget
: undefined
})
input._widget =
this.createPromotedHostWidget(input, id, interiorWidget) ??
this._projectPromotedWidget(input)
store.registerWidgetRenderState(id, {
advanced: interiorWidget.options?.advanced ?? interiorWidget.advanced,
hasLayoutSize: typeof interiorWidget.computeLayoutSize === 'function',
isDOMWidget: isDOMBackedWidget(interiorWidget),
tooltip: interiorWidget.tooltip
})
this._setConcreteSlots()
this.subgraph.events.dispatch('widget-promoted', {

View File

@@ -2,40 +2,12 @@ import { describe, expect, test } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IWidgetOptions } from '@/lib/litegraph/src/litegraph'
import { toNodeId } from '@/types/nodeId'
import { widgetId } from '@/types/widgetId'
import { getNodeWidgetIds } from '@/lib/litegraph/src/utils/widget'
import {
evaluateInput,
getWidgetStep,
resolveNodeRootGraphId
} from '@/lib/litegraph/src/litegraph'
describe('getNodeWidgetIds', () => {
test('includes promoted widget ids stored on input slots', () => {
const seedId = widgetId('graph', toNodeId(10), 'seed')
const textId = widgetId('graph', toNodeId(10), 'text')
expect(
getNodeWidgetIds({
widgets: [{ widgetId: seedId }, {}],
inputs: [{}, { widgetId: textId }]
})
).toStrictEqual([seedId, textId])
})
test('deduplicates widget ids when the same id is on widget and input', () => {
const seedId = widgetId('graph', toNodeId(10), 'seed')
expect(
getNodeWidgetIds({
widgets: [{ widgetId: seedId }],
inputs: [{ widgetId: seedId }]
})
).toStrictEqual([seedId])
})
})
describe('getWidgetStep', () => {
test('should return step2 when available', () => {
const options: IWidgetOptions<unknown> = {

View File

@@ -1,6 +1,5 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import type { WidgetId } from '@/types/widgetId'
import type { UUID } from '@/utils/uuid'
import { evaluateMathExpression } from '@/lib/litegraph/src/utils/mathParser'
@@ -25,26 +24,6 @@ export function evaluateInput(input: string): number | undefined {
return newValue
}
export function getWidgetIds(
widgets: readonly { readonly widgetId?: WidgetId }[]
): WidgetId[] {
return widgets
.map((widget) => widget.widgetId)
.filter((id): id is WidgetId => id !== undefined)
}
export function getNodeWidgetIds(node: {
readonly widgets?: readonly { readonly widgetId?: WidgetId }[]
readonly inputs?: readonly { readonly widgetId?: WidgetId }[]
}): WidgetId[] {
return Array.from(
new Set([
...getWidgetIds(node.widgets ?? []),
...getWidgetIds(node.inputs ?? [])
])
)
}
export function resolveNodeRootGraphId(
node: Pick<LGraphNode, 'graph'>
): UUID | undefined

View File

@@ -4,15 +4,7 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
IBaseWidget,
INumericWidget
} from '@/lib/litegraph/src/types/widgets'
import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import type {
DrawWidgetOptions,
WidgetEventOptions
} from '@/lib/litegraph/src/widgets/BaseWidget'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
import { NumberWidget } from '@/lib/litegraph/src/widgets/NumberWidget'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { toNodeId } from '@/types/nodeId'
@@ -35,28 +27,6 @@ function createTestWidget(
)
}
class MutableTypeWidget extends BaseWidget<IBaseWidget<number, string>> {
drawWidget(
_ctx: CanvasRenderingContext2D,
_options: DrawWidgetOptions
): void {}
onClick(_options: WidgetEventOptions): void {}
}
function createMutableTypeWidget(node: LGraphNode): MutableTypeWidget {
return new MutableTypeWidget(
{
type: 'number',
name: 'typeChangedWidget',
value: 42,
options: { min: 0, max: 100 },
y: 0
},
node
)
}
describe('BaseWidget store integration', () => {
let graph: LGraph
let node: LGraphNode
@@ -205,31 +175,6 @@ describe('BaseWidget store integration', () => {
store.getWidget(widgetId(graph.id, toNodeId(1), 'valuesWidget'))?.value
).toBe(77)
})
it('registers the live widget type', () => {
const widget = createMutableTypeWidget(node)
widget.type = 'number-custom'
widget.setNodeId(toNodeId(1))
expect(
store.getWidget(widgetId(graph.id, toNodeId(1), 'typeChangedWidget'))
?.type
).toBe('number-custom')
})
it('stores explicit isDOMWidget false over component presence', () => {
const widget = createTestWidget(node, { name: 'flaggedDomWidget' })
Object.assign(widget, { component: {}, isDOMWidget: false })
widget.setNodeId(toNodeId(1))
expect(
store.getWidgetRenderState(
widgetId(graph.id, toNodeId(1), 'flaggedDomWidget')
)?.isDOMWidget
).toBe(false)
})
})
describe('DOM widget value registration', () => {

View File

@@ -46,16 +46,6 @@ export interface WidgetEventOptions {
canvas: LGraphCanvas
}
function isDOMBackedWidget(widget: IBaseWidget): boolean {
if ('isDOMWidget' in widget && typeof widget.isDOMWidget === 'boolean') {
return widget.isDOMWidget
}
return (
('element' in widget && !!widget.element) ||
('component' in widget && !!widget.component)
)
}
export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
implements IBaseWidget, NodeBindable
{
@@ -157,19 +147,13 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
const graphId = this.node.graph?.rootGraph.id
if (!graphId) return
const store = useWidgetValueStore()
const id = widgetId(graphId, nodeId, this.name)
this._state = store.registerWidget(id, {
...this._state,
type: this.type,
value: this.value
})
store.registerWidgetRenderState(id, {
advanced: this.options?.advanced ?? this.advanced,
hasLayoutSize: typeof this.computeLayoutSize === 'function',
isDOMWidget: isDOMBackedWidget(this),
tooltip: this.tooltip
})
this._state = useWidgetValueStore().registerWidget(
widgetId(graphId, nodeId, this.name),
{
...this._state,
value: this.value
}
)
}
constructor(widget: TWidget & { node: LGraphNode })

View File

@@ -1,208 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen, within } from '@testing-library/vue'
import { setActivePinia } from 'pinia'
import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NodeError } from '@/schemas/apiSchema'
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { toNodeId } from '@/types/nodeId'
const billingMock = vi.hoisted(() => ({
isActiveSubscription: true
}))
const overlayMock = vi.hoisted(() => ({
overlayMessage: 'KSampler is missing a required input: model',
overlayTitle: 'Required input missing'
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: billingMock.isActiveSubscription
})
}))
vi.mock('@/components/error/useErrorOverlayState', () => ({
useErrorOverlayState: () => ({
overlayMessage: overlayMock.overlayMessage,
overlayTitle: overlayMock.overlayTitle
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
linearMode: {
error: {
goto: 'Show errors in graph'
},
mobileNoWorkflow: 'No workflow',
runCount: 'Run count',
viewJob: 'View job'
},
menu: {
run: 'Run'
},
menuLabels: {
publish: 'Publish'
},
queue: {
jobAddedToQueue: 'Job added to queue',
jobQueueing: 'Queueing'
}
}
}
})
const nodeErrors: Record<string, NodeError> = {
'1': {
class_type: 'TestNode',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Missing input',
details: '',
extra_info: { input_name: 'prompt' }
}
]
}
}
function renderControls({
hasError = false,
isActiveSubscription = true,
mobile = false
}: {
hasError?: boolean
isActiveSubscription?: boolean
mobile?: boolean
} = {}) {
billingMock.isActiveSubscription = isActiveSubscription
const pinia = createTestingPinia({
createSpy: vi.fn,
stubActions: false
})
setActivePinia(pinia)
useAppModeStore().selectedOutputs = [toNodeId(1)]
if (hasError) {
useExecutionErrorStore().lastNodeErrors = nodeErrors
}
const toastTarget = document.createElement('div')
return render(LinearControls, {
props: { mobile, toastTo: toastTarget },
global: {
plugins: [pinia, i18n],
stubs: {
AppModeWidgetList: true,
Loader: true,
PartnerNodesList: true,
Popover: {
template: '<div><slot name="button" /><slot /></div>'
},
ScrubableNumberInput: true,
SubscribeToRunButton: true
}
}
})
}
describe('LinearControls', () => {
beforeEach(() => {
vi.clearAllMocks()
billingMock.isActiveSubscription = true
overlayMock.overlayMessage = 'KSampler is missing a required input: model'
overlayMock.overlayTitle = 'Required input missing'
})
it.for([
{ label: 'desktop', mobile: false },
{ label: 'mobile', mobile: true }
])('shows a workflow error warning in $label controls', ({ mobile }) => {
renderControls({ hasError: true, mobile })
const warning = screen.getByRole('status')
expect(
within(warning).getByText('Required input missing')
).toBeInTheDocument()
expect(
within(warning).getByText('KSampler is missing a required input: model')
).toBeInTheDocument()
expect(
within(warning).getByRole('button', { name: 'Show errors in graph' })
).toBeInTheDocument()
expect(within(warning).queryByLabelText('Close')).not.toBeInTheDocument()
const runButton = screen.getByRole('button', { name: 'Run' })
expect(runButton).toHaveAttribute(
'aria-describedby',
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
)
const description = screen.getByTestId(
'linear-validation-warning-description'
)
expect(description).toHaveAttribute(
'id',
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
)
expect(description).toHaveTextContent('Required input missing')
expect(description).toHaveTextContent(
'KSampler is missing a required input: model'
)
expect(description).not.toHaveTextContent('Show errors in graph')
})
it.for([
{ label: 'desktop', mobile: false },
{ label: 'mobile', mobile: true }
])(
'does not show the workflow error warning in $label controls without graph errors',
({ mobile }) => {
renderControls({ mobile })
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Show errors in graph' })
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Run' })).not.toHaveAttribute(
'aria-describedby'
)
}
)
it.for([
{ label: 'desktop', mobile: false },
{ label: 'mobile', mobile: true }
])(
'does not show the workflow error warning in $label controls without an active subscription',
({ mobile }) => {
renderControls({
hasError: true,
isActiveSubscription: false,
mobile
})
expect(screen.queryByRole('status')).not.toBeInTheDocument()
}
)
it('does not show the warning when the error copy is empty', () => {
overlayMock.overlayMessage = ''
renderControls({ hasError: true })
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Run' })).not.toHaveAttribute(
'aria-describedby'
)
})
})

View File

@@ -1,11 +1,10 @@
<script setup lang="ts">
import { useTimeout } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, ref, toValue, useTemplateRef } from 'vue'
import { ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
import Loader from '@/components/loader/Loader.vue'
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import Popover from '@/components/ui/Popover.vue'
@@ -15,15 +14,11 @@ import SubscribeToRunButton from '@/platform/cloud/subscription/components/Subsc
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import LinearRunErrorWarning from '@/renderer/extensions/linearMode/LinearRunErrorWarning.vue'
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
import PartnerNodesList from '@/renderer/extensions/linearMode/PartnerNodesList.vue'
import { useCommandStore } from '@/stores/commandStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const { batchCount } = storeToRefs(useQueueSettingsStore())
@@ -33,8 +28,6 @@ const workflowStore = useWorkflowStore()
const { isBuilderMode } = useAppMode()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const { hasAnyError } = storeToRefs(useExecutionErrorStore())
const { overlayMessage } = useErrorOverlayState()
const { toastTo, mobile } = defineProps<{
toastTo?: string | HTMLElement
@@ -50,13 +43,6 @@ const { ready: jobToastTimeout, start: resetJobToastTimeout } = useTimeout(
{ controls: true, immediate: false }
)
const widgetListRef = useTemplateRef('widgetListRef')
const linearRunButtonTestId = 'linear-run-button'
const showRunErrorWarning = computed(
() =>
hasAnyError.value &&
toValue(isActiveSubscription) &&
toValue(overlayMessage).trim().length > 0
)
//TODO: refactor out of this file.
//code length is small, but changes should propagate
@@ -148,10 +134,9 @@ function handleDragDrop() {
<PartnerNodesList v-if="!mobile" />
<section
v-if="mobile"
:data-testid="linearRunButtonTestId"
data-testid="linear-run-button"
class="border-t border-node-component-border p-4 pb-6"
>
<LinearRunErrorWarning v-if="showRunErrorWarning" />
<SubscribeToRunButton
v-if="!isActiveSubscription"
class="mt-4 w-full"
@@ -181,24 +166,18 @@ function handleDragDrop() {
variant="primary"
class="grow"
size="lg"
:aria-describedby="
showRunErrorWarning
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
: undefined
"
@click="runButtonClick"
>
<i aria-hidden="true" class="icon-[lucide--play]" />
<i class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
</div>
</section>
<section
v-else
:data-testid="linearRunButtonTestId"
data-testid="linear-run-button"
class="border-t border-node-component-border p-4 pb-6"
>
<LinearRunErrorWarning v-if="showRunErrorWarning" />
<div
class="m-1 mb-2 text-node-component-slot-text"
v-text="t('linearMode.runCount')"
@@ -219,14 +198,9 @@ function handleDragDrop() {
variant="primary"
class="mt-4 w-full text-sm"
size="lg"
:aria-describedby="
showRunErrorWarning
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
: undefined
"
@click="runButtonClick"
>
<i aria-hidden="true" class="icon-[lucide--play]" />
<i class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
</section>

View File

@@ -1,92 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import LinearRunErrorWarning from '@/renderer/extensions/linearMode/LinearRunErrorWarning.vue'
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
const mocks = vi.hoisted(() => ({
overlayMessage: 'KSampler is missing a required input: model',
overlayTitle: 'Required input missing',
viewErrorsInGraph: vi.fn()
}))
vi.mock('@/components/error/useErrorOverlayState', () => ({
useErrorOverlayState: () => ({
overlayMessage: mocks.overlayMessage,
overlayTitle: mocks.overlayTitle
})
}))
vi.mock('@/composables/useViewErrorsInGraph', () => ({
useViewErrorsInGraph: () => ({
viewErrorsInGraph: mocks.viewErrorsInGraph
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
linearMode: {
error: {
goto: 'Show errors in graph'
}
}
}
}
})
function renderWarning() {
const user = userEvent.setup()
const result = render(LinearRunErrorWarning, {
global: { plugins: [i18n] }
})
return { ...result, user }
}
describe('LinearRunErrorWarning', () => {
beforeEach(() => {
mocks.viewErrorsInGraph.mockReset()
})
it('shows the current error overlay title and message without a close action', () => {
renderWarning()
const warning = screen.getByRole('status')
expect(warning).toHaveTextContent('Required input missing')
expect(warning).toHaveTextContent(
'KSampler is missing a required input: model'
)
expect(screen.getByText('Required input missing')).toHaveAttribute(
'title',
'Required input missing'
)
const description = screen.getByTestId(
'linear-validation-warning-description'
)
expect(description).toHaveAttribute(
'id',
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
)
expect(description).toHaveTextContent('Required input missing')
expect(description).toHaveTextContent(
'KSampler is missing a required input: model'
)
expect(description).not.toHaveTextContent('Show errors in graph')
expect(screen.queryByLabelText('Close')).not.toBeInTheDocument()
})
it('opens graph errors when the action is clicked', async () => {
const { user } = renderWarning()
await user.click(
screen.getByRole('button', { name: 'Show errors in graph' })
)
expect(mocks.viewErrorsInGraph).toHaveBeenCalledOnce()
})
})

View File

@@ -1,63 +0,0 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
import { useViewErrorsInGraph } from '@/composables/useViewErrorsInGraph'
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
const { t } = useI18n()
const { viewErrorsInGraph } = useViewErrorsInGraph()
const { overlayMessage, overlayTitle } = useErrorOverlayState()
</script>
<template>
<div
role="status"
data-testid="linear-validation-warning"
class="mb-3 flex w-full flex-col gap-2 overflow-hidden rounded-lg border border-l-4 border-border-default border-l-destructive-background bg-base-background p-3 shadow-interface transition-colors duration-200 ease-in-out"
>
<div
:id="LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID"
data-testid="linear-validation-warning-description"
class="flex flex-col gap-2"
>
<div class="flex w-full items-start gap-2">
<i
aria-hidden="true"
class="mt-0.5 icon-[lucide--circle-x] size-4 shrink-0 text-destructive-background"
/>
<span
class="min-w-0 flex-1 truncate text-sm text-base-foreground"
:title="overlayTitle"
>
{{ overlayTitle }}
</span>
</div>
<div
class="flex w-full items-start gap-2"
data-testid="linear-validation-warning-message"
>
<span class="size-4 shrink-0" aria-hidden="true" />
<p
class="m-0 line-clamp-3 min-w-0 flex-1 text-sm/snug wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ overlayMessage }}
</p>
</div>
</div>
<div class="flex w-full items-center justify-end pt-2">
<Button
variant="secondary"
size="unset"
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
data-testid="linear-view-errors"
@click="viewErrorsInGraph"
>
{{ t('linearMode.error.goto') }}
</Button>
</div>
</div>
</template>

View File

@@ -1,2 +0,0 @@
export const LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID =
'linear-run-error-warning'

View File

@@ -85,6 +85,7 @@ describe('Vue Node - Subgraph Functionality', () => {
selected: false,
executing: false,
subgraphId,
widgets: [],
inputs: [],
outputs: [],
hasErrors: false,

View File

@@ -15,7 +15,6 @@ import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composable
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
const mockData = vi.hoisted(() => ({
mockExecuting: false,
@@ -61,7 +60,7 @@ vi.mock(
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: { id: 'graph-test', getNodeById: vi.fn() },
rootGraph: { getNodeById: vi.fn() },
canvas: { setDirty: vi.fn() }
}
}))
@@ -162,6 +161,7 @@ const mockNodeData: VueNodeData = {
flags: {},
inputs: [],
outputs: [],
widgets: [],
selected: false,
executing: false
}
@@ -178,7 +178,6 @@ describe('LGraphNode', () => {
beforeEach(() => {
vi.resetAllMocks()
mockData.mockExecuting = false
mockData.mockLgraphNode = null
setActivePinia(pinia)
const canvasStore = useCanvasStore()
@@ -205,18 +204,6 @@ describe('LGraphNode', () => {
)
})
it('does not prune widget store state while rendering', () => {
mockData.mockLgraphNode = {
widgets: [],
isSubgraphNode: () => false
}
const widgetValueStore = useWidgetValueStore()
renderLGraphNode({ nodeData: mockNodeData })
expect(widgetValueStore.replaceNodeWidgetOrder).not.toHaveBeenCalled()
})
it('should render node title', () => {
const { container } = render(LGraphNode, {
props: { nodeData: mockNodeData },
@@ -287,16 +274,17 @@ describe('LGraphNode', () => {
})
it('should hide advanced footer button while the node is collapsed', () => {
mockData.mockLgraphNode = {
isSubgraphNode: () => false,
widgets: [
{ name: 'advancedWidget', type: 'number', options: { advanced: true } }
]
}
renderLGraphNode({
nodeData: {
...mockNodeData,
flags: { collapsed: true }
flags: { collapsed: true },
widgets: [
{
name: 'advancedWidget',
type: 'number',
options: { advanced: true }
}
]
}
})
@@ -306,17 +294,18 @@ describe('LGraphNode', () => {
})
it('should show error-only footer for collapsed nodes with advanced widgets', () => {
mockData.mockLgraphNode = {
isSubgraphNode: () => false,
widgets: [
{ name: 'advancedWidget', type: 'number', options: { advanced: true } }
]
}
renderLGraphNode({
nodeData: {
...mockNodeData,
flags: { collapsed: true },
hasErrors: true
hasErrors: true,
widgets: [
{
name: 'advancedWidget',
type: 'number',
options: { advanced: true }
}
]
}
})

View File

@@ -57,7 +57,7 @@
cn(
'pointer-events-none absolute z-0 border-3 outline-none',
selectionShapeClass,
hasAnyError ? '-inset-1.75' : '-inset-0.75',
hasAnyError ? 'inset-[-7px]' : 'inset-[-3px]',
isSelected
? 'border-node-component-outline'
: 'border-node-stroke-executing'
@@ -107,10 +107,10 @@
multi
class="absolute right-0 translate-x-1/2"
/>
<NodeSlots :node-data unified />
<NodeSlots :node-data="nodeData" unified />
</template>
<NodeHeader
:node-data
:node-data="nodeData"
:collapsed="isCollapsed"
:price-badges="badges.pricing"
@collapse="handleCollapse"
@@ -130,7 +130,7 @@
/>
<template v-if="!isCollapsed && isRerouteNode">
<NodeSlots :node-data />
<NodeSlots :node-data="nodeData" />
</template>
<template v-else-if="!isCollapsed">
@@ -157,20 +157,20 @@
"
:data-testid="`node-body-${nodeData.id}`"
>
<NodeSlots :node-data />
<NodeSlots :node-data="nodeData" />
<NodeWidgets
v-if="hasRenderableWidgets"
:node-data
:widget-ids="widgetIds"
/>
<NodeWidgets v-if="nodeData.widgets?.length" :node-data="nodeData" />
<div v-if="hasCustomContent" class="flex min-h-0 flex-1 flex-col">
<NodeContent v-if="nodeMedia" :node-data :media="nodeMedia" />
<NodeContent
v-if="nodeMedia"
:node-data="nodeData"
:media="nodeMedia"
/>
<NodeContent
v-for="preview in promotedPreviews"
:key="`${preview.sourceNodeId}-${preview.sourceWidgetName}`"
:node-data
:node-data="nodeData"
:media="preview"
/>
</div>
@@ -302,12 +302,8 @@ import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { getWidgetIdForNode, isVideoOutput } from '@/utils/litegraphUtil'
import { isVideoOutput } from '@/utils/litegraphUtil'
import {
getLocatorIdFromNodeData,
getNodeByLocatorId
@@ -740,45 +736,18 @@ const { promotedPreviews } = usePromotedPreviews(lgraphNode)
useGLSLPreview(lgraphNode)
const widgetValueStore = useWidgetValueStore()
const widgetIds = computed(() => {
const graphId = app.rootGraph?.id
const bareNodeId = stripGraphPrefix(nodeData.id)
if (!graphId || !bareNodeId) return []
const storedIds = widgetValueStore.getNodeWidgetIds(graphId, bareNodeId) ?? []
const node = lgraphNode.value
if (!node) return storedIds
if (!node.widgets?.length) return []
const duplicateIndexByKey = new Map<string, number>()
const liveIdSet = new Set(
node.widgets.flatMap((widget) => {
const duplicateKey = `${widget.name}:${widget.type}`
const duplicateIndex = duplicateIndexByKey.get(duplicateKey) ?? 0
duplicateIndexByKey.set(duplicateKey, duplicateIndex + 1)
const id = getWidgetIdForNode(node, widget, duplicateIndex)
return id ? [id] : []
})
)
return storedIds.filter((id) => liveIdSet.has(id))
})
const hasRenderableWidgets = computed(() => widgetIds.value.length > 0)
const showAdvancedInputsButton = computed(() => {
const node = lgraphNode.value
if (!node) return false
if (isCollapsed.value) return false
// For subgraph nodes: check for unpromoted widgets
if (node instanceof SubgraphNode) {
return hasUnpromotedWidgets(node)
}
const hasAdvancedWidgets = widgetIds.value.some((id) => {
const renderState = widgetValueStore.getWidgetRenderState(id)
const widgetState = widgetValueStore.getWidget(id)
return renderState?.advanced ?? widgetState?.options?.advanced
})
// For regular nodes: show button if there are advanced widgets and they're currently hidden
const hasAdvancedWidgets = nodeData.widgets?.some((w) => w.options?.advanced)
const alwaysShowAdvanced = settingStore.get(
'Comfy.Node.AlwaysShowAdvancedWidgets'
)

View File

@@ -1,9 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { computed } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { ProcessedWidget } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
import { fromPartial } from '@total-typescript/shoehorn'
@@ -12,20 +9,12 @@ vi.mock('@/stores/widgetStore', () => ({
useWidgetStore: () => ({ inputIsWidget: () => true })
}))
const WidgetGridProbe = {
props: ['processedWidgets'],
setup(props: { processedWidgets?: ProcessedWidget[] }) {
const widgets = computed(() =>
(props.processedWidgets ?? []).map((widget) => ({
name: widget.name,
value: widget.simplified.value,
options: { values: widget.simplified.options?.values }
}))
)
return { widgets }
},
template:
'<div data-testid="node-data">{{ JSON.stringify({ widgets }) }}</div>'
// Serializes the nodeData prop so tests can assert on the data contract
// LGraphNodePreview hands to NodeWidgets. How that data renders is covered
// by NodeWidgets.test.ts and browser_tests/tests/sidebar/modelLibrary.spec.ts.
const NodeWidgetsProbe = {
props: ['nodeData'],
template: '<div data-testid="node-data">{{ JSON.stringify(nodeData) }}</div>'
}
interface ProbedWidget {
@@ -50,11 +39,10 @@ function renderedWidgets(
render(LGraphNodePreview, {
props: { nodeDef: def, ...props },
global: {
plugins: [createTestingPinia({ stubActions: false })],
stubs: {
NodeHeader: true,
NodeSlots: true,
WidgetGrid: WidgetGridProbe
NodeWidgets: NodeWidgetsProbe
}
}
})

View File

@@ -19,12 +19,9 @@
>
<NodeSlots :node-data="nodeData" />
<WidgetGrid
v-if="processedWidgets.length"
:processed-widgets="processedWidgets"
:grid-template-rows="gridTemplateRows"
:node-type="nodeData.type"
:node-id="nodeData.id"
<NodeWidgets
v-if="nodeData.widgets?.length"
:node-data="nodeData"
class="pointer-events-none"
/>
</div>
@@ -39,11 +36,11 @@ import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import { RenderShape } from '@/lib/litegraph/src/litegraph'
import NodeHeader from '@/renderer/extensions/vueNodes/components/NodeHeader.vue'
import NodeSlots from '@/renderer/extensions/vueNodes/components/NodeSlots.vue'
import WidgetGrid from '@/renderer/extensions/vueNodes/components/WidgetGrid.vue'
import { usePreviewWidgets } from '@/renderer/extensions/vueNodes/composables/usePreviewWidgets'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useWidgetStore } from '@/stores/widgetStore'
import { toNodeId } from '@/types/nodeId'
@@ -61,9 +58,39 @@ const {
const widgetStore = useWidgetStore()
// Convert nodeDef into VueNodeData
const nodeData = computed<VueNodeData>(() => {
const widgets = Object.entries(nodeDef.inputs || {})
.filter(([_, input]) => widgetStore.inputIsWidget(input))
.map(([name, input]) => {
const comboValues =
input.type === 'COMBO' && Array.isArray(input.options)
? input.options
: undefined
// Preview nodes have no widget-value store entry, so combo widgets
// render their first option; lead with the requested value to show it.
const leadValue = widgetValues?.[name]
return {
nodeId: toNodeId('-1'),
name,
type: input.widgetType || input.type,
value:
input.default !== undefined
? input.default
: (comboValues?.[0] ?? ''),
options: {
hidden: input.hidden,
advanced: input.advanced,
values:
leadValue && comboValues
? [leadValue, ...comboValues.filter((o) => o !== leadValue)]
: comboValues
} satisfies IWidgetOptions
}
})
const inputs: INodeInputSlot[] = Object.entries(nodeDef.inputs || {})
.filter(([, input]) => !widgetStore.inputIsWidget(input))
.filter(([_, input]) => !widgetStore.inputIsWidget(input))
.map(([name, input]) => ({
name,
type: input.type,
@@ -92,19 +119,16 @@ const nodeData = computed<VueNodeData>(() => {
id: toNodeId(`preview-${nodeDef.name}`),
title: nodeDef.display_name || nodeDef.name,
type: nodeDef.name,
mode: 0,
mode: 0, // Normal mode
selected: false,
executing: false,
widgets,
inputs,
outputs,
flags: {
collapsed: false
}
}
})
const { processedWidgets, gridTemplateRows } = usePreviewWidgets(
() => nodeDef,
() => widgetValues
)
</script>

View File

@@ -26,6 +26,7 @@ const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
mode: 0,
selected: false,
executing: false,
widgets: [],
inputs: [],
outputs: [],
flags: { collapsed: false },

View File

@@ -38,6 +38,7 @@ const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
executing: false,
inputs: [],
outputs: [],
widgets: [],
flags: { collapsed: false },
...overrides
})

View File

@@ -3,17 +3,20 @@
import { createTestingPinia } from '@pinia/testing'
import { render } from '@testing-library/vue'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { toNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import type {
SafeWidgetData,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { widgetId } from '@/types/widgetId'
import type { WidgetId } from '@/types/widgetId'
const GRAPH_ID = 'graph-test'
@@ -33,13 +36,7 @@ const WidgetStub = {
name: 'WidgetStub',
props: ['widget', 'nodeId', 'nodeType', 'modelValue'],
template:
'<div class="widget-stub" :data-node-type="nodeType" :data-name="widget.name">{{ nodeType }}</div>'
}
const AppInputStub = {
props: ['widgetId', 'name', 'enable'],
template:
'<div class="app-input-stub" :data-entity-id="widgetId"><slot /></div>'
'<div class="widget-stub" :data-node-type="nodeType">{{ nodeType }}</div>'
}
vi.mock(
@@ -53,78 +50,63 @@ vi.mock(
}
)
function createMockNodeData(
nodeType = 'TestNode',
id: NodeId = toNodeId(1)
): VueNodeData {
return {
describe('NodeWidgets', () => {
const createMockWidget = (
overrides: Partial<SafeWidgetData> = {}
): SafeWidgetData => ({
nodeId: toNodeId('test_node'),
name: 'test_widget',
type: 'combo',
options: undefined,
callback: undefined,
spec: undefined,
isDOMWidget: false,
slotMetadata: undefined,
...overrides
})
const createMockNodeData = (
nodeType: string = 'TestNode',
widgets: SafeWidgetData[] = [],
id: NodeId = toNodeId(1)
): VueNodeData => ({
id,
type: nodeType,
widgets,
title: 'Test Node',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
}
}
function registerWidgetState(
id: WidgetId,
init: {
type?: string
value?: unknown
options?: Record<string, unknown>
} = {}
) {
useWidgetValueStore().registerWidget(id, {
type: init.type ?? 'combo',
value: init.value ?? 'value',
options: init.options ?? {}
})
}
function renderComponent({
nodeData,
widgetIds,
setupStores
}: {
nodeData?: VueNodeData
widgetIds?: readonly WidgetId[]
setupStores?: () => void
}) {
const pinia = createTestingPinia({ stubActions: false })
setActivePinia(pinia)
setupStores?.()
function renderComponent(nodeData?: VueNodeData, setupStores?: () => void) {
const pinia = createTestingPinia({ stubActions: false })
setActivePinia(pinia)
setupStores?.()
return render(NodeWidgets, {
props: {
nodeData,
widgetIds
},
global: {
plugins: [pinia],
stubs: {
InputSlot: true,
AppInput: AppInputStub
return render(NodeWidgets, {
props: {
nodeData
},
mocks: {
$t: (key: string) => key
global: {
plugins: [pinia],
stubs: {
InputSlot: true
},
mocks: {
$t: (key: string) => key
}
}
}
})
}
})
}
describe('NodeWidgets', () => {
describe('node-type prop passing', () => {
it('passes node type to widget components', () => {
const id = widgetId(GRAPH_ID, toNodeId(1), 'test_widget')
const nodeData = createMockNodeData('CheckpointLoaderSimple')
const { container } = renderComponent({
nodeData,
widgetIds: [id],
setupStores: () => registerWidgetState(id)
})
const widget = createMockWidget()
const nodeData = createMockNodeData('CheckpointLoaderSimple', [widget])
const { container } = renderComponent(nodeData)
const stub = container.querySelector('.widget-stub')
expect(stub).not.toBeNull()
@@ -134,31 +116,15 @@ describe('NodeWidgets', () => {
})
it('renders no widgets when nodeData is undefined', () => {
const id = widgetId(GRAPH_ID, toNodeId(1), 'test_widget')
const { container } = renderComponent({
widgetIds: [id],
setupStores: () => registerWidgetState(id)
})
expect(container.querySelectorAll('.widget-stub')).toHaveLength(0)
})
it('renders no widgets when no widget ids are registered or passed', () => {
const { container } = renderComponent({
nodeData: createMockNodeData('CheckpointLoaderSimple')
})
const { container } = renderComponent(undefined)
expect(container.querySelectorAll('.widget-stub')).toHaveLength(0)
})
it('passes empty string when nodeData.type is empty', () => {
const id = widgetId(GRAPH_ID, toNodeId(1), 'test_widget')
const nodeData = createMockNodeData('')
const { container } = renderComponent({
nodeData,
widgetIds: [id],
setupStores: () => registerWidgetState(id)
})
const widget = createMockWidget()
const nodeData = createMockNodeData('', [widget])
const { container } = renderComponent(nodeData)
const stub = container.querySelector('.widget-stub')
expect(stub).not.toBeNull()
@@ -166,18 +132,7 @@ describe('NodeWidgets', () => {
})
})
it('derives widget ids from the store when ids are not passed', () => {
const nodeId = toNodeId('test_node')
const id = widgetId(GRAPH_ID, nodeId, 'test_widget')
const { container } = renderComponent({
nodeData: createMockNodeData('TestNode', nodeId),
setupStores: () => registerWidgetState(id)
})
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(1)
})
it('deduplicates repeated widget ids while keeping distinct widget ids', () => {
it('deduplicates widgets with identical render identity while keeping distinct promoted sources', () => {
const duplicateEntityId = widgetId(
GRAPH_ID,
toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
@@ -188,34 +143,163 @@ describe('NodeWidgets', () => {
toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:20'),
'string_a'
)
const nodeData = createMockNodeData('SubgraphNode')
const { container } = renderComponent({
nodeData,
widgetIds: [duplicateEntityId, duplicateEntityId, distinctEntityId],
setupStores: () => {
registerWidgetState(duplicateEntityId, { type: 'text' })
registerWidgetState(distinctEntityId, { type: 'text' })
}
const duplicateA = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: duplicateEntityId
})
const duplicateB = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: duplicateEntityId
})
const distinct = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:20'),
widgetId: distinctEntityId
})
const nodeData = createMockNodeData('SubgraphNode', [
duplicateA,
duplicateB,
distinct
])
const { container } = renderComponent(nodeData)
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('hides widgets when store options mark them hidden', () => {
const nodeData = createMockNodeData('TestNode', toNodeId('test_node'))
const id = widgetId(GRAPH_ID, toNodeId('test_node'), 'test_widget')
const { container } = renderComponent({
nodeData,
widgetIds: [id],
setupStores: () => {
registerWidgetState(id, {
type: 'combo',
options: { hidden: true }
})
}
it('prefers a visible duplicate over a hidden duplicate when identities collide', () => {
const sharedEntityId = widgetId(
GRAPH_ID,
toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
'string_a'
)
const hiddenDuplicate = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: sharedEntityId,
options: { hidden: true }
})
const visibleDuplicate = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: sharedEntityId,
options: { hidden: false }
})
const nodeData = createMockNodeData('SubgraphNode', [
hiddenDuplicate,
visibleDuplicate
])
const { container } = renderComponent(nodeData)
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(1)
})
it('does not deduplicate entries that share names but have different widget types', () => {
const sharedEntityId = widgetId(
GRAPH_ID,
toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
'string_a'
)
const textWidget = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: sharedEntityId
})
const comboWidget = createMockWidget({
name: 'string_a',
type: 'combo',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: sharedEntityId
})
const nodeData = createMockNodeData('SubgraphNode', [
textWidget,
comboWidget
])
const { container } = renderComponent(nodeData)
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('keeps unresolved same-name promoted entries distinct by source execution identity', () => {
const firstTransientEntry = createMockWidget({
nodeId: undefined,
name: 'string_a',
type: 'text',
sourceExecutionId: createNodeExecutionId([toNodeId(65), toNodeId(18)])
})
const secondTransientEntry = createMockWidget({
nodeId: undefined,
name: 'string_a',
type: 'text',
sourceExecutionId: createNodeExecutionId([toNodeId(65), toNodeId(19)])
})
const nodeData = createMockNodeData('SubgraphNode', [
firstTransientEntry,
secondTransientEntry
])
const { container } = renderComponent(nodeData)
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('does not deduplicate promoted duplicates that differ only by disambiguating source identity', () => {
const firstPromoted = createMockWidget({
name: 'text',
type: 'text',
nodeId: toNodeId('outer-subgraph:1'),
widgetId: widgetId(GRAPH_ID, toNodeId('outer-subgraph:1'), 'text')
})
const secondPromoted = createMockWidget({
name: 'text',
type: 'text',
nodeId: toNodeId('outer-subgraph:2'),
widgetId: widgetId(GRAPH_ID, toNodeId('outer-subgraph:2'), 'text')
})
const nodeData = createMockNodeData('SubgraphNode', [
firstPromoted,
secondPromoted
])
const { container } = renderComponent(nodeData)
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('hides widgets when merged store options mark them hidden', async () => {
const nodeData = createMockNodeData('TestNode', [
createMockWidget({
nodeId: toNodeId('test_node'),
name: 'test_widget',
options: { hidden: false }
})
])
const { container } = renderComponent(nodeData)
const widgetValueStore = useWidgetValueStore()
widgetValueStore.registerWidget(
widgetId('graph-test', toNodeId('test_node'), 'test_widget'),
{
type: 'combo',
value: 'value',
options: { hidden: true },
label: undefined,
serialize: true,
disabled: false
}
)
await nextTick()
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(0)
})
@@ -223,17 +307,44 @@ describe('NodeWidgets', () => {
it('forwards canonical widgetId to AppInput for selection', () => {
const seedAEntityId = widgetId(GRAPH_ID, toNodeId('test_node'), 'seed_a')
const seedBEntityId = widgetId(GRAPH_ID, toNodeId('test_node'), 'seed_b')
const nodeData = createMockNodeData('TestNode', toNodeId('test_node'))
const nodeData = createMockNodeData('TestNode', [
createMockWidget({
nodeId: toNodeId('test_node'),
name: 'seed_a',
type: 'text',
widgetId: seedAEntityId
}),
createMockWidget({
nodeId: toNodeId('test_node'),
name: 'seed_b',
type: 'text',
widgetId: seedBEntityId
})
])
const { container } = renderComponent({
nodeData,
widgetIds: [seedAEntityId, seedBEntityId],
setupStores: () => {
registerWidgetState(seedAEntityId, { type: 'text' })
registerWidgetState(seedBEntityId, { type: 'text' })
const { container } = render(NodeWidgets, {
props: { nodeData },
global: {
plugins: [
(() => {
const pinia = createTestingPinia({ stubActions: false })
setActivePinia(pinia)
return pinia
})()
],
stubs: {
InputSlot: true,
AppInput: {
props: ['widgetId', 'name', 'enable'],
template:
'<div class="app-input-stub" :data-entity-id="widgetId"><slot /></div>'
}
},
mocks: {
$t: (key: string) => key
}
}
})
const appInputElements = container.querySelectorAll('.app-input-stub')
const ids = Array.from(appInputElements).map((el) =>
el.getAttribute('data-entity-id')
@@ -241,35 +352,4 @@ describe('NodeWidgets', () => {
expect(ids).toStrictEqual([seedAEntityId, seedBEntityId])
})
it('marks widgets with host execution errors', () => {
const nodeId = toNodeId('test_node')
const id = widgetId(GRAPH_ID, nodeId, 'seed')
const { container } = renderComponent({
nodeData: createMockNodeData('TestNode', nodeId),
widgetIds: [id],
setupStores: () => {
useExecutionErrorStore().lastNodeErrors = {
[createNodeExecutionId([nodeId])]: {
errors: [
{
type: 'value_not_in_list',
message: 'seed is invalid',
details: '',
extra_info: { input_name: 'seed' }
}
],
class_type: 'TestNode',
dependent_outputs: []
}
}
registerWidgetState(id, { type: 'text' })
}
})
expect(container.querySelector('.widget-stub')?.className).toContain(
'text-node-stroke-error'
)
})
})

View File

@@ -2,44 +2,104 @@
<div v-if="renderError" class="node-error p-2 text-sm text-red-500">
{{ st('nodeErrors.widgets', 'Node Widgets Error') }}
</div>
<WidgetGrid
<div
v-else
:processed-widgets="processedWidgets"
:grid-template-rows="gridTemplateRows"
:node-type="nodeType"
:can-select-inputs="canSelectInputs"
:node-id="nodeData?.id"
data-testid="node-widgets"
:class="
shouldHandleNodePointerEvents
? 'pointer-events-auto'
: 'pointer-events-none'
cn(
'lg-node-widgets grid grid-cols-[min-content_minmax(80px,min-content)_minmax(125px,1fr)] gap-y-1 pr-3',
shouldHandleNodePointerEvents
? 'pointer-events-auto'
: 'pointer-events-none'
)
"
:style="{
'grid-template-rows': gridTemplateRows,
flex: gridTemplateRows.includes('auto') ? 1 : undefined
}"
@pointerdown.capture="handleBringToFront"
@pointerdown="handleWidgetPointerEvent"
@pointermove="handleWidgetPointerEvent"
@pointerup="handleWidgetPointerEvent"
/>
>
<template v-for="widget in processedWidgets" :key="widget.renderKey">
<div
v-if="widget.visible"
data-testid="node-widget"
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch"
>
<!-- Widget Input Slot Dot -->
<div
:class="
cn(
'z-10 flex w-3 items-stretch opacity-0 transition-opacity duration-150 group-hover:opacity-100',
widget.slotMetadata?.linked && 'opacity-100'
)
"
>
<InputSlot
v-if="widget.slotMetadata"
:key="`widget-slot-${widget.name}-${widget.slotMetadata.index}`"
:slot-data="{
name: widget.name,
type: widget.slotMetadata.type,
boundingRect: [0, 0, 0, 0]
}"
:node-id="nodeData?.id"
:has-error="widget.hasError"
:index="widget.slotMetadata.index"
:socketless="widget.simplified.spec?.socketless"
dot-only
/>
</div>
<!-- Widget Component -->
<AppInput
:widget-id="widget.widgetId"
:name="widget.name"
:enable="canSelectInputs && !widget.simplified.options?.disabled"
>
<component
:is="widget.vueComponent"
v-model="widget.value"
v-tooltip.left="widget.tooltipConfig"
:widget="widget.simplified"
:node-id="nodeData?.id"
:node-type="nodeType"
:class="
cn(
'col-span-2',
widget.hasError && 'font-bold text-node-stroke-error'
)
"
@update:model-value="widget.updateHandler"
@contextmenu="widget.handleContextMenu"
/>
</AppInput>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { onErrorCaptured, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import type { WidgetId } from '@/types/widgetId'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { st } from '@/i18n'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import WidgetGrid from '@/renderer/extensions/vueNodes/components/WidgetGrid.vue'
import AppInput from '@/renderer/extensions/linearMode/AppInput.vue'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import { useProcessedWidgets } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { cn } from '@comfyorg/tailwind-utils'
import InputSlot from './InputSlot.vue'
interface NodeWidgetsProps {
nodeData?: VueNodeData
widgetIds?: readonly WidgetId[]
}
const { nodeData, widgetIds } = defineProps<NodeWidgetsProps>()
const { nodeData } = defineProps<NodeWidgetsProps>()
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
useCanvasInteractions()
@@ -69,10 +129,7 @@ onErrorCaptured((error) => {
})
const { canSelectInputs, gridTemplateRows, nodeType, processedWidgets } =
useProcessedWidgets(
() => nodeData,
() => widgetIds
)
useProcessedWidgets(() => nodeData)
// Tracks widget-row growth that the node-level RO can't see
if (nodeData?.id != null) {

View File

@@ -1,89 +0,0 @@
<template>
<div
data-testid="node-widgets"
class="lg-node-widgets grid grid-cols-[min-content_minmax(80px,min-content)_minmax(125px,1fr)] gap-y-1 pr-3"
:style="{
'grid-template-rows': gridTemplateRows,
flex: gridTemplateRows.includes('auto') ? 1 : undefined
}"
>
<template v-for="widget in processedWidgets" :key="widget.renderKey">
<div
v-if="widget.visible"
data-testid="node-widget"
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch"
>
<!-- Widget Input Slot Dot -->
<div
:class="
cn(
'z-10 flex w-3 items-stretch opacity-0 transition-opacity duration-150 group-hover:opacity-100',
widget.slotMetadata?.linked && 'opacity-100'
)
"
>
<InputSlot
v-if="widget.slotMetadata"
:key="`widget-slot-${widget.name}-${widget.slotMetadata.index}`"
:slot-data="{
name: widget.name,
type: widget.slotMetadata.type,
boundingRect: [0, 0, 0, 0]
}"
:node-id="nodeId"
:has-error="widget.hasError"
:index="widget.slotMetadata.index"
:socketless="widget.simplified.spec?.socketless"
dot-only
/>
</div>
<!-- Widget Component -->
<AppInput
:widget-id="widget.widgetId"
:name="widget.name"
:enable="canSelectInputs && !widget.simplified.options?.disabled"
>
<component
:is="widget.vueComponent"
v-model="widget.value"
v-tooltip.left="widget.tooltipConfig"
:widget="widget.simplified"
:node-id="nodeId"
:node-type="nodeType"
:class="
cn(
'col-span-2',
widget.hasError && 'font-bold text-node-stroke-error'
)
"
@update:model-value="widget.updateHandler"
@contextmenu="widget.handleContextMenu"
/>
</AppInput>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import type { ProcessedWidget } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
import AppInput from '@/renderer/extensions/linearMode/AppInput.vue'
import type { NodeId } from '@/types/nodeId'
import { cn } from '@comfyorg/tailwind-utils'
import InputSlot from './InputSlot.vue'
const {
processedWidgets,
gridTemplateRows,
nodeType,
canSelectInputs = false,
nodeId
} = defineProps<{
processedWidgets: ProcessedWidget[]
gridTemplateRows: string
nodeType: string
canSelectInputs?: boolean
nodeId?: NodeId
}>()
</script>

View File

@@ -2,6 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
import { i18n, te } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { Settings } from '@/schemas/apiSchema'
@@ -18,8 +19,9 @@ const positiveCoordsTooltipKey =
const outputTooltipKey = 'nodeDefs.SAM3_Detect.outputs.0.tooltip'
const positiveCoordsWidget: { name: string; tooltip?: string } = {
name: 'positive_coords'
const positiveCoordsWidget: SafeWidgetData = {
name: 'positive_coords',
type: 'STRING'
}
function mergeOutputTooltipMessage(tooltip: string | null) {

View File

@@ -5,6 +5,7 @@ import type {
import { computed, ref, unref } from 'vue'
import type { MaybeRef } from 'vue'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
import { st, stRaw } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
@@ -135,11 +136,11 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
/**
* Get tooltip text for widgets
*/
const getWidgetTooltip = (widget: { name: string; tooltip?: string }) => {
const getWidgetTooltip = (widget: SafeWidgetData) => {
if (!tooltipsEnabled.value || !nodeDef.value) return ''
// First try widget-specific tooltip
const widgetTooltip = widget.tooltip
const widgetTooltip = (widget as { tooltip?: string }).tooltip
if (widgetTooltip) return widgetTooltip
// Then try input-based tooltip lookup

View File

@@ -1,92 +0,0 @@
import type { TooltipOptions } from 'primevue'
import { computed } from 'vue'
import type { Component } from 'vue'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { ProcessedWidget } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
import { isWidgetVisible } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
import {
getComponent,
shouldExpand
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useWidgetStore } from '@/stores/widgetStore'
import type { WidgetValue } from '@/types/simplifiedWidget'
const EMPTY_TOOLTIP: TooltipOptions = {}
const noop = () => {}
/**
* Builds render-ready widgets for a static node preview straight from its
* schema, without registering anything in the widget stores. Previews have no
* live graph, so nothing here needs (or should touch) store-backed state.
*/
export function usePreviewWidgets(
nodeDefGetter: () => ComfyNodeDefV2,
widgetValuesGetter: () => Record<string, string> | undefined
) {
const widgetStore = useWidgetStore()
const settingStore = useSettingStore()
const showAdvanced = computed(() =>
Boolean(settingStore.get('Comfy.Node.AlwaysShowAdvancedWidgets'))
)
const processedWidgets = computed<ProcessedWidget[]>(() => {
const nodeDef = nodeDefGetter()
const widgetValues = widgetValuesGetter()
return Object.entries(nodeDef.inputs || {})
.filter(([, input]) => widgetStore.inputIsWidget(input))
.map(([name, input]): ProcessedWidget => {
const comboValues =
input.type === 'COMBO' && Array.isArray(input.options)
? input.options
: undefined
const leadValue = widgetValues?.[name]
const value = (leadValue ??
(input.default !== undefined
? input.default
: (comboValues?.[0] ?? ''))) as WidgetValue
const options = {
hidden: input.hidden,
advanced: input.advanced,
values:
leadValue && comboValues
? [
leadValue,
...comboValues.filter((option) => option !== leadValue)
]
: comboValues
} satisfies IWidgetOptions
const type = input.widgetType || input.type
const vueComponent: Component = getComponent(type) ?? WidgetLegacy
return {
advanced: Boolean(input.advanced),
handleContextMenu: noop,
hasLayoutSize: false,
hasError: false,
hidden: Boolean(input.hidden),
name,
renderKey: `preview:${nodeDef.name}:${name}`,
simplified: { name, type, value, options, spec: input },
tooltipConfig: EMPTY_TOOLTIP,
type,
updateHandler: noop,
value,
visible: isWidgetVisible(options, showAdvanced.value),
vueComponent,
slotMetadata: undefined
}
})
})
const gridTemplateRows = computed(() =>
processedWidgets.value
.filter((widget) => widget.visible)
.map((widget) => (shouldExpand(widget.type) ? 'auto' : 'min-content'))
.join(' ')
)
return { processedWidgets, gridTemplateRows }
}

View File

@@ -3,28 +3,23 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { toNodeId } from '@/types/nodeId'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
import {
computeProcessedWidgets,
getWidgetIdentity,
hasWidgetError,
isWidgetVisible
} from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetDOM.vue'
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import {
createNodeExecutionId,
createNodeLocatorId
} from '@/types/nodeIdentification'
import { toNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import { widgetId } from '@/types/widgetId'
import type { WidgetId } from '@/types/widgetId'
const GRAPH_ID = 'graph-test'
@@ -33,128 +28,80 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
canvas: {
graph: {
rootGraph: {
id: GRAPH_ID
id: toNodeId('graph-test')
}
}
}
})
}))
function createMockWidget(
overrides: Partial<IBaseWidget> & { widgetId?: WidgetId } = {}
): IBaseWidget {
const { widgetId: id, ...rest } = overrides
const widget: IBaseWidget = {
name: 'test_widget',
type: 'combo',
options: {},
value: 'value',
y: 0,
...rest
}
if (id) {
Object.defineProperty(widget, 'widgetId', {
value: id,
configurable: true
})
}
return widget
}
function createNode(
widgets: IBaseWidget[],
id: NodeId = toNodeId(1),
type = 'TestNode'
): LGraphNode {
const node = new LGraphNode(type)
node.id = id
node.type = type
node.widgets = widgets
return node
}
function createGraphWithNode(
widgets: IBaseWidget[],
id: NodeId = toNodeId(1),
type = 'TestNode'
): { graph: LGraph; node: LGraphNode } {
const graph = new LGraph()
graph.id = GRAPH_ID
const node = createNode(widgets, id, type)
graph.add(node)
return { graph, node }
}
const noopUi = {
getTooltipConfig: () => ({}) as TooltipOptions,
handleNodeRightClick: () => {}
}
function registerWidgetState(
id: WidgetId,
init: {
type?: string
value?: unknown
label?: string
options?: IBaseWidget['options']
} = {}
) {
return useWidgetValueStore().registerWidget(id, {
type: init.type ?? 'combo',
value: 'value' in init ? init.value : 'value',
label: init.label,
options: init.options ?? {}
})
}
function processWidgets({
widgetIds,
nodeId = toNodeId(1),
nodeType = 'TestNode',
showAdvanced = false,
subgraphId,
rootGraph = null
}: {
widgetIds: readonly WidgetId[]
nodeId?: NodeId
nodeType?: string
showAdvanced?: boolean
subgraphId?: string | null
rootGraph?: LGraph | null
}) {
return computeProcessedWidgets({
nodeData: {
id: nodeId,
type: nodeType,
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: [],
subgraphId
},
widgetIds,
graphId: GRAPH_ID,
showAdvanced,
isGraphReady: false,
rootGraph,
ui: noopUi
})
}
const createMockWidget = (
overrides: Partial<SafeWidgetData> = {}
): SafeWidgetData => ({
nodeId: toNodeId('test_node'),
name: 'test_widget',
type: 'combo',
options: undefined,
callback: undefined,
spec: undefined,
isDOMWidget: false,
slotMetadata: undefined,
...overrides
})
describe('getWidgetIdentity', () => {
it('keys render identity by widgetId and widget type', () => {
it('keys dedupeIdentity by widgetId and widget type', () => {
const id = widgetId(GRAPH_ID, toNodeId('subgraph:19'), 'text')
const widget = createMockWidget({
widgetId: id,
name: 'text',
type: 'text'
})
const { dedupeIdentity, renderKey } = getWidgetIdentity(
{ widgetId: id, type: 'text' },
widget,
toNodeId('1'),
0
)
expect(dedupeIdentity).toBe(`${id}:text`)
expect(renderKey).toBe(dedupeIdentity)
})
it('falls back to host nodeId so duplicate normal widgets dedupe', () => {
const widget = createMockWidget({
nodeId: undefined,
sourceExecutionId: undefined
})
const { dedupeIdentity, renderKey } = getWidgetIdentity(
widget,
toNodeId('5'),
3
)
expect(dedupeIdentity).toBe('node:5:test_widget:combo')
expect(renderKey).toBe(dedupeIdentity)
})
it('returns transient renderKey when no nodeId is available at all', () => {
const widget = createMockWidget({
nodeId: undefined,
sourceExecutionId: undefined
})
const { dedupeIdentity, renderKey } = getWidgetIdentity(
widget,
undefined,
3
)
expect(dedupeIdentity).toBeUndefined()
expect(renderKey).toBe('transient::test_widget:combo:3')
})
it('uses sourceExecutionId for identity when no nodeId', () => {
const widget = createMockWidget({
nodeId: undefined,
sourceExecutionId: createNodeExecutionId([toNodeId(65), toNodeId(18)])
})
const { dedupeIdentity } = getWidgetIdentity(widget, toNodeId('1'), 0)
expect(dedupeIdentity).toBe('exec:65:18:test_widget:combo')
})
})
describe('isWidgetVisible', () => {
@@ -194,9 +141,10 @@ describe('hasWidgetError', () => {
})
it('returns false when no errors', () => {
const widget = createMockWidget()
expect(
hasWidgetError(
{ name: 'test_widget' },
widget,
createNodeExecutionId([toNodeId(1)]),
undefined,
executionErrorStore,
@@ -206,12 +154,13 @@ describe('hasWidgetError', () => {
})
it('returns true when node has matching input error', () => {
const widget = createMockWidget({ name: 'seed' })
const nodeErrors = {
errors: [{ extra_info: { input_name: 'seed' } }]
}
expect(
hasWidgetError(
{ name: 'seed' },
widget,
createNodeExecutionId([toNodeId(1)]),
nodeErrors,
executionErrorStore,
@@ -220,13 +169,13 @@ describe('hasWidgetError', () => {
).toBe(true)
})
it('returns true when the resolved source target has a matching error', () => {
const sourceExecutionId = createNodeExecutionId([
toNodeId(65),
toNodeId(18)
])
it('returns true via sourceExecutionId when execution store has matching error', () => {
const widget = createMockWidget({
name: 'seed',
sourceExecutionId: createNodeExecutionId([toNodeId(65), toNodeId(18)])
})
executionErrorStore.lastNodeErrors = {
[sourceExecutionId]: {
'65:18': {
errors: [
{
type: 'required_input_missing',
@@ -239,16 +188,9 @@ describe('hasWidgetError', () => {
dependent_outputs: []
}
}
expect(
hasWidgetError(
{
name: 'display_seed',
errorTarget: {
executionId: sourceExecutionId,
widgetName: 'seed'
}
},
widget,
createNodeExecutionId([toNodeId(1)]),
undefined,
executionErrorStore,
@@ -258,10 +200,11 @@ describe('hasWidgetError', () => {
})
it('returns true when widget has missing model', () => {
const widget = createMockWidget({ name: 'ckpt_name' })
vi.spyOn(missingModelStore, 'isWidgetMissingModel').mockReturnValue(true)
expect(
hasWidgetError(
{ name: 'ckpt_name' },
widget,
createNodeExecutionId([toNodeId(1)]),
undefined,
executionErrorStore,
@@ -270,13 +213,37 @@ describe('hasWidgetError', () => {
).toBe(true)
})
it('matches errors by the slot name (widget.name) for promoted widgets', () => {
const widget = createMockWidget({
name: 'display_slot',
sourceWidgetName: 'internal_name'
})
const nodeErrors = {
errors: [{ extra_info: { input_name: 'display_slot' } }]
}
expect(
hasWidgetError(
widget,
createNodeExecutionId([toNodeId(1)]),
nodeErrors,
executionErrorStore,
missingModelStore
)
).toBe(true)
})
it('matches missing models by the host widget name', () => {
const widget = createMockWidget({
name: 'display_slot',
sourceExecutionId: createNodeExecutionId([toNodeId(65), toNodeId(18)]),
sourceWidgetName: 'ckpt_name'
})
const spy = vi
.spyOn(missingModelStore, 'isWidgetMissingModel')
.mockReturnValue(true)
expect(
hasWidgetError(
{ name: 'display_slot' },
widget,
createNodeExecutionId([toNodeId(1)]),
undefined,
executionErrorStore,
@@ -287,16 +254,111 @@ describe('hasWidgetError', () => {
})
})
describe('computeProcessedWidgets', () => {
const noopUi = {
getTooltipConfig: () => ({}) as TooltipOptions,
handleNodeRightClick: () => {}
}
describe('computeProcessedWidgets borderStyle', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('applies advanced border styling to advanced widgets', () => {
const id = widgetId(GRAPH_ID, toNodeId(1), 'text')
registerWidgetState(id, { type: 'text', options: { advanced: true } })
it('does not apply border styling to promoted widgets', () => {
const id = widgetId(GRAPH_ID, toNodeId('inner-subgraph:1'), 'text')
useWidgetValueStore().registerWidget(id, {
type: 'combo',
value: 'a',
options: {},
label: 'Text'
})
const promotedWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: toNodeId('inner-subgraph:1'),
widgetId: id
})
const result = processWidgets({ widgetIds: [id], showAdvanced: true })
const result = computeProcessedWidgets({
nodeData: {
id: toNodeId('3'),
type: 'SubgraphNode',
widgets: [promotedWidget],
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: 'graph-test',
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
expect(result[0].simplified.borderStyle).toBeUndefined()
expect(result[0].simplified.label).toBe('Text')
})
it('does not apply border styling to regular widgets', () => {
const widget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: toNodeId('inner-subgraph:1'),
widgetId: widgetId(GRAPH_ID, toNodeId('inner-subgraph:1'), 'text')
})
const result = computeProcessedWidgets({
nodeData: {
id: toNodeId('4'),
type: 'SubgraphNode',
widgets: [widget],
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: 'graph-test',
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
expect(
result.some((w) => w.simplified.borderStyle?.includes('promoted'))
).toBe(false)
})
it('applies advanced border styling to advanced widgets', () => {
const advancedWidget = createMockWidget({
name: 'text',
type: 'combo',
options: { advanced: true }
})
const result = computeProcessedWidgets({
nodeData: {
id: toNodeId('1'),
type: 'TestNode',
widgets: [advancedWidget],
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: 'graph-test',
showAdvanced: true,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
expect(result[0].simplified.borderStyle).toBe(
'ring ring-component-node-widget-advanced'
@@ -305,17 +367,37 @@ describe('computeProcessedWidgets', () => {
it('reads widget identity, value, label, and options from widgetId state', () => {
const id = widgetId(GRAPH_ID, toNodeId('host'), 'text')
registerWidgetState(id, {
useWidgetValueStore().registerWidget(id, {
type: 'combo',
value: 'state value',
label: 'State Label',
options: { values: ['state value'] }
})
const result = processWidgets({
widgetIds: [id],
const widget = createMockWidget({
widgetId: id,
nodeId: toNodeId('host'),
nodeType: 'SubgraphNode'
name: 'stale name',
type: 'combo',
options: { values: ['stale value'] }
})
const result = computeProcessedWidgets({
nodeData: {
id: toNodeId('3'),
type: 'SubgraphNode',
widgets: [widget],
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: GRAPH_ID,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
expect(result[0]).toMatchObject({
@@ -331,167 +413,265 @@ describe('computeProcessedWidgets', () => {
})
})
it('preserves null values from widgetId state', () => {
const id = widgetId(GRAPH_ID, toNodeId('host'), 'text')
registerWidgetState(id, {
type: 'combo',
value: null,
options: {}
})
const result = processWidgets({
widgetIds: [id],
nodeId: toNodeId('host')
})
expect(result[0].value).toBeNull()
expect(result[0].simplified.value).toBeNull()
})
it('uses widget state nodeId for simplified widget locator', () => {
it('uses widget nodeId for simplified widget locator when present', () => {
const subgraphId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
const id = widgetId(GRAPH_ID, toNodeId('inner-node'), 'text')
registerWidgetState(id, { type: 'combo', value: 'a', options: {} })
const widget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: toNodeId('inner-node')
})
const result = processWidgets({
widgetIds: [id],
nodeId: toNodeId('host-node'),
nodeType: 'SubgraphNode',
subgraphId
const result = computeProcessedWidgets({
nodeData: {
id: toNodeId('host-node'),
type: 'SubgraphNode',
widgets: [widget],
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: [],
subgraphId
},
graphId: GRAPH_ID,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
expect(result[0].simplified.nodeLocatorId).toBe(
createNodeLocatorId(subgraphId, toNodeId('inner-node'))
)
})
it('deduplication keeps visible widget over hidden duplicate', () => {
const sharedWidgetId = widgetId(GRAPH_ID, toNodeId('1'), 'text')
const hiddenWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: toNodeId('1'),
widgetId: sharedWidgetId,
options: { hidden: true }
})
it('deduplicates repeated widget ids', () => {
const id = widgetId(GRAPH_ID, toNodeId(1), 'text')
registerWidgetState(id, { type: 'text' })
const visibleWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: toNodeId('1'),
widgetId: sharedWidgetId
})
const result = processWidgets({ widgetIds: [id, id] })
const result = computeProcessedWidgets({
nodeData: {
id: toNodeId('1'),
type: 'TestNode',
widgets: [hiddenWidget, visibleWidget],
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: 'graph-test',
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
expect(result).toHaveLength(1)
expect(result[0].name).toBe('text')
expect(result[0].hidden).toBe(false)
})
it('keeps distinct widget ids separate even when names match', () => {
const firstId = widgetId(GRAPH_ID, toNodeId('outer-subgraph:1'), 'text')
const secondId = widgetId(GRAPH_ID, toNodeId('outer-subgraph:2'), 'text')
registerWidgetState(firstId, { type: 'text' })
registerWidgetState(secondId, { type: 'text' })
const result = processWidgets({
widgetIds: [firstId, secondId],
nodeType: 'SubgraphNode'
it('collapses duplicate normal widgets on the same node to one render', () => {
const colorA = createMockWidget({
name: 'color',
type: 'color',
nodeId: undefined,
sourceExecutionId: undefined
})
const colorB = createMockWidget({
name: 'color',
type: 'color',
nodeId: undefined,
sourceExecutionId: undefined
})
expect(result).toHaveLength(2)
expect(result.map((widget) => widget.widgetId)).toStrictEqual([
firstId,
secondId
])
const result = computeProcessedWidgets({
nodeData: {
id: toNodeId('1'),
type: 'ColorToRGBInt',
widgets: [colorA, colorB],
title: 'Color to RGB Int',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: 'graph-test',
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
expect(result).toHaveLength(1)
expect(result[0].name).toBe('color')
expect(result[0].renderKey).toBe('node:1:color:color')
})
it('reads render-only metadata from widgetValueStore render state', () => {
const id = widgetId(GRAPH_ID, toNodeId('host'), 'display_slot')
registerWidgetState(id, {
type: 'unknown',
value: 'model.safetensors',
options: {}
})
useWidgetValueStore().registerWidgetRenderState(id, {
advanced: true,
hasLayoutSize: true,
isDOMWidget: true,
tooltip: 'Choose checkpoint'
it('omits the processed widget id when node id normalization fails', () => {
const widget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: toNodeId('')
})
const result = processWidgets({
widgetIds: [id],
nodeId: toNodeId('host'),
showAdvanced: true
const result = computeProcessedWidgets({
nodeData: {
id: toNodeId('1'),
type: 'TestNode',
widgets: [widget],
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: 'graph-test',
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
expect(result[0]).toMatchObject({
advanced: true,
hasLayoutSize: true,
simplified: {
name: 'display_slot'
}
})
expect(result[0].vueComponent).toBe(WidgetDOM)
})
it('passes input spec to simplified widgets', () => {
const id = widgetId(GRAPH_ID, toNodeId('host'), 'prompt')
const spec = {
type: 'STRING',
name: 'prompt',
socketless: true
} satisfies InputSpec
registerWidgetState(id, { type: 'text', value: 'hello' })
useWidgetValueStore().registerWidgetSpec(id, spec)
const result = processWidgets({ widgetIds: [id], nodeId: toNodeId('host') })
expect(result[0].simplified.spec).toStrictEqual(spec)
})
it('treats explicit isDOMWidget false as authoritative', () => {
const id = widgetId(GRAPH_ID, toNodeId(1), 'custom')
registerWidgetState(id, { type: 'unknown' })
useWidgetValueStore().registerWidgetRenderState(id, {
isDOMWidget: false
})
const result = processWidgets({ widgetIds: [id] })
expect(result[0].vueComponent).toBe(WidgetLegacy)
expect(result[0].vueComponent).not.toBe(WidgetDOM)
expect(result[0].id).toBeUndefined()
})
})
describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
const GRAPH_ID = 'graph-test'
const NODE_ID = toNodeId(1)
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
function processUpdateWidgets(widgets: IBaseWidget[]) {
const { graph } = createGraphWithNode(widgets, NODE_ID)
const ids = widgets
.map((widget) => widget.widgetId)
.filter((id): id is WidgetId => id !== undefined)
return processWidgets({ widgetIds: ids, nodeId: NODE_ID, rootGraph: graph })
function processWidgets(widgets: SafeWidgetData[]) {
return computeProcessedWidgets({
nodeData: {
id: NODE_ID,
type: 'TestNode',
widgets,
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: GRAPH_ID,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
}
it('calls widget.callback with the new value when a live widget exists', () => {
it('calls widget.callback with the new value when widgetState exists', () => {
const callback = vi.fn()
const id = widgetId(GRAPH_ID, NODE_ID, 'seed')
const widget = createMockWidget({ name: 'seed', widgetId: id, callback })
registerWidgetState(id, { type: 'combo', value: 0 })
const widget = createMockWidget({
name: 'seed',
nodeId: NODE_ID,
callback
})
const [processed] = processUpdateWidgets([widget])
useWidgetValueStore().registerWidget(widgetId(GRAPH_ID, NODE_ID, 'seed'), {
type: 'combo',
value: 0,
options: {}
})
const [processed] = processWidgets([widget])
processed.updateHandler(42)
expect(callback).toHaveBeenCalledWith(42, undefined, expect.any(LGraphNode))
expect(callback).toHaveBeenCalledWith(42)
})
it('calls widget.callback even when widgetState is undefined (no store entry)', () => {
const callback = vi.fn()
const widget = createMockWidget({
name: 'unregistered_widget',
nodeId: NODE_ID,
callback
})
const [processed] = processWidgets([widget])
processed.updateHandler('new-value')
expect(callback).toHaveBeenCalledWith('new-value')
})
it('updates widgetState.value when store entry exists', () => {
const id = widgetId(GRAPH_ID, NODE_ID, 'seed')
registerWidgetState(id, { type: 'combo', value: 0 })
const widget = createMockWidget({
name: 'seed',
nodeId: NODE_ID
})
const [processed] = processWidgets({ widgetIds: [id], nodeId: NODE_ID })
useWidgetValueStore().registerWidget(widgetId(GRAPH_ID, NODE_ID, 'seed'), {
type: 'combo',
value: 0,
options: {}
})
const [processed] = processWidgets([widget])
processed.updateHandler(99)
expect(useWidgetValueStore().getWidget(id)?.value).toBe(99)
const state = useWidgetValueStore().getWidget(
widgetId(GRAPH_ID, NODE_ID, 'seed')
)
expect(state?.value).toBe(99)
})
it('clears promoted missing models through the host widget identity', () => {
const widget = createMockWidget({
name: 'display_slot',
nodeId: NODE_ID,
sourceExecutionId: createNodeExecutionId([65, 18]),
sourceWidgetName: 'ckpt_name'
})
const executionErrorStore = useExecutionErrorStore()
const clearSpy = vi.spyOn(executionErrorStore, 'clearWidgetRelatedErrors')
const [processed] = processWidgets([widget])
processed.updateHandler('real_model.safetensors')
expect(clearSpy).toHaveBeenCalledWith(
createNodeExecutionId([65, 18]),
'ckpt_name',
'ckpt_name',
'real_model.safetensors',
{ min: undefined, max: undefined }
)
expect(clearSpy).toHaveBeenCalledWith(
createNodeExecutionId([NODE_ID]),
'display_slot',
'display_slot',
'real_model.safetensors',
{ min: undefined, max: undefined }
)
})
it('clears execution errors on update', () => {
const id = widgetId(GRAPH_ID, NODE_ID, 'seed')
registerWidgetState(id, { type: 'combo', value: 'bad-value' })
const widget = createMockWidget({
name: 'seed',
nodeId: NODE_ID
})
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
@@ -511,11 +691,11 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
}
}
const [processed] = processWidgets({ widgetIds: [id], nodeId: NODE_ID })
const [processed] = processWidgets([widget])
expect(
hasWidgetError(
{ name: 'seed' },
widget,
createNodeExecutionId([NODE_ID]),
executionErrorStore.lastNodeErrors[NODE_ID],
executionErrorStore,
@@ -527,46 +707,7 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
expect(
hasWidgetError(
{ name: 'seed' },
createNodeExecutionId([NODE_ID]),
executionErrorStore.lastNodeErrors?.[NODE_ID],
executionErrorStore,
missingModelStore
)
).toBe(false)
})
it('clears execution errors from simplified callback without a live widget', () => {
const id = widgetId(GRAPH_ID, NODE_ID, 'seed')
registerWidgetState(id, { type: 'combo', value: 'bad-value' })
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
executionErrorStore.lastNodeErrors = {
[NODE_ID]: {
errors: [
{
type: 'required_input_missing',
message: 'seed is required',
details: '',
extra_info: { input_name: 'seed' }
}
],
class_type: 'TestNode',
dependent_outputs: []
}
}
const [processed] = processWidgets({ widgetIds: [id], nodeId: NODE_ID })
expect(processed.simplified.callback).toBe(processed.updateHandler)
processed.simplified.callback?.('fixed-value')
expect(useWidgetValueStore().getWidget(id)?.value).toBe('fixed-value')
expect(
hasWidgetError(
{ name: 'seed' },
widget,
createNodeExecutionId([NODE_ID]),
executionErrorStore.lastNodeErrors?.[NODE_ID],
executionErrorStore,

View File

@@ -2,22 +2,20 @@ import type { TooltipOptions } from 'primevue'
import { computed } from 'vue'
import type { Component } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import type {
SafeWidgetData,
VueNodeData,
WidgetSlotMetadata
} from '@/composables/graph/useGraphNodeManager'
import { useAppMode } from '@/composables/useAppMode'
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { app } from '@/scripts/app'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetDOM.vue'
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
import {
@@ -25,34 +23,29 @@ import {
shouldExpand,
shouldRenderAsVue
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { app } from '@/scripts/app'
import { nodeTypeValidForApp } from '@/stores/appModeStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import {
createNodeExecutionId,
createNodeLocatorId
} from '@/types/nodeIdentification'
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
import type { NodeId } from '@/types/nodeId'
import { getControlWidget } from '@/types/simplifiedWidget'
import type { WidgetId } from '@/types/widgetId'
import { widgetId } from '@/types/widgetId'
import type { WidgetState } from '@/types/widgetState'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import type {
LinkedUpstreamInfo,
SimplifiedWidget,
WidgetValue
} from '@/types/simplifiedWidget'
import type { WidgetId } from '@/types/widgetId'
import { parseWidgetId } from '@/types/widgetId'
import {
getExecutionIdFromNodeData,
getLocatorIdFromNodeData,
getNodeByLocatorId
} from '@/utils/graphTraversalUtil'
import { getWidgetIdForNode } from '@/utils/litegraphUtil'
import { getExecutionIdFromNodeData } from '@/utils/graphTraversalUtil'
const TOOLTIP_VALUE_TYPES = ['asset', 'combo', 'number', 'text'] as const
type TooltipValueType = (typeof TOOLTIP_VALUE_TYPES)[number]
@@ -60,25 +53,7 @@ function isTooltipValueType(val: unknown): val is TooltipValueType {
return TOOLTIP_VALUE_TYPES.includes(val as TooltipValueType)
}
interface WidgetSlotMetadata {
index: number
linked: boolean
originNodeId?: NodeId
originOutputName?: string
type: string
}
interface WidgetTooltipSource {
name: string
tooltip?: string
}
interface WidgetErrorTarget {
executionId: NodeExecutionId
widgetName: string
}
export interface ProcessedWidget {
interface ProcessedWidget {
advanced: boolean
handleContextMenu: (e: PointerEvent) => void
hasLayoutSize: boolean
@@ -99,16 +74,12 @@ export interface ProcessedWidget {
}
interface WidgetUiCallbacks {
getTooltipConfig: (
widget: WidgetTooltipSource,
fullVal?: string
) => TooltipOptions
getTooltipConfig: (widget: SafeWidgetData, fullVal?: string) => TooltipOptions
handleNodeRightClick: (e: PointerEvent, nodeId: NodeId) => void
}
interface ComputeProcessedWidgetsOptions {
nodeData: VueNodeData | undefined
widgetIds?: readonly WidgetId[]
graphId: string | undefined
showAdvanced: boolean
isGraphReady: boolean
@@ -116,62 +87,86 @@ interface ComputeProcessedWidgetsOptions {
ui: WidgetUiCallbacks
}
function normalizeWidgetValue(value: unknown): WidgetValue {
if (value === null || value === undefined || value === void 0) {
return undefined
}
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value
}
if (typeof value === 'object') {
if (
Array.isArray(value) &&
value.length > 0 &&
value.every((item): item is File => item instanceof File)
) {
return value
function createWidgetUpdateHandler(
widgetState: WidgetState | undefined,
widget: SafeWidgetData,
nodeExecId: NodeExecutionId,
widgetOptions: IWidgetOptions | Record<string, never>,
executionErrorStore: ReturnType<typeof useExecutionErrorStore>
): (newValue: WidgetValue) => void {
return (newValue: WidgetValue) => {
if (widgetState) widgetState.value = newValue
widget.callback?.(newValue)
const options = { min: widgetOptions?.min, max: widgetOptions?.max }
if (widget.sourceExecutionId) {
const sourceWidgetName = widget.sourceWidgetName ?? widget.name
executionErrorStore.clearWidgetRelatedErrors(
widget.sourceExecutionId,
sourceWidgetName,
sourceWidgetName,
newValue,
options
)
}
return value
executionErrorStore.clearWidgetRelatedErrors(
nodeExecId,
widget.name,
widget.name,
newValue,
options
)
}
console.warn(`Invalid widget value type: ${typeof value}`, value)
return undefined
}
function buildSlotMetadata(
inputs: INodeInputSlot[] | undefined,
graphRef: LGraph | null | undefined
): Map<string, WidgetSlotMetadata> {
const metadata = new Map<string, WidgetSlotMetadata>()
inputs?.forEach((input, index) => {
let originNodeId: NodeId | undefined
let originOutputName: string | undefined
export function hasWidgetError(
widget: SafeWidgetData,
nodeExecId: NodeExecutionId,
nodeErrors:
| { errors: { extra_info?: { input_name?: string } }[] }
| undefined,
executionErrorStore: ReturnType<typeof useExecutionErrorStore>,
missingModelStore: ReturnType<typeof useMissingModelStore>
): boolean {
const errors = widget.sourceExecutionId
? executionErrorStore.lastNodeErrors?.[widget.sourceExecutionId]?.errors
: nodeErrors?.errors
return (
!!errors?.some((e) => e.extra_info?.input_name === widget.name) ||
missingModelStore.isWidgetMissingModel(nodeExecId, widget.name)
)
}
let linked = input.link != null
if (input.link != null && graphRef) {
const link = graphRef.getLink(input.link)
linked = Boolean(link)
const originNode = link ? graphRef.getNodeById(link.origin_id) : null
if (link && originNode) {
originNodeId = link.origin_id
originOutputName = originNode.outputs?.[link.origin_slot]?.name
}
}
export function getWidgetIdentity(
widget: SafeWidgetData,
nodeId: NodeId | undefined,
index: number
): {
dedupeIdentity?: string
renderKey: string
} {
if (widget.widgetId) {
const dedupeIdentity = `${widget.widgetId}:${widget.type}`
return { dedupeIdentity, renderKey: dedupeIdentity }
}
const hostNodeIdRoot = nodeId ? stripGraphPrefix(nodeId) : null
const widgetNodeIdRoot = widget.nodeId
? stripGraphPrefix(widget.nodeId)
: null
const stableIdentityRoot = widgetNodeIdRoot
? `node:${widgetNodeIdRoot}`
: widget.sourceExecutionId
? `exec:${widget.sourceExecutionId}`
: hostNodeIdRoot
? `node:${hostNodeIdRoot}`
: undefined
const slotInfo: WidgetSlotMetadata = {
index,
linked,
originNodeId,
originOutputName,
type: String(input.type)
}
if (input.name) metadata.set(input.name, slotInfo)
if (input.widget?.name) metadata.set(input.widget.name, slotInfo)
})
return metadata
const dedupeIdentity = stableIdentityRoot
? `${stableIdentityRoot}:${widget.name}:${widget.type}`
: undefined
const renderKey =
dedupeIdentity ??
`transient:${String(nodeId ?? '')}:${widget.name}:${widget.type}:${index}`
return { dedupeIdentity, renderKey }
}
function getProcessedNodeExecutionId(
@@ -195,53 +190,6 @@ function getWidgetNodeLocatorId(
)
}
function getHostNode(
rootGraph: LGraph | null,
nodeData: VueNodeData
): LGraphNode | null {
if (!rootGraph) return null
const locatorId = getLocatorIdFromNodeData(nodeData)
return locatorId ? getNodeByLocatorId(rootGraph, locatorId) : null
}
function getLiveWidget(
rootGraph: LGraph | null,
nodeData: VueNodeData,
id: WidgetId
): { node: LGraphNode; widget: IBaseWidget } | undefined {
if (!rootGraph) return undefined
const { nodeId } = parseWidgetId(id)
const locatorId = createNodeLocatorId(nodeData.subgraphId ?? null, nodeId)
const node = locatorId ? getNodeByLocatorId(rootGraph, locatorId) : null
if (!node) return undefined
const duplicateIndexByKey = new Map<string, number>()
for (const widget of node.widgets ?? []) {
const duplicateKey = `${widget.name}:${widget.type}`
const duplicateIndex = duplicateIndexByKey.get(duplicateKey) ?? 0
duplicateIndexByKey.set(duplicateKey, duplicateIndex + 1)
if (getWidgetIdForNode(node, widget, duplicateIndex) === id) {
return { node, widget }
}
}
}
function getWidgetErrorTarget(
rootGraph: LGraph | null,
hostNode: LGraphNode | null,
liveWidget: IBaseWidget | undefined
): WidgetErrorTarget | undefined {
if (!hostNode || !liveWidget) return undefined
const source = resolvePromotedWidgetSource(rootGraph, hostNode, liveWidget)
if (!source?.sourceExecutionId) return undefined
return {
executionId: source.sourceExecutionId,
widgetName: source.sourceWidgetName
}
}
export function isWidgetVisible(
options: IWidgetOptions,
showAdvanced: boolean,
@@ -252,123 +200,19 @@ export function isWidgetVisible(
return !hidden && (!advanced || showAdvanced || linked)
}
export function hasWidgetError(
widget: { name: string; errorTarget?: WidgetErrorTarget },
nodeExecId: NodeExecutionId,
nodeErrors:
| { errors: { extra_info?: { input_name?: string } }[] }
| undefined,
executionErrorStore: ReturnType<typeof useExecutionErrorStore>,
missingModelStore: ReturnType<typeof useMissingModelStore>
): boolean {
const hasHostError =
!!nodeErrors?.errors.some(
(e) => e.extra_info?.input_name === widget.name
) || missingModelStore.isWidgetMissingModel(nodeExecId, widget.name)
const target = widget.errorTarget
if (!target) return hasHostError
const sourceErrors = executionErrorStore.lastNodeErrors?.[target.executionId]
return (
hasHostError ||
!!sourceErrors?.errors.some(
(e) => e.extra_info?.input_name === target.widgetName
) ||
missingModelStore.isWidgetMissingModel(
target.executionId,
target.widgetName
)
)
}
export function getWidgetIdentity(
widget: { widgetId: WidgetId; type: string },
_nodeId: NodeId | undefined,
_index: number
): {
dedupeIdentity: string
renderKey: string
} {
const dedupeIdentity = `${widget.widgetId}:${widget.type}`
return { dedupeIdentity, renderKey: dedupeIdentity }
}
function createWidgetUpdateHandler({
id,
live,
errorTarget,
nodeExecId,
widgetName,
widgetOptions,
executionErrorStore,
widgetValueStore
}: {
id: WidgetId
live?: { node: LGraphNode; widget: IBaseWidget }
errorTarget?: WidgetErrorTarget
nodeExecId: NodeExecutionId
widgetName: string
widgetOptions: IWidgetOptions
executionErrorStore: ReturnType<typeof useExecutionErrorStore>
widgetValueStore: ReturnType<typeof useWidgetValueStore>
}): (newValue: WidgetValue) => void {
return (newValue: WidgetValue) => {
widgetValueStore.setValue(id, newValue)
if (live) {
const normalized = normalizeWidgetValue(newValue)
live.widget.value = normalized ?? undefined
live.widget.callback?.(normalized, app.canvas, live.node)
live.node.widgets?.forEach((w) => w.triggerDraw?.())
}
const options = { min: widgetOptions?.min, max: widgetOptions?.max }
if (errorTarget) {
executionErrorStore.clearWidgetRelatedErrors(
errorTarget.executionId,
errorTarget.widgetName,
errorTarget.widgetName,
newValue,
options
)
}
executionErrorStore.clearWidgetRelatedErrors(
nodeExecId,
widgetName,
widgetName,
newValue,
options
)
}
}
function getWidgetIds(
graphId: string | undefined,
nodeId: NodeId,
explicitWidgetIds: readonly WidgetId[] | undefined,
widgetValueStore: ReturnType<typeof useWidgetValueStore>
): readonly WidgetId[] {
if (explicitWidgetIds) return explicitWidgetIds
const bareNodeId = stripGraphPrefix(nodeId)
return graphId && bareNodeId
? widgetValueStore.getNodeWidgetIds(graphId, bareNodeId)
: []
}
export function computeProcessedWidgets({
nodeData,
widgetIds,
graphId,
showAdvanced,
isGraphReady,
rootGraph,
ui
}: ComputeProcessedWidgetsOptions): ProcessedWidget[] {
if (!nodeData) return []
if (!nodeData?.widgets) return []
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const widgetValueStore = useWidgetValueStore()
const nodeDefStore = useNodeDefStore()
const nodeExecId = getProcessedNodeExecutionId(
isGraphReady,
@@ -377,140 +221,185 @@ export function computeProcessedWidgets({
)
if (!nodeExecId) return []
const ids = getWidgetIds(graphId, nodeData.id, widgetIds, widgetValueStore)
const hostNode = getHostNode(rootGraph, nodeData)
const slotMetadata = buildSlotMetadata(
nodeData.inputs ?? hostNode?.inputs,
hostNode?.graph ?? rootGraph
)
const nodeErrors = executionErrorStore.lastNodeErrors?.[nodeExecId]
const nodeId = nodeData.id
const { widgets } = nodeData
const result: ProcessedWidget[] = []
const seenIdentities = new Set<string>()
const uniqueWidgets: Array<{
widget: SafeWidgetData
identity: ReturnType<typeof getWidgetIdentity>
mergedOptions: IWidgetOptions
widgetState: WidgetState | undefined
isVisible: boolean
}> = []
const dedupeIndexByIdentity = new Map<string, number>()
ids.forEach((id, index) => {
const widgetState = widgetValueStore.getWidget(id)
if (!widgetState) return
for (const [index, widget] of widgets.entries()) {
if (!shouldRenderAsVue(widget)) continue
const renderState = widgetValueStore.getWidgetRenderState(id)
const live = getLiveWidget(rootGraph, nodeData, id)
const liveWidget = live?.widget
const sourceWidget =
hostNode && liveWidget
? resolvePromotedWidgetSource(rootGraph, hostNode, liveWidget)
?.sourceWidget
const identity = getWidgetIdentity(widget, nodeId, index)
const widgetNodeId = stripGraphPrefix(widget.nodeId ?? nodeId)
const widgetState = widget.widgetId
? widgetValueStore.getWidget(widget.widgetId)
: graphId && widgetNodeId
? widgetValueStore.getWidget(
widgetId(graphId, widgetNodeId, widget.name)
)
: undefined
const options: IWidgetOptions = { ...(widgetState.options ?? {}) }
if (options.advanced === undefined) {
options.advanced = renderState?.advanced
const mergedOptions: IWidgetOptions = {
...(widget.options ?? {}),
...(widgetState?.options ?? {})
}
const visible = isWidgetVisible(
mergedOptions,
showAdvanced,
widget.slotMetadata?.linked
)
if (!identity.dedupeIdentity) {
uniqueWidgets.push({
widget,
identity,
mergedOptions,
widgetState,
isVisible: visible
})
continue
}
if (!shouldRenderAsVue({ type: widgetState.type, options })) return
const slotInfo = slotMetadata.get(widgetState.name)
const visible = isWidgetVisible(options, showAdvanced, slotInfo?.linked)
const isDisabled = slotInfo?.linked || widgetState.disabled
const widgetOptions = isDisabled ? { ...options, disabled: true } : options
const value = widgetState.value as WidgetValue
const errorTarget = getWidgetErrorTarget(rootGraph, hostNode, liveWidget)
const tooltip = renderState?.tooltip
const hasLayoutSize = renderState?.hasLayoutSize ?? false
const isDOMWidget = renderState?.isDOMWidget ?? false
const existingIndex = dedupeIndexByIdentity.get(identity.dedupeIdentity)
if (existingIndex === undefined) {
dedupeIndexByIdentity.set(identity.dedupeIdentity, uniqueWidgets.length)
uniqueWidgets.push({
widget,
identity,
mergedOptions,
widgetState,
isVisible: visible
})
continue
}
const existingWidget = uniqueWidgets[existingIndex]
if (existingWidget && !existingWidget.isVisible && visible) {
uniqueWidgets[existingIndex] = {
widget,
identity,
mergedOptions,
widgetState,
isVisible: true
}
}
}
for (const {
widget,
mergedOptions,
widgetState,
isVisible: visible,
identity: { renderKey }
} of uniqueWidgets) {
const bareWidgetId = stripGraphPrefix(widget.nodeId ?? nodeId)
const vueComponent =
getComponent(widgetState.type) || (isDOMWidget ? WidgetDOM : WidgetLegacy)
const bareWidgetId = stripGraphPrefix(widgetState.nodeId)
getComponent(widget.type) ||
(widget.isDOMWidget ? WidgetDOM : WidgetLegacy)
const { slotMetadata } = widget
const value = widgetState?.value as WidgetValue
const isDisabled = slotMetadata?.linked || widgetState?.disabled
const widgetOptions = isDisabled
? { ...mergedOptions, disabled: true }
: mergedOptions
const borderStyle = mergedOptions.advanced
? 'ring ring-component-node-widget-advanced'
: undefined
const linkedUpstream: LinkedUpstreamInfo | undefined =
slotInfo?.linked && slotInfo.originNodeId
slotMetadata?.linked && slotMetadata.originNodeId
? {
nodeId: slotInfo.originNodeId,
outputName: slotInfo.originOutputName
nodeId: slotMetadata.originNodeId,
outputName: slotMetadata.originOutputName
}
: undefined
const controlWidget =
(liveWidget ? getControlWidget(liveWidget) : undefined) ??
(sourceWidget ? getControlWidget(sourceWidget) : undefined)
const updateHandler = createWidgetUpdateHandler({
id,
live,
errorTarget,
nodeExecId,
widgetName: widgetState.name,
widgetOptions,
executionErrorStore,
widgetValueStore
})
const nodeLocatorId = getWidgetNodeLocatorId(nodeData, bareWidgetId)
const simplified: SimplifiedWidget = {
name: widgetState.name,
type: widgetState.type,
name: widgetState?.name ?? widget.name,
type: widget.type,
value,
borderStyle: widgetOptions.advanced
? 'ring ring-component-node-widget-advanced'
: undefined,
callback: updateHandler,
controlWidget,
label: widgetState.label,
borderStyle,
callback: widget.callback,
controlWidget: widget.controlWidget,
label: widgetState?.label,
linkedUpstream,
nodeLocatorId: getWidgetNodeLocatorId(nodeData, bareWidgetId),
nodeLocatorId,
options: widgetOptions,
spec:
widgetValueStore.getWidgetSpec(id)?.spec ??
(live
? nodeDefStore.getInputSpecForWidget(live.node, live.widget.name)
: undefined)
spec: widget.spec
}
const updateHandler = createWidgetUpdateHandler(
widgetState,
widget,
nodeExecId,
widgetOptions,
executionErrorStore
)
const valueTooltip =
isTooltipValueType(widgetState.type) && String(value).length > 10
isTooltipValueType(widget.type) && String(value).length > 10
? String(value)
: undefined
const tooltipConfig = ui.getTooltipConfig(
{ name: widgetState.name, tooltip },
valueTooltip
)
const tooltipConfig = ui.getTooltipConfig(widget, valueTooltip)
const handleContextMenu = (e: PointerEvent) => {
e.preventDefault()
e.stopPropagation()
ui.handleNodeRightClick(e, nodeData.id)
showNodeOptions(e, widgetState.name)
if (nodeId !== undefined) ui.handleNodeRightClick(e, nodeId)
showNodeOptions(
e,
widget.name,
widget.nodeId !== undefined
? (stripGraphPrefix(widget.nodeId) ?? undefined)
: undefined
)
}
const identity = getWidgetIdentity(
{ widgetId: id, type: widgetState.type },
nodeData.id,
index
)
if (seenIdentities.has(identity.dedupeIdentity)) return
seenIdentities.add(identity.dedupeIdentity)
result.push({
advanced: widgetOptions.advanced ?? false,
advanced: mergedOptions.advanced ?? false,
handleContextMenu,
hasLayoutSize,
hasLayoutSize: widget.hasLayoutSize ?? false,
hasError: hasWidgetError(
{ name: widgetState.name, errorTarget },
widget,
nodeExecId,
nodeErrors,
executionErrorStore,
missingModelStore
),
hidden: widgetOptions.hidden ?? false,
widgetId: id,
name: widgetState.name,
renderKey: identity.renderKey,
type: widgetState.type,
hidden: mergedOptions.hidden ?? false,
widgetId: widget.widgetId,
name: widget.name,
renderKey,
type: widget.type,
vueComponent,
simplified,
value,
visible,
updateHandler,
tooltipConfig,
slotMetadata: slotInfo,
slotMetadata,
...(bareWidgetId === null ? {} : { id: bareWidgetId })
})
})
}
return result
}
export function useProcessedWidgets(
nodeDataGetter: () => VueNodeData | undefined,
widgetIdsGetter: () => readonly WidgetId[] | undefined = () => undefined
nodeDataGetter: () => VueNodeData | undefined
) {
const canvasStore = useCanvasStore()
const settingStore = useSettingStore()
@@ -547,7 +436,6 @@ export function useProcessedWidgets(
const processedWidgets = computed((): ProcessedWidget[] =>
computeProcessedWidgets({
nodeData: nodeDataGetter(),
widgetIds: widgetIdsGetter(),
graphId: canvasStore.canvas?.graph?.rootGraph.id,
showAdvanced: showAdvanced.value,
isGraphReady: app.isGraphReady,

View File

@@ -78,9 +78,7 @@ watch(() => canvasStore.currentGraph, bindWidget)
function draw() {
if (!widgetInstance || !node) return
const width =
canvasEl.value.parentElement.clientWidth ||
canvasEl.value.getBoundingClientRect().width
const width = canvasEl.value.parentElement.clientWidth
// Priority: computedHeight (from litegraph) > computeLayoutSize > computeSize
let height = 20
if (widgetInstance.computedHeight) {
@@ -128,7 +126,7 @@ function handleMove(e: PointerEvent) {
</script>
<template>
<div
class="relative mx-[-12px] w-full min-w-0"
class="relative mx-[-12px] min-w-0 basis-0"
:style="{ minHeight: `${containerHeight}px` }"
>
<canvas

View File

@@ -8,6 +8,7 @@ import {
shouldRenderAsVue,
FOR_TESTING
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
const {
WidgetButton,
@@ -133,7 +134,7 @@ describe('widgetRegistry', () => {
})
it('should respect options while checking type', () => {
const widget: { type: string; options: { canvasOnly: boolean } } = {
const widget: Partial<SafeWidgetData> = {
type: 'text',
options: { canvasOnly: false }
}

View File

@@ -4,7 +4,7 @@
import { defineAsyncComponent } from 'vue'
import type { Component } from 'vue'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
const WidgetButton = defineAsyncComponent(
() => import('../components/WidgetButton.vue')
@@ -268,10 +268,7 @@ export const isEssential = (type: string): boolean => {
return widgets.get(canonicalType)?.essential || false
}
export const shouldRenderAsVue = (widget: {
options?: Pick<IWidgetOptions, 'canvasOnly'>
type?: string
}): boolean => {
export const shouldRenderAsVue = (widget: Partial<SafeWidgetData>): boolean => {
return !widget.options?.canvasOnly && !!widget.type
}

View File

@@ -2,8 +2,10 @@ import { fromAny } from '@total-typescript/shoehorn'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { MissingNodeType } from '@/types/comfy'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import type { NodeLocatorId } from '@/types/nodeIdentification'
// Mock dependencies
vi.mock('@/i18n', () => ({
@@ -15,6 +17,53 @@ vi.mock('@/platform/distribution/types', () => ({
}))
const mockShowErrorsTab = vi.hoisted(() => ({ value: false }))
const {
mockApp,
mockCanvasStore,
mockExecutionIdToNodeLocatorId,
mockGetExecutionIdByNode,
mockGetNodeByExecutionId,
mockWorkflowStore
} = vi.hoisted(() => ({
mockApp: {
isGraphReady: true,
rootGraph: {}
},
mockCanvasStore: {
currentGraph: undefined as object | undefined
},
mockExecutionIdToNodeLocatorId: vi.fn(
(_rootGraph: unknown, id: string) => id as NodeLocatorId
),
mockGetExecutionIdByNode: vi.fn(),
mockGetNodeByExecutionId: vi.fn(),
mockWorkflowStore: {
nodeLocatorIdToNodeId: vi.fn()
}
}))
vi.mock('@/scripts/app', () => ({ app: mockApp }))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => mockCanvasStore
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => mockWorkflowStore
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
executionIdToNodeLocatorId: (
...args: Parameters<typeof mockExecutionIdToNodeLocatorId>
) => mockExecutionIdToNodeLocatorId(...args),
forEachNode: vi.fn(),
getExecutionIdByNode: (
...args: Parameters<typeof mockGetExecutionIdByNode>
) => mockGetExecutionIdByNode(...args),
getNodeByExecutionId: (
...args: Parameters<typeof mockGetNodeByExecutionId>
) => mockGetNodeByExecutionId(...args)
}))
vi.mock('@/stores/settingStore', () => ({
useSettingStore: vi.fn(() => ({
@@ -39,6 +88,22 @@ import { useExecutionErrorStore } from './executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { toNodeId } from '@/types/nodeId'
beforeEach(() => {
mockShowErrorsTab.value = false
mockApp.isGraphReady = true
mockCanvasStore.currentGraph = undefined
mockExecutionIdToNodeLocatorId.mockImplementation(
(_rootGraph: unknown, id: string) => id as NodeLocatorId
)
mockGetExecutionIdByNode.mockReset()
mockGetNodeByExecutionId.mockReset()
mockWorkflowStore.nodeLocatorIdToNodeId.mockReset()
mockWorkflowStore.nodeLocatorIdToNodeId.mockImplementation(
(locator: NodeLocatorId) =>
toNodeId(String(locator).split(':').at(-1) ?? locator)
)
})
describe('executionErrorStore — node error operations', () => {
beforeEach(() => {
setActivePinia(createPinia())
@@ -144,6 +209,31 @@ describe('executionErrorStore — node error operations', () => {
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
})
it('does nothing when the requested slot has no errors', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
'123': {
errors: [
{
type: 'value_bigger_than_max',
message: 'Max exceeded',
details: '',
extra_info: { input_name: 'otherSlot' }
}
],
dependent_outputs: [],
class_type: 'TestNode'
}
}
store.clearSimpleNodeErrors(
createNodeExecutionId([toNodeId(123)]),
'testSlot'
)
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
})
it('preserves complex errors when slot has both simple and complex errors', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
@@ -388,6 +478,358 @@ describe('executionErrorStore — node error operations', () => {
expect(store.lastNodeErrors).not.toBeNull()
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
})
it('keeps numeric range errors when no range options prove them valid', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
'123': {
errors: [
{
type: 'value_bigger_than_max',
message: '...',
details: '',
extra_info: { input_name: 'testWidget' }
}
],
dependent_outputs: [],
class_type: 'TestNode'
}
}
store.clearWidgetRelatedErrors(
createNodeExecutionId([toNodeId(123)]),
'testWidget',
'testWidget',
15
)
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
})
it('clears simple widget errors when the numeric value has no node error entry', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
'999': {
errors: [
{
type: 'value_bigger_than_max',
message: '...',
details: '',
extra_info: { input_name: 'testWidget' }
}
],
dependent_outputs: [],
class_type: 'TestNode'
}
}
store.clearWidgetRelatedErrors(
createNodeExecutionId([toNodeId(123)]),
'testWidget',
'testWidget',
15,
{ max: 10 }
)
expect(store.lastNodeErrors?.['999'].errors).toHaveLength(1)
})
})
describe('startup clearing', () => {
it('clears execution-start errors and closes the overlay when node errors are empty', () => {
const store = useExecutionErrorStore()
store.lastExecutionError = fromAny({ node_id: '1' })
store.lastPromptError = fromAny({ message: 'prompt failed' })
store.lastNodeErrors = {}
store.showErrorOverlay()
store.clearExecutionStartErrors()
expect(store.lastExecutionError).toBeNull()
expect(store.lastPromptError).toBeNull()
expect(store.isErrorOverlayOpen).toBe(false)
})
it('keeps the overlay open when node errors remain after execution start', () => {
const store = useExecutionErrorStore()
store.lastExecutionError = fromAny({ node_id: '1' })
store.lastPromptError = fromAny({ message: 'prompt failed' })
store.lastNodeErrors = {
'1': {
errors: [
{
type: 'required_input_missing',
message: 'Missing',
details: '',
extra_info: { input_name: 'x' }
}
],
dependent_outputs: [],
class_type: 'Test'
}
}
store.showErrorOverlay()
store.clearExecutionStartErrors()
expect(store.isErrorOverlayOpen).toBe(true)
})
})
})
describe('executionErrorStore derived graph state', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('derives execution error node ids through locator mapping', () => {
const store = useExecutionErrorStore()
mockExecutionIdToNodeLocatorId.mockReturnValue(
fromAny<NodeLocatorId, string>('graph:7')
)
store.lastExecutionError = fromAny({ node_id: '7' })
expect(store.lastExecutionErrorNodeId).toBe(toNodeId(7))
})
it('returns null when there is no execution error locator', () => {
const store = useExecutionErrorStore()
store.lastExecutionError = fromAny({ node_id: '7' })
mockExecutionIdToNodeLocatorId.mockReturnValue(
fromAny<NodeLocatorId, undefined>(undefined)
)
expect(store.lastExecutionErrorNodeId).toBeNull()
})
it('returns null when there is no execution error', () => {
const store = useExecutionErrorStore()
expect(store.lastExecutionErrorNodeId).toBeNull()
})
it('combines prompt, node, execution, and missing-node error counts', () => {
const store = useExecutionErrorStore()
const missingNodesStore = useMissingNodesErrorStore()
store.lastPromptError = fromAny({ message: 'prompt failed' })
store.lastExecutionError = fromAny({ node_id: null })
store.lastNodeErrors = {
'1': {
errors: [
{
type: 'required_input_missing',
message: 'Missing',
details: '',
extra_info: { input_name: 'x' }
},
{
type: 'value_bigger_than_max',
message: 'Too large',
details: '',
extra_info: { input_name: 'y' }
}
],
dependent_outputs: [],
class_type: 'Test'
}
}
missingNodesStore.setMissingNodeTypes(
fromAny<MissingNodeType[], unknown>([{ type: 'MissingNode', hint: '' }])
)
expect(store.hasPromptError).toBe(true)
expect(store.hasNodeError).toBe(true)
expect(store.hasExecutionError).toBe(true)
expect(store.hasAnyError).toBe(true)
expect(store.allErrorExecutionIds).toEqual(['1'])
expect(store.totalErrorCount).toBe(5)
})
it('reports empty derived state when there are no errors', () => {
const store = useExecutionErrorStore()
expect(store.hasNodeError).toBe(false)
expect(store.allErrorExecutionIds).toEqual([])
expect(store.totalErrorCount).toBe(0)
})
it('includes defined execution node ids in the error id list', () => {
const store = useExecutionErrorStore()
store.lastExecutionError = fromAny({ node_id: '2' })
expect(store.allErrorExecutionIds).toEqual(['2'])
})
it('excludes undefined execution node ids from the error id list', () => {
const store = useExecutionErrorStore()
store.lastExecutionError = fromAny({ node_id: undefined })
expect(store.allErrorExecutionIds).toEqual([])
})
it('collects active graph node ids for validation and execution errors', () => {
const store = useExecutionErrorStore()
const activeGraph = {}
mockCanvasStore.currentGraph = activeGraph
mockGetNodeByExecutionId.mockImplementation((_rootGraph, id: string) => ({
id: toNodeId(id),
graph: activeGraph
}))
store.lastNodeErrors = {
'1': {
errors: [
{
type: 'required_input_missing',
message: 'Missing',
details: '',
extra_info: { input_name: 'x' }
}
],
dependent_outputs: [],
class_type: 'Test'
}
}
store.lastExecutionError = fromAny({ node_id: '2' })
expect([...store.activeGraphErrorNodeIds].sort()).toEqual(['1', '2'])
})
it('falls back to the root graph when there is no current canvas graph', () => {
const store = useExecutionErrorStore()
mockCanvasStore.currentGraph = undefined
mockGetNodeByExecutionId.mockReturnValue({
id: toNodeId(1),
graph: mockApp.rootGraph
})
store.lastNodeErrors = {
'1': {
errors: [
{
type: 'required_input_missing',
message: 'Missing',
details: '',
extra_info: { input_name: 'x' }
}
],
dependent_outputs: [],
class_type: 'Test'
}
}
expect([...store.activeGraphErrorNodeIds]).toEqual(['1'])
})
it('ignores graph errors outside the active graph', () => {
const store = useExecutionErrorStore()
const activeGraph = {}
mockCanvasStore.currentGraph = activeGraph
mockGetNodeByExecutionId.mockReturnValue({
id: toNodeId(1),
graph: {}
})
store.lastNodeErrors = {
'1': {
errors: [
{
type: 'required_input_missing',
message: 'Missing',
details: '',
extra_info: { input_name: 'x' }
}
],
dependent_outputs: [],
class_type: 'Test'
}
}
store.lastExecutionError = fromAny({ node_id: '1' })
expect(store.activeGraphErrorNodeIds.size).toBe(0)
})
it('returns no active graph node ids before the graph is ready', () => {
const store = useExecutionErrorStore()
mockApp.isGraphReady = false
store.lastExecutionError = fromAny({ node_id: '2' })
expect(store.activeGraphErrorNodeIds.size).toBe(0)
})
it('maps node errors by locator and checks slots', () => {
const store = useExecutionErrorStore()
const nodeError = {
errors: [
{
type: 'required_input_missing',
message: 'Missing',
details: '',
extra_info: { input_name: 'x' }
}
],
dependent_outputs: [],
class_type: 'Test'
}
mockExecutionIdToNodeLocatorId.mockImplementation((_rootGraph, id) =>
id === 'missing'
? fromAny<NodeLocatorId, undefined>(undefined)
: fromAny<NodeLocatorId, string>(`locator:${id}`)
)
store.lastNodeErrors = {
'1': nodeError,
missing: nodeError
}
const locator = fromAny<NodeLocatorId, string>('locator:1')
expect(store.getNodeErrors(locator)).toEqual(nodeError)
expect(store.slotHasError(locator, 'x')).toBe(true)
expect(store.slotHasError(locator, 'y')).toBe(false)
expect(
store.getNodeErrors(fromAny<NodeLocatorId, string>('locator:missing'))
).toBeUndefined()
})
it('returns no slot error when there are no node errors', () => {
const store = useExecutionErrorStore()
expect(
store.slotHasError(fromAny<NodeLocatorId, string>('locator:1'), 'x')
).toBe(false)
})
it('detects container nodes with internal errors', () => {
const store = useExecutionErrorStore()
const node = fromAny<LGraphNode, unknown>({})
mockGetExecutionIdByNode.mockReturnValueOnce(undefined)
expect(store.isContainerWithInternalError(node)).toBe(false)
store.lastNodeErrors = {
'1:2': {
errors: [
{
type: 'required_input_missing',
message: 'Missing',
details: '',
extra_info: { input_name: 'x' }
}
],
dependent_outputs: [],
class_type: 'Test'
}
}
mockGetExecutionIdByNode.mockReturnValue(
createNodeExecutionId([toNodeId(1)])
)
expect(store.isContainerWithInternalError(node)).toBe(true)
})
it('does not report container errors before the graph is ready', () => {
const store = useExecutionErrorStore()
mockApp.isGraphReady = false
expect(
store.isContainerWithInternalError(fromAny<LGraphNode, unknown>({}))
).toBe(false)
})
})
@@ -457,6 +899,23 @@ describe('surfaceMissingModels — silent option', () => {
expect(store.isErrorOverlayOpen).toBe(false)
})
it('does NOT open error overlay when the setting is disabled', () => {
const store = useExecutionErrorStore()
mockShowErrorsTab.value = false
store.surfaceMissingModels([
fromAny({
name: 'model.safetensors',
nodeId: toNodeId('1'),
nodeType: 'Loader',
widgetName: 'ckpt',
isMissing: true,
isAssetSupported: false
})
])
expect(store.isErrorOverlayOpen).toBe(false)
})
})
describe('surfaceMissingMedia — silent option', () => {
@@ -525,6 +984,23 @@ describe('surfaceMissingMedia — silent option', () => {
expect(store.isErrorOverlayOpen).toBe(false)
})
it('does NOT open error overlay when the setting is disabled', () => {
const store = useExecutionErrorStore()
mockShowErrorsTab.value = false
store.surfaceMissingMedia([
fromAny({
name: 'photo.png',
nodeId: toNodeId('1'),
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
isMissing: true
})
])
expect(store.isErrorOverlayOpen).toBe(false)
})
})
describe('clearAllErrors', () => {

View File

@@ -0,0 +1,120 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useExecutionStore } from '@/stores/executionStore'
const { handlers, openSet } = vi.hoisted(() => ({
handlers: {} as Record<string, (e: { detail: unknown }) => void>,
openSet: new Set<unknown>()
}))
vi.mock('@/scripts/app', () => ({ app: { rootGraph: {} } }))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: (name: string, fn: (e: { detail: unknown }) => void) => {
handlers[name] = fn
},
removeEventListener: () => {}
}
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
isOpen: (workflow: unknown) => openSet.has(workflow),
openWorkflows: [],
nodeLocatorIdToNodeExecutionId: () => null
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ canvas: undefined })
}))
vi.mock('@/stores/executionErrorStore', () => ({
useExecutionErrorStore: () => ({
clearExecutionStartErrors: () => {},
clearPromptError: () => {}
})
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ mode: ref('default'), isAppMode: ref(false) })
}))
vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined }))
vi.mock('@/utils/appMode', () => ({
getWorkflowMode: () => 'workflow',
isAppModeValue: () => false
}))
function workflow(path: string): ComfyWorkflow {
return { path } as unknown as ComfyWorkflow
}
function setup() {
const store = useExecutionStore()
store.bindExecutionEvents()
return store
}
function promptOutput(): ComfyApiWorkflow {
return {}
}
function startJob(
store: ReturnType<typeof useExecutionStore>,
id: string,
wf: ComfyWorkflow,
nodes: string[] = []
) {
openSet.add(wf)
store.storeJob({ nodes, id, promptOutput: promptOutput(), workflow: wf })
handlers['execution_start']?.({ detail: { prompt_id: id } })
}
beforeEach(() => {
setActivePinia(createPinia())
for (const key of Object.keys(handlers)) delete handlers[key]
openSet.clear()
})
describe('executionStore interrupt and cached', () => {
it('drops the workflow badge and goes idle on interruption', () => {
const store = setup()
const wf = workflow('a.json')
startJob(store, 'job-1', wf)
expect(store.getWorkflowStatus(wf)).toBe('running')
handlers['execution_interrupted']?.({ detail: { prompt_id: 'job-1' } })
expect(store.getWorkflowStatus(wf)).toBeUndefined()
expect(store.isIdle).toBe(true)
})
it('ends the active job when executing resolves to null', () => {
const store = setup()
startJob(store, 'job-2', workflow('b.json'))
expect(store.isIdle).toBe(false)
handlers['executing']?.({ detail: null })
expect(store.isIdle).toBe(true)
})
it('marks cached nodes as executed', () => {
const store = setup()
startJob(store, 'job-3', workflow('c.json'), ['a', 'b', 'c'])
expect(store.nodesExecuted).toBe(0)
handlers['execution_cached']?.({
detail: { prompt_id: 'job-3', nodes: ['a', 'b'] }
})
expect(store.nodesExecuted).toBe(2)
})
})

View File

@@ -0,0 +1,119 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useExecutionStore } from '@/stores/executionStore'
const { handlers } = vi.hoisted(() => ({
handlers: {} as Record<string, (e: { detail: unknown }) => void>
}))
vi.mock('@/scripts/app', () => ({ app: { rootGraph: {} } }))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: (name: string, fn: (e: { detail: unknown }) => void) => {
handlers[name] = fn
},
removeEventListener: () => {}
}
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
isOpen: () => false,
openWorkflows: [],
nodeLocatorIdToNodeExecutionId: () => null
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ canvas: undefined })
}))
vi.mock('@/stores/executionErrorStore', () => ({
useExecutionErrorStore: () => ({
clearExecutionStartErrors: () => {},
clearPromptError: () => {}
})
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ mode: ref('default'), isAppMode: ref(false) })
}))
vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined }))
vi.mock('@/utils/appMode', () => ({
getWorkflowMode: () => 'workflow',
isAppModeValue: () => false
}))
function setup() {
const store = useExecutionStore()
store.bindExecutionEvents()
return store
}
function promptOutput(): ComfyApiWorkflow {
return {}
}
function startJob(
store: ReturnType<typeof useExecutionStore>,
id: string,
nodes: string[]
) {
store.storeJob({
nodes,
id,
promptOutput: promptOutput(),
workflow: { path: `${id}.json` } as unknown as ComfyWorkflow
})
handlers['execution_start']?.({ detail: { prompt_id: id } })
}
beforeEach(() => {
setActivePinia(createPinia())
for (const key of Object.keys(handlers)) delete handlers[key]
})
describe('executionStore execution lifecycle', () => {
it('reports zero progress while idle', () => {
const store = setup()
expect(store.totalNodesToExecute).toBe(0)
expect(store.nodesExecuted).toBe(0)
expect(store.executionProgress).toBe(0)
})
it('counts the queued nodes once a job starts', () => {
const store = setup()
startJob(store, 'job-1', ['a', 'b', 'c'])
expect(store.totalNodesToExecute).toBe(3)
expect(store.nodesExecuted).toBe(0)
expect(store.executionProgress).toBe(0)
})
it('advances progress as executed events arrive', () => {
const store = setup()
startJob(store, 'job-1', ['a', 'b', 'c'])
handlers['executed']?.({ detail: { node: 'a' } })
expect(store.nodesExecuted).toBe(1)
expect(store.executionProgress).toBeCloseTo(1 / 3)
handlers['executed']?.({ detail: { node: 'b' } })
handlers['executed']?.({ detail: { node: 'c' } })
expect(store.nodesExecuted).toBe(3)
expect(store.executionProgress).toBe(1)
})
it('ignores executed events when there is no active job', () => {
const store = setup()
handlers['executed']?.({ detail: { node: 'a' } })
expect(store.nodesExecuted).toBe(0)
})
})

View File

@@ -0,0 +1,128 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { NodeProgressState } from '@/schemas/apiSchema'
import { useExecutionStore } from '@/stores/executionStore'
const { handlers } = vi.hoisted(() => ({
handlers: {} as Record<string, (e: { detail: unknown }) => void>
}))
vi.mock('@/scripts/app', () => ({ app: { rootGraph: {} } }))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: (name: string, fn: (e: { detail: unknown }) => void) => {
handlers[name] = fn
},
removeEventListener: () => {}
}
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
isOpen: () => false,
openWorkflows: [],
nodeLocatorIdToNodeExecutionId: () => null
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ canvas: undefined })
}))
vi.mock('@/stores/executionErrorStore', () => ({
useExecutionErrorStore: () => ({
clearExecutionStartErrors: () => {},
clearPromptError: () => {}
})
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => ({ revokePreviewsByExecutionId: () => {} })
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ mode: ref('default'), isAppMode: ref(false) })
}))
vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined }))
vi.mock('@/utils/appMode', () => ({
getWorkflowMode: () => 'workflow',
isAppModeValue: () => false
}))
function progressState(
jobId: string,
nodes: Record<string, Partial<NodeProgressState>>
) {
handlers['progress_state']?.({ detail: { prompt_id: jobId, nodes } })
}
function setup() {
const store = useExecutionStore()
store.bindExecutionEvents()
return store
}
beforeEach(() => {
setActivePinia(createPinia())
for (const key of Object.keys(handlers)) delete handlers[key]
})
describe('executionStore node progress', () => {
it('is idle until an execution starts', () => {
const store = setup()
expect(store.isIdle).toBe(true)
handlers['execution_start']?.({ detail: { prompt_id: 'job-1' } })
expect(store.isIdle).toBe(false)
})
it('derives the running node ids from a progress_state event', () => {
const store = setup()
progressState('job-1', {
n1: { state: 'running', value: 1, max: 4 },
n2: { state: 'finished' },
n3: { state: 'pending' }
})
expect(store.executingNodeIds).toEqual(['n1'])
expect(store.executingNodeId).toBe('n1')
})
it('exposes fractional progress for the executing node', () => {
const store = setup()
progressState('job-1', {
n1: { state: 'running', value: 1, max: 4 }
})
expect(store.executingNodeProgress).toBe(0.25)
})
it('reports no executing node when none are running', () => {
const store = setup()
progressState('job-1', {
n1: { state: 'finished' },
n2: { state: 'pending' }
})
expect(store.executingNodeIds).toEqual([])
expect(store.executingNodeId).toBeNull()
})
it('replaces progress state on each progress_state event', () => {
const store = setup()
progressState('job-1', { n1: { state: 'running', value: 1, max: 4 } })
expect(store.executingNodeId).toBe('n1')
progressState('job-1', { n2: { state: 'running', value: 2, max: 2 } })
expect(store.executingNodeIds).toEqual(['n2'])
})
})

View File

@@ -0,0 +1,173 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useExecutionStore } from '@/stores/executionStore'
import type { classifyCloudValidationError } from '@/utils/executionErrorUtil'
type CloudValidationResult = ReturnType<typeof classifyCloudValidationError>
const { handlers, errorStore, activeWorkflow, dist, classifyCloud } =
vi.hoisted(() => ({
handlers: {} as Record<string, (e: { detail: unknown }) => void>,
errorStore: {
clearExecutionStartErrors: () => {},
clearPromptError: () => {}
} as Record<string, unknown>,
activeWorkflow: { value: null as { path: string } | null },
dist: { isCloud: false },
classifyCloud: vi.fn<(_: string) => CloudValidationResult>(() => null)
}))
vi.mock('@/scripts/app', () => ({ app: { rootGraph: {} } }))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: (name: string, fn: (e: { detail: unknown }) => void) => {
handlers[name] = fn
},
removeEventListener: () => {}
}
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
isOpen: () => true,
openWorkflows: [],
nodeLocatorIdToNodeExecutionId: () => null,
get activeWorkflow() {
return activeWorkflow.value
}
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ canvas: undefined })
}))
vi.mock('@/stores/executionErrorStore', () => ({
useExecutionErrorStore: () => errorStore
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => ({ revokePreviewsByExecutionId: () => {} })
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ mode: ref('default'), isAppMode: ref(false) })
}))
vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined }))
vi.mock('@/utils/appMode', () => ({
getWorkflowMode: () => 'workflow',
isAppModeValue: () => false
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return dist.isCloud
}
}))
vi.mock('@/platform/errorCatalog/accountPreconditionRouting', () => ({
resolveAccountPrecondition: () => null
}))
vi.mock('@/utils/executionErrorUtil', () => ({
classifyCloudValidationError: classifyCloud
}))
function setup() {
const store = useExecutionStore()
store.bindExecutionEvents()
return store
}
function workflow(path: string): ComfyWorkflow {
return { path } as unknown as ComfyWorkflow
}
function promptOutput(): ComfyApiWorkflow {
return {}
}
beforeEach(() => {
setActivePinia(createPinia())
for (const key of Object.keys(handlers)) delete handlers[key]
activeWorkflow.value = null
dist.isCloud = false
classifyCloud.mockReturnValue(null)
for (const k of ['lastPromptError', 'lastNodeErrors', 'lastExecutionError'])
delete errorStore[k]
})
describe('executionStore running state and error edges', () => {
it('lists jobs with a running node and counts running workflows', () => {
const store = setup()
handlers['progress_state']?.({
detail: {
prompt_id: 'job-1',
nodes: { n1: { state: 'running', value: 1, max: 2 } }
}
})
expect(store.runningJobIds).toEqual(['job-1'])
expect(store.runningWorkflowCount).toBe(1)
})
it('does not report the active workflow as running when the path differs', () => {
const store = setup()
expect(store.isActiveWorkflowRunning).toBe(false)
const wf = workflow('w.json')
activeWorkflow.value = { path: 'other.json' }
store.storeJob({
nodes: [],
id: 'job-2',
promptOutput: promptOutput(),
workflow: wf
})
handlers['execution_start']?.({ detail: { prompt_id: 'job-2' } })
expect(store.isActiveWorkflowRunning).toBe(false)
})
it('reports the active workflow as running when job, path and session agree', () => {
const store = setup()
const wf = workflow('w.json')
activeWorkflow.value = { path: 'w.json' }
store.storeJob({
nodes: [],
id: 'job-2',
promptOutput: promptOutput(),
workflow: wf
})
handlers['execution_start']?.({ detail: { prompt_id: 'job-2' } })
expect(store.isActiveWorkflowRunning).toBe(true)
})
it('formats a service-level error message from the exception message alone', () => {
setup()
handlers['execution_error']?.({
detail: { prompt_id: 'job-3', exception_message: 'Job has stagnated' }
})
expect(errorStore.lastPromptError).toEqual({
type: 'error',
message: 'Job has stagnated',
details: ''
})
})
it('stores a classified cloud prompt error on the prompt-error branch', () => {
dist.isCloud = true
classifyCloud.mockReturnValue({
kind: 'promptError',
promptError: { type: 'validation', message: 'bad input', details: '' }
})
setup()
handlers['execution_error']?.({
detail: { prompt_id: 'job-4', exception_message: '{}' }
})
expect(errorStore.lastPromptError).toEqual({
type: 'validation',
message: 'bad input',
details: ''
})
})
})

View File

@@ -0,0 +1,153 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useExecutionStore } from '@/stores/executionStore'
const { handlers, openSet } = vi.hoisted(() => ({
handlers: {} as Record<string, (e: { detail: unknown }) => void>,
openSet: new Set<unknown>()
}))
vi.mock('@/scripts/app', () => ({ app: { rootGraph: {} } }))
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: (name: string, fn: (e: { detail: unknown }) => void) => {
handlers[name] = fn
},
removeEventListener: () => {}
}
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
isOpen: (workflow: unknown) => openSet.has(workflow),
openWorkflows: [],
nodeLocatorIdToNodeExecutionId: () => null
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ canvas: undefined })
}))
vi.mock('@/stores/executionErrorStore', () => ({
useExecutionErrorStore: () => ({
clearExecutionStartErrors: () => {},
clearPromptError: () => {}
})
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ mode: ref('default'), isAppMode: ref(false) })
}))
vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined }))
vi.mock('@/utils/appMode', () => ({
getWorkflowMode: () => 'workflow',
isAppModeValue: () => false
}))
function workflow(path: string): ComfyWorkflow {
return { path } as unknown as ComfyWorkflow
}
function promptOutput(): ComfyApiWorkflow {
return {}
}
function storeJob(
store: ReturnType<typeof useExecutionStore>,
id: string,
wf: ComfyWorkflow
) {
store.storeJob({ nodes: [], id, promptOutput: promptOutput(), workflow: wf })
}
function fire(event: string, jobId: string) {
handlers[event]?.({ detail: { prompt_id: jobId } })
}
function setup() {
const store = useExecutionStore()
store.bindExecutionEvents()
return store
}
beforeEach(() => {
setActivePinia(createPinia())
for (const key of Object.keys(handlers)) delete handlers[key]
openSet.clear()
})
describe('executionStore workflow status', () => {
it('marks an open workflow running on execution_start and completed on success', () => {
const store = setup()
const wf = workflow('a.json')
openSet.add(wf)
storeJob(store, 'job-1', wf)
fire('execution_start', 'job-1')
expect(store.getWorkflowStatus(wf)).toBe('running')
fire('execution_success', 'job-1')
expect(store.getWorkflowStatus(wf)).toBe('completed')
})
it('buffers a status that arrives before the job is attached, then flushes on storeJob', () => {
const store = setup()
const wf = workflow('b.json')
openSet.add(wf)
fire('execution_start', 'job-2')
expect(store.getWorkflowStatus(wf)).toBeUndefined()
storeJob(store, 'job-2', wf)
expect(store.getWorkflowStatus(wf)).toBe('running')
})
it('does not apply status to a workflow that is not open', () => {
const store = setup()
const wf = workflow('c.json')
storeJob(store, 'job-3', wf)
fire('execution_start', 'job-3')
expect(store.getWorkflowStatus(wf)).toBeUndefined()
})
it('clears a workflow status', () => {
const store = setup()
const wf = workflow('d.json')
openSet.add(wf)
storeJob(store, 'job-4', wf)
fire('execution_start', 'job-4')
expect(store.getWorkflowStatus(wf)).toBe('running')
store.clearWorkflowStatus(wf)
expect(store.getWorkflowStatus(wf)).toBeUndefined()
})
it('does not let a late buffered running overwrite a terminal status', () => {
const store = setup()
const wf = workflow('e.json')
openSet.add(wf)
storeJob(store, 'job-5', wf)
fire('execution_success', 'job-5')
expect(store.getWorkflowStatus(wf)).toBe('completed')
fire('execution_start', 'job-6')
storeJob(store, 'job-6', wf)
expect(store.getWorkflowStatus(wf)).toBe('completed')
})
it('returns undefined for a null or unknown workflow', () => {
const store = setup()
expect(store.getWorkflowStatus(null)).toBeUndefined()
expect(store.getWorkflowStatus(workflow('unknown.json'))).toBeUndefined()
})
})

View File

@@ -0,0 +1,139 @@
import { describe, expect, it, vi } from 'vitest'
import type { SerializedNodeId } from '@/types/nodeId'
import { ResultItemImpl } from '@/stores/queueStore'
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (path: string) => `http://localhost:8188${path}`,
addEventListener: () => {}
}
}))
// Importing ResultItemImpl transitively loads @/scripts/app, whose module-level
// ComfyApp singleton wires real listeners. Stub it; ResultItemImpl needs none of it.
vi.mock('@/scripts/app', () => ({ app: {} }))
// Keep preview-url assertions deterministic: don't append cloud params.
vi.mock('@/platform/distribution/cloudPreviewUtil', () => ({
appendCloudResParam: () => {}
}))
interface ItemOverrides {
filename?: string
mediaType?: string
format?: string
frame_rate?: number
}
function item(over: ItemOverrides = {}) {
return new ResultItemImpl({
filename: over.filename ?? 'out.png',
subfolder: 'sub',
type: 'output',
nodeId: '1' as SerializedNodeId,
mediaType: over.mediaType ?? 'images',
format: over.format,
frame_rate: over.frame_rate
})
}
describe('ResultItemImpl', () => {
it('builds view url params and omits absent vhs fields', () => {
const params = item({ filename: 'a.png' }).urlParams
expect(params.get('filename')).toBe('a.png')
expect(params.get('type')).toBe('output')
expect(params.get('subfolder')).toBe('sub')
expect(params.has('format')).toBe(false)
expect(params.has('frame_rate')).toBe(false)
})
it('includes vhs format and frame_rate params when present', () => {
const params = item({ format: 'video/h264-mp4', frame_rate: 24 }).urlParams
expect(params.get('format')).toBe('video/h264-mp4')
expect(params.get('frame_rate')).toBe('24')
})
it('returns an empty url for a nameless item and a view url otherwise', () => {
expect(item({ filename: '' }).url).toBe('')
expect(item({ filename: 'a.png' }).url).toContain('/view?')
})
it('routes image preview urls through /view', () => {
expect(
item({ filename: 'a.png', mediaType: 'images' }).previewUrl
).toContain('/view?')
})
it('exposes the vhs advanced preview endpoint', () => {
expect(item().vhsAdvancedPreviewUrl).toContain('/viewvideo?')
})
it('maps html video mime types by suffix and vhs format', () => {
expect(item({ filename: 'a.webm' }).htmlVideoType).toBe('video/webm')
expect(item({ filename: 'a.mp4' }).htmlVideoType).toBe('video/mp4')
expect(item({ filename: 'a.mov' }).htmlVideoType).toBe('video/quicktime')
expect(
item({ filename: 'a.bin', format: 'video/mp4', frame_rate: 24 })
.htmlVideoType
).toBe('video/mp4')
expect(item({ filename: 'a.txt' }).htmlVideoType).toBeUndefined()
})
it('maps html audio mime types by suffix', () => {
expect(item({ filename: 'a.mp3' }).htmlAudioType).toBe('audio/mpeg')
expect(item({ filename: 'a.wav' }).htmlAudioType).toBe('audio/wav')
expect(item({ filename: 'a.ogg' }).htmlAudioType).toBe('audio/ogg')
expect(item({ filename: 'a.flac' }).htmlAudioType).toBe('audio/flac')
expect(item({ filename: 'a.png' }).htmlAudioType).toBeUndefined()
})
it('treats vhs format as such only with both format and frame_rate', () => {
expect(item({ format: 'video/mp4', frame_rate: 24 }).isVhsFormat).toBe(true)
expect(item({ format: 'video/mp4' }).isVhsFormat).toBe(false)
})
it('classifies video by suffix and by media type', () => {
expect(item({ filename: 'a.webm' }).isVideo).toBe(true)
expect(item({ filename: 'a.bin', mediaType: 'video' }).isVideo).toBe(true)
expect(item({ filename: 'a.png', mediaType: 'video' }).isVideo).toBe(false)
})
it('classifies image only when not contradicted by a media suffix', () => {
expect(item({ filename: 'a.png', mediaType: 'images' }).isImage).toBe(true)
expect(item({ filename: 'a.webm', mediaType: 'images' }).isImage).toBe(
false
)
})
it('classifies audio by suffix and by media type', () => {
expect(item({ filename: 'a.mp3' }).isAudio).toBe(true)
expect(item({ filename: 'a.bin', mediaType: 'audio' }).isAudio).toBe(true)
expect(item({ filename: 'a.png', mediaType: 'audio' }).isAudio).toBe(false)
})
it('reports text and preview support', () => {
const text = item({ filename: 'a.txt', mediaType: 'text' })
expect(text.isText).toBe(true)
expect(text.supportsPreview).toBe(true)
expect(item({ filename: 'a.png' }).supportsPreview).toBe(true)
expect(
item({ filename: 'a.bin', mediaType: 'binary' }).supportsPreview
).toBe(false)
})
it('filters previewable outputs and finds an item by url', () => {
const png = item({ filename: 'a.png' })
const mp3 = item({ filename: 'b.mp3', mediaType: 'audio' })
const bin = item({ filename: 'a.bin', mediaType: 'binary' })
expect(ResultItemImpl.filterPreviewable([png, mp3, bin])).toEqual([
png,
mp3
])
expect(ResultItemImpl.findByUrl([png, mp3, bin], png.url)).toBe(0)
expect(ResultItemImpl.findByUrl([png, mp3, bin], mp3.url)).toBe(1)
expect(ResultItemImpl.findByUrl([png, mp3, bin], 'no-match')).toBe(0)
expect(ResultItemImpl.findByUrl([png, mp3, bin])).toBe(0)
})
})

View File

@@ -0,0 +1,210 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type { ResultItemType, TaskOutput } from '@/schemas/apiSchema'
import type { SerializedNodeId } from '@/types/nodeId'
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (path: string) => `http://localhost:8188${path}`,
addEventListener: () => {}
}
}))
vi.mock('@/scripts/app', () => ({ app: {} }))
vi.mock('@/platform/distribution/cloudPreviewUtil', () => ({
appendCloudResParam: () => {}
}))
const { parseTaskOutput } = vi.hoisted(() => ({ parseTaskOutput: vi.fn() }))
vi.mock('@/stores/resultItemParsing', () => ({ parseTaskOutput }))
beforeEach(() => {
parseTaskOutput.mockClear()
})
type JobStatus =
| 'in_progress'
| 'pending'
| 'completed'
| 'failed'
| 'cancelled'
function executionError(
overrides: Partial<NonNullable<JobListItem['execution_error']>> = {}
): NonNullable<JobListItem['execution_error']> {
return {
node_id: '1',
node_type: 'KSampler',
exception_message: 'boom',
exception_type: 'Error',
traceback: [],
current_inputs: {},
current_outputs: {},
...overrides
}
}
function job(over: Partial<JobListItem> = {}): JobListItem {
return {
id: 'job-1',
status: 'completed',
create_time: 1000,
priority: 0,
...over
}
}
function result(filename: string, type: ResultItemType = 'output') {
return new ResultItemImpl({
filename,
subfolder: '',
type,
nodeId: '1' as SerializedNodeId,
mediaType: 'images'
})
}
describe('TaskItemImpl', () => {
it('maps job status to taskType and apiTaskType', () => {
expect(new TaskItemImpl(job({ status: 'in_progress' })).taskType).toBe(
'Running'
)
expect(new TaskItemImpl(job({ status: 'pending' })).taskType).toBe(
'Pending'
)
expect(new TaskItemImpl(job({ status: 'completed' })).taskType).toBe(
'History'
)
expect(new TaskItemImpl(job({ status: 'pending' })).apiTaskType).toBe(
'queue'
)
expect(new TaskItemImpl(job({ status: 'completed' })).apiTaskType).toBe(
'history'
)
})
it('exposes displayStatus for every backend status', () => {
const statuses: [JobStatus, string][] = [
['in_progress', 'Running'],
['pending', 'Pending'],
['completed', 'Completed'],
['failed', 'Failed'],
['cancelled', 'Cancelled']
]
for (const [status, display] of statuses) {
expect(new TaskItemImpl(job({ status })).displayStatus).toBe(display)
}
})
it('derives history/running flags and a status-qualified key', () => {
const running = new TaskItemImpl(job({ id: 'a', status: 'in_progress' }))
expect(running.isRunning).toBe(true)
expect(running.isHistory).toBe(false)
expect(running.key).toBe('aRunning')
expect(new TaskItemImpl(job({ status: 'completed' })).isHistory).toBe(true)
})
it('uses explicitly provided flat outputs', () => {
const outputs = [result('a.png')]
const task = new TaskItemImpl(job(), undefined, outputs)
expect(task.flatOutputs).toBe(outputs)
})
it('parses outputs lazily when flat outputs are not supplied', () => {
const parsed = [result('p.png')]
parseTaskOutput.mockReturnValueOnce(parsed)
const outputs: TaskOutput = { '1': { images: [] } }
const task = new TaskItemImpl(job(), outputs)
expect(parseTaskOutput).toHaveBeenCalled()
expect(task.flatOutputs).toBe(parsed)
})
it('synthesizes outputs from preview_output when none are provided', () => {
parseTaskOutput.mockReturnValueOnce([])
const preview = { nodeId: '5', mediaType: 'images', filename: 'prev.png' }
new TaskItemImpl(job({ preview_output: preview }))
expect(parseTaskOutput).toHaveBeenCalledWith({
'5': { images: [preview] }
})
})
it('prefers the last saved output over temp previews for previewOutput', () => {
const temp = result('temp.png', 'temp')
const saved = result('saved.png', 'output')
const task = new TaskItemImpl(job(), undefined, [temp, saved])
expect(task.previewOutput).toBe(saved)
const onlyTemp = new TaskItemImpl(job(), undefined, [temp])
expect(onlyTemp.previewOutput).toBe(temp)
})
it('reports interrupted only for an interrupt-typed failure', () => {
expect(
new TaskItemImpl(
job({
status: 'failed',
execution_error: executionError({
exception_type: 'InterruptProcessingException'
})
})
).interrupted
).toBe(true)
expect(
new TaskItemImpl(
job({
status: 'failed',
execution_error: executionError({ exception_type: 'Other' })
})
).interrupted
).toBe(false)
})
it('surfaces error message and passthrough job fields', () => {
const task = new TaskItemImpl(
job({
status: 'failed',
outputs_count: 3,
workflow_id: 'wf-9',
execution_error: executionError({ exception_message: 'boom' })
})
)
expect(task.errorMessage).toBe('boom')
expect(task.outputsCount).toBe(3)
expect(task.workflowId).toBe('wf-9')
})
it('computes execution time only when both timestamps exist', () => {
expect(
new TaskItemImpl(
job({ execution_start_time: 1000, execution_end_time: 3000 })
).executionTimeInSeconds
).toBe(2)
expect(
new TaskItemImpl(job({ execution_start_time: 1000 })).executionTime
).toBeUndefined()
})
it('flatten returns itself when not completed', () => {
const running = new TaskItemImpl(job({ status: 'in_progress' }))
expect(running.flatten()).toEqual([running])
})
it('flatten expands a completed task into one task per output', () => {
const outputs = [result('a.png'), result('b.png')]
const task = new TaskItemImpl(
job({ id: 'j', status: 'completed' }),
undefined,
outputs
)
const flattened = task.flatten()
expect(flattened).toHaveLength(2)
expect(flattened.map((t) => t.jobId)).toEqual(['j-0', 'j-1'])
})
})

View File

@@ -1,4 +1,4 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { describe, expect, it } from 'vitest'
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
@@ -154,6 +154,22 @@ describe(parseNodeOutput, () => {
expect(result).toHaveLength(1)
expect(result[0].filename).toBe('valid.png')
})
it('excludes non-object and invalid-type items', () => {
const output = fromAny<NodeExecutionOutput, unknown>({
images: [
null,
'not-an-item',
{ filename: 'bad.png', type: 'invalid' },
{ filename: 'valid.png', type: 'output' }
]
})
const result = parseNodeOutput('1', output)
expect(result).toHaveLength(1)
expect(result[0].filename).toBe('valid.png')
})
})
describe(parseTaskOutput, () => {

View File

@@ -141,7 +141,7 @@ describe('useWidgetValueStore', () => {
expect(registered?.value).toBe(100)
})
it('getNodeWidgets returns widgets in registration order', () => {
it('getNodeWidgets returns all widgets for a node', () => {
const store = useWidgetValueStore()
store.registerWidget(
widgetId(graphA, toNodeId('node-1'), 'seed'),
@@ -157,57 +157,8 @@ describe('useWidgetValueStore', () => {
)
const widgets = store.getNodeWidgets(graphA, toNodeId('node-1'))
expect(widgets.map((w) => w.name)).toEqual(['seed', 'steps'])
})
it('getNodeWidgetIds returns the explicit node widget order', () => {
const store = useWidgetValueStore()
const seed = widgetId(graphA, toNodeId('node-1'), 'seed')
const steps = widgetId(graphA, toNodeId('node-1'), 'steps')
const cfg = widgetId(graphA, toNodeId('node-1'), 'cfg')
store.registerWidget(seed, state('number', 1))
store.registerWidget(steps, state('number', 20))
store.registerWidget(cfg, state('number', 7))
store.setNodeWidgetOrder(graphA, toNodeId('node-1'), [cfg, seed])
expect(store.getNodeWidgetIds(graphA, toNodeId('node-1'))).toEqual([
cfg,
seed,
steps
])
expect(
store.getNodeWidgets(graphA, toNodeId('node-1')).map((w) => w.name)
).toEqual(['cfg', 'seed', 'steps'])
})
it('ignores widget IDs from other nodes when setting order', () => {
const store = useWidgetValueStore()
const seed = widgetId(graphA, toNodeId('node-1'), 'seed')
const other = widgetId(graphA, toNodeId('node-2'), 'cfg')
store.registerWidget(seed, state('number', 1))
store.registerWidget(other, state('number', 7))
store.setNodeWidgetOrder(graphA, toNodeId('node-1'), [other, seed])
expect(store.getNodeWidgetIds(graphA, toNodeId('node-1'))).toEqual([seed])
})
it('replaceNodeWidgetOrder prunes widgets missing from the live order', () => {
const store = useWidgetValueStore()
const seed = widgetId(graphA, toNodeId('node-1'), 'seed')
const steps = widgetId(graphA, toNodeId('node-1'), 'steps')
const cfg = widgetId(graphA, toNodeId('node-1'), 'cfg')
store.registerWidget(seed, state('number', 1))
store.registerWidget(steps, state('number', 20))
store.registerWidget(cfg, state('number', 7))
store.replaceNodeWidgetOrder(graphA, toNodeId('node-1'), [cfg, seed])
expect(store.getWidget(steps)).toBeUndefined()
expect(store.getNodeWidgetIds(graphA, toNodeId('node-1'))).toEqual([
cfg,
seed
])
expect(widgets).toHaveLength(2)
expect(widgets.map((w) => w.name).sort()).toEqual(['seed', 'steps'])
})
})
@@ -223,17 +174,12 @@ describe('useWidgetValueStore', () => {
).toBe(false)
})
it('deleteWidget removes registered widgets from node order', () => {
it('deleteWidget removes registered widgets', () => {
const store = useWidgetValueStore()
const steps = widgetId(graphA, toNodeId('node-1'), 'steps')
store.registerWidget(seedA, state('number', 100))
store.registerWidget(steps, state('number', 20))
expect(store.deleteWidget(seedA)).toBe(true)
expect(store.getWidget(seedA)).toBeUndefined()
expect(store.getNodeWidgetIds(graphA, toNodeId('node-1'))).toEqual([
steps
])
expect(store.deleteWidget(seedA)).toBe(false)
})
})

View File

@@ -2,35 +2,18 @@ import { defineStore } from 'pinia'
import { reactive, ref } from 'vue'
import type { UUID } from '@/utils/uuid'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { parseNodeId } from '@/types/nodeId'
import type { NodeId, SerializedNodeId } from '@/types/nodeId'
import { parseWidgetId } from '@/types/widgetId'
import type { WidgetId } from '@/types/widgetId'
import type { WidgetState, WidgetStateInit } from '@/types/widgetState'
export interface WidgetRenderState {
advanced?: boolean
hasLayoutSize?: boolean
isDOMWidget?: boolean
tooltip?: string
}
export interface WidgetSpec {
spec: InputSpecV2
}
export function stripGraphPrefix(scopedId: SerializedNodeId): NodeId | null {
return parseNodeId(String(scopedId).replace(/^(.*:)+/, ''))
}
export const useWidgetValueStore = defineStore('widgetValue', () => {
const graphWidgetStates = ref(new Map<UUID, Map<WidgetId, WidgetState>>())
const graphWidgetRenderStates = ref(
new Map<UUID, Map<WidgetId, WidgetRenderState>>()
)
const graphWidgetSpecs = ref(new Map<UUID, Map<WidgetId, WidgetSpec>>())
const graphNodeWidgetOrders = ref(new Map<UUID, Map<NodeId, WidgetId[]>>())
function getGraphWidgetStates(graphId: UUID): Map<WidgetId, WidgetState> {
const widgetStates = graphWidgetStates.value.get(graphId)
@@ -41,73 +24,12 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
return nextWidgetStates
}
function getGraphWidgetRenderStates(
graphId: UUID
): Map<WidgetId, WidgetRenderState> {
const widgetRenderStates = graphWidgetRenderStates.value.get(graphId)
if (widgetRenderStates) return widgetRenderStates
const nextWidgetRenderStates = reactive(
new Map<WidgetId, WidgetRenderState>()
)
graphWidgetRenderStates.value.set(graphId, nextWidgetRenderStates)
return nextWidgetRenderStates
}
function getGraphWidgetSpecs(graphId: UUID): Map<WidgetId, WidgetSpec> {
const widgetSpecs = graphWidgetSpecs.value.get(graphId)
if (widgetSpecs) return widgetSpecs
const nextWidgetSpecs = reactive(new Map<WidgetId, WidgetSpec>())
graphWidgetSpecs.value.set(graphId, nextWidgetSpecs)
return nextWidgetSpecs
}
function getGraphNodeWidgetOrders(graphId: UUID): Map<NodeId, WidgetId[]> {
const widgetOrders = graphNodeWidgetOrders.value.get(graphId)
if (widgetOrders) return widgetOrders
const nextWidgetOrders = reactive(new Map<NodeId, WidgetId[]>())
graphNodeWidgetOrders.value.set(graphId, nextWidgetOrders)
return nextWidgetOrders
}
function getNodeWidgetOrder(graphId: UUID, nodeId: NodeId): WidgetId[] {
const graphOrders = getGraphNodeWidgetOrders(graphId)
const order = graphOrders.get(nodeId)
if (order) return order
const nextOrder = reactive<WidgetId[]>([])
graphOrders.set(nodeId, nextOrder)
return nextOrder
}
function appendNodeWidgetOrder(widgetId: WidgetId): void {
const { graphId, nodeId } = parseWidgetId(widgetId)
const order = getNodeWidgetOrder(graphId, nodeId)
if (!order.includes(widgetId)) order.push(widgetId)
}
function removeNodeWidgetOrder(widgetId: WidgetId): void {
const { graphId, nodeId } = parseWidgetId(widgetId)
const graphOrders = getGraphNodeWidgetOrders(graphId)
const order = graphOrders.get(nodeId)
if (!order) return
const index = order.indexOf(widgetId)
if (index !== -1) order.splice(index, 1)
if (order.length === 0) graphOrders.delete(nodeId)
}
function registerWidget<TValue = unknown>(
widgetId: WidgetId,
init: WidgetStateInit<TValue>
): WidgetState<TValue> {
const existing = getWidget(widgetId)
if (existing) {
appendNodeWidgetOrder(widgetId)
return existing as WidgetState<TValue>
}
if (existing) return existing as WidgetState<TValue>
const { graphId, nodeId, name } = parseWidgetId(widgetId)
const state: WidgetState<TValue> = {
@@ -118,61 +40,14 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
}
const widgetStates = getGraphWidgetStates(graphId)
widgetStates.set(widgetId, state)
appendNodeWidgetOrder(widgetId)
return widgetStates.get(widgetId) as WidgetState<TValue>
}
function registerWidgetRenderState(
widgetId: WidgetId,
init: WidgetRenderState
): WidgetRenderState {
const { graphId } = parseWidgetId(widgetId)
const widgetRenderStates = getGraphWidgetRenderStates(graphId)
const existing = widgetRenderStates.get(widgetId)
if (existing) {
Object.assign(existing, init)
return existing
}
const state: WidgetRenderState = { ...init }
widgetRenderStates.set(widgetId, state)
return widgetRenderStates.get(widgetId) as WidgetRenderState
}
function getWidget(widgetId: WidgetId): WidgetState | undefined {
const { graphId } = parseWidgetId(widgetId)
return getGraphWidgetStates(graphId).get(widgetId)
}
function registerWidgetSpec(
widgetId: WidgetId,
spec: InputSpecV2
): WidgetSpec {
const { graphId } = parseWidgetId(widgetId)
const widgetSpecs = getGraphWidgetSpecs(graphId)
const existing = widgetSpecs.get(widgetId)
if (existing) {
existing.spec = spec
return existing
}
const component: WidgetSpec = { spec }
widgetSpecs.set(widgetId, component)
return widgetSpecs.get(widgetId) as WidgetSpec
}
function getWidgetSpec(widgetId: WidgetId): WidgetSpec | undefined {
const { graphId } = parseWidgetId(widgetId)
return getGraphWidgetSpecs(graphId).get(widgetId)
}
function getWidgetRenderState(
widgetId: WidgetId
): WidgetRenderState | undefined {
const { graphId } = parseWidgetId(widgetId)
return getGraphWidgetRenderStates(graphId).get(widgetId)
}
function setValue(widgetId: WidgetId, value: WidgetState['value']): boolean {
const state = getWidget(widgetId)
if (!state) return false
@@ -182,125 +57,25 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
function deleteWidget(widgetId: WidgetId): boolean {
const { graphId } = parseWidgetId(widgetId)
getGraphWidgetRenderStates(graphId).delete(widgetId)
getGraphWidgetSpecs(graphId).delete(widgetId)
removeNodeWidgetOrder(widgetId)
return getGraphWidgetStates(graphId).delete(widgetId)
}
function getNodeWidgets(graphId: UUID, localNodeId: NodeId): WidgetState[] {
return getNodeWidgetIds(graphId, localNodeId).flatMap((id) => {
const state = getWidget(id)
return state ? [state] : []
})
}
function getRegisteredNodeWidgetIds(
graphId: UUID,
localNodeId: NodeId
): WidgetId[] {
const widgetStates = getGraphWidgetStates(graphId)
return [...widgetStates.entries()]
.filter(([, state]) => state.nodeId === localNodeId)
.map(([id]) => id)
}
function getOrderedRegisteredNodeWidgetIds(
registeredIds: readonly WidgetId[],
orderedWidgetIds: readonly WidgetId[]
): WidgetId[] {
const registeredIdSet = new Set(registeredIds)
return orderedWidgetIds.filter((id) => registeredIdSet.has(id))
}
function getRegisteredNodeWidgetOrder(
graphId: UUID,
localNodeId: NodeId,
orderedWidgetIds: readonly WidgetId[]
): WidgetId[] {
const registeredIds = getRegisteredNodeWidgetIds(graphId, localNodeId)
const orderedIds = getOrderedRegisteredNodeWidgetIds(
registeredIds,
orderedWidgetIds
return [...getGraphWidgetStates(graphId).values()].filter(
(state) => state.nodeId === localNodeId
)
const orderedIdSet = new Set(orderedIds)
return [
...orderedIds,
...registeredIds.filter((id) => !orderedIdSet.has(id))
]
}
function getNodeWidgetIds(graphId: UUID, localNodeId: NodeId): WidgetId[] {
const order = getNodeWidgetOrder(graphId, localNodeId)
const nextOrder = getRegisteredNodeWidgetOrder(graphId, localNodeId, order)
if (
nextOrder.length !== order.length ||
nextOrder.some((id, index) => id !== order[index])
) {
order.splice(0, order.length, ...nextOrder)
}
return [...order]
}
function setNodeWidgetOrder(
graphId: UUID,
localNodeId: NodeId,
orderedWidgetIds: readonly WidgetId[]
): void {
const nextOrder = getRegisteredNodeWidgetOrder(
graphId,
localNodeId,
orderedWidgetIds
)
const order = getNodeWidgetOrder(graphId, localNodeId)
order.splice(0, order.length, ...nextOrder)
}
function replaceNodeWidgetOrder(
graphId: UUID,
localNodeId: NodeId,
orderedWidgetIds: readonly WidgetId[]
): void {
const widgetStates = getGraphWidgetStates(graphId)
const registeredIds = getRegisteredNodeWidgetIds(graphId, localNodeId)
const nextOrder = getOrderedRegisteredNodeWidgetIds(
registeredIds,
orderedWidgetIds
)
const nextOrderSet = new Set(nextOrder)
for (const [id, state] of widgetStates.entries()) {
if (state.nodeId !== localNodeId || nextOrderSet.has(id)) continue
widgetStates.delete(id)
getGraphWidgetRenderStates(graphId).delete(id)
getGraphWidgetSpecs(graphId).delete(id)
}
const order = getNodeWidgetOrder(graphId, localNodeId)
order.splice(0, order.length, ...nextOrder)
}
function clearGraph(graphId: UUID): void {
graphWidgetStates.value.delete(graphId)
graphWidgetRenderStates.value.delete(graphId)
graphWidgetSpecs.value.delete(graphId)
graphNodeWidgetOrders.value.delete(graphId)
}
return {
registerWidget,
registerWidgetRenderState,
registerWidgetSpec,
getWidget,
getWidgetRenderState,
getWidgetSpec,
setValue,
deleteWidget,
getNodeWidgets,
getNodeWidgetIds,
setNodeWidgetOrder,
replaceNodeWidgetOrder,
clearGraph
}
})

View File

@@ -3,11 +3,7 @@
* Removes all DOM manipulation and positioning concerns
*/
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import { IS_CONTROL_WIDGET } from '@/scripts/controlWidgetMarker'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import type { NodeId } from '@/types/nodeId'
import type { NodeLocatorId } from '@/types/nodeIdentification'
@@ -34,7 +30,7 @@ function isControlOption(val: WidgetValue): val is ControlOptions {
return CONTROL_OPTIONS.includes(val as ControlOptions)
}
function normalizeControlOption(val: WidgetValue): ControlOptions {
export function normalizeControlOption(val: WidgetValue): ControlOptions {
if (isControlOption(val)) return val
return 'randomize'
}
@@ -44,17 +40,6 @@ export type SafeControlWidget = {
update: (value: WidgetValue) => void
}
export function getControlWidget(
widget: IBaseWidget
): SafeControlWidget | undefined {
const controlWidget = widget.linkedWidgets?.find((w) => w[IS_CONTROL_WIDGET])
if (!controlWidget) return
return {
value: normalizeControlOption(controlWidget.value),
update: (value) => (controlWidget.value = normalizeControlOption(value))
}
}
export interface LinkedUpstreamInfo {
nodeId: NodeId
outputName?: string

View File

@@ -19,6 +19,7 @@ export interface WidgetState<
| 'disabled'
| 'y'
> {
isDOMWidget?: boolean
nodeId: NodeId
}

View File

@@ -1,16 +1,10 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import { LGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { applyTextReplacements } from '@/utils/searchAndReplace'
describe('applyTextReplacements', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
// Test specifically the filename sanitization part
describe('filename sanitization', () => {
it('should replace invalid filename characters with underscores', () => {

View File

@@ -9,6 +9,7 @@ import { computed, useTemplateRef } from 'vue'
import AppBuilder from '@/components/builder/AppBuilder.vue'
import AppModeToolbar from '@/components/appMode/AppModeToolbar.vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import ErrorOverlay from '@/components/error/ErrorOverlay.vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
@@ -164,6 +165,7 @@ function dragDrop(e: DragEvent) {
</div>
<div ref="bottomLeftRef" class="absolute bottom-7 left-4 z-20" />
<div ref="bottomRightRef" class="absolute right-4 bottom-7 z-20" />
<div class="absolute top-4 right-4 z-20"><ErrorOverlay app-mode /></div>
</SplitterPanel>
<SplitterPanel
v-if="hasRightPanel"

View File

@@ -4,7 +4,7 @@ import { app } from '../../scripts/app.js'
function legacyWidget(node, inputName, inputData) {
if (!node.widgets) node.widgets = []
const widget = {
node.widgets.push({
draw: function (ctx, node, widget_width, y, H) {
ctx.save()
ctx.fillStyle = '#7F7'
@@ -24,9 +24,7 @@ function legacyWidget(node, inputName, inputData) {
type: 'DEVTOOLS.LEGACYWIDGET',
value: 0,
y: 0
}
node.widgets.push(widget)
return { widget }
})
}
app.registerExtension({