Compare commits

...

25 Commits

Author SHA1 Message Date
Alexander Brown
f8a8ef1a51 Merge branch 'main' into glary/fix-fe-800-color-widget-default 2026-06-26 18:25:01 -07:00
Alexander Brown
0610afa851 Merge branch 'main' into glary/fix-fe-800-color-widget-default 2026-06-26 13:27:12 -07:00
Alexander Brown
4999e3586b Merge branch 'main' into glary/fix-fe-800-color-widget-default 2026-06-23 21:04:12 -07:00
Alexander Brown
f2c8be3233 Merge branch 'main' into glary/fix-fe-800-color-widget-default 2026-06-18 21:40:23 -07:00
Alexander Brown
6a98abe3b5 Merge branch 'main' into glary/fix-fe-800-color-widget-default 2026-06-18 18:13:04 -07:00
DrJKL
90e2cda856 test(widgets): cover chart/galleria/textarea/color widget composables
Amp-Thread-ID: https://ampcode.com/threads/T-019edc07-5162-768d-9a20-407beaffdd02
Co-authored-by: Amp <amp@ampcode.com>
2026-06-18 17:26:42 -07:00
DrJKL
784737f691 fix(widgets): map V1 CHART type to chartType; dedupe ChartWidgetOptions
Amp-Thread-ID: https://ampcode.com/threads/T-019ed885-29ee-7721-9811-8d62437e3d34
Co-authored-by: Amp <amp@ampcode.com>
2026-06-17 20:13:57 -07:00
Alexander Brown
b9fa13b895 Merge branch 'main' into glary/fix-fe-800-color-widget-default 2026-06-17 19:16:41 -07:00
Alexander Brown
08611550d1 Merge branch 'main' into glary/fix-fe-800-color-widget-default 2026-06-17 17:28:55 -07:00
DrJKL
6a02ee339b refactor(widgets): address review nits on color/chart widgets
Replace the single-key options spread in useChartWidget with an extracted
widgetOptions const, and drop the redundant serialize-flag test in
useColorWidget.

Amp-Thread-ID: https://ampcode.com/threads/T-019ed2bb-ab3c-761e-a6be-54b78b724fc4
Co-authored-by: Amp <amp@ampcode.com>
2026-06-16 17:03:01 -07:00
Alexander Brown
cbbcadad11 Merge branch 'main' into glary/fix-fe-800-color-widget-default 2026-06-16 16:19:06 -07:00
Alexander Brown
1a428e0a0e Merge branch 'main' into glary/fix-fe-800-color-widget-default 2026-06-11 12:51:24 -07:00
Alexander Brown
460f64f950 Merge branch 'main' into glary/fix-fe-800-color-widget-default 2026-06-02 16:11:12 -07:00
Alexander Brown
36c5fcf028 Merge branch 'main' into glary/fix-fe-800-color-widget-default 2026-05-27 13:18:17 -07:00
Alexander Brown
6af84bddf9 Merge branch 'main' into glary/fix-fe-800-color-widget-default 2026-05-26 20:47:17 -07:00
Alexander Brown
1562ddbe0a Merge branch 'main' into glary/fix-fe-800-color-widget-default 2026-05-25 18:35:29 -07:00
GitHub Action
ae41efb202 [automated] Apply ESLint and Oxfmt fixes 2026-05-25 20:25:34 +00:00
DrJKL
755daab02b Merge remote-tracking branch 'origin/main' into glary/fix-fe-800-color-widget-default
Amp-Thread-ID: https://ampcode.com/threads/T-019e60c5-1442-712e-81bc-15619aafc516
Co-authored-by: Amp <amp@ampcode.com>

# Conflicts:
#	src/renderer/extensions/vueNodes/widgets/composables/useColorWidget.test.ts
#	src/renderer/extensions/vueNodes/widgets/composables/useColorWidget.ts
2026-05-25 13:21:44 -07:00
DrJKL
11386a768d test: use searchBoxV2 to add COLOR widget node in e2e
Fixes timeout on V1 searchbox selector in vueNodes test environment.

Amp-Thread-ID: https://ampcode.com/threads/T-019e60bb-0426-701e-a24f-49d4ff528f3b
Co-authored-by: Amp <amp@ampcode.com>
2026-05-25 13:11:26 -07:00
DrJKL
b6111cd25f refactor(schema): flatten CHART/GALLERIA V2 specs + add input type guards
Extend the FE-800 flattening (COLOR/TEXTAREA/MARKDOWN) to the two
remaining widgets that carried a nested `options` bag, and add the
missing type guards so every widget composable narrows through a
discriminated check instead of a bare type assertion.

- nodeDefSchemaV2:
  - zChartInputSpec: hoist nested options.{type,data} to top-level
    `chartType` + `data` (rename avoids collision with the discriminator
    `type: 'CHART'`).
  - zGalleriaInputSpec: hoist nested options.images to top-level
    `images`.
  - Add `isColorInputSpec`, `isTextareaInputSpec`, `isGalleriaInputSpec`
    — matches the existing INT/FLOAT/BOOLEAN/STRING/COMBO/CHART guards.
  - Drop unused public exports for ChartInputSpec/GalleriaInputSpec/
    TextareaInputSpec; they are now only referenced by their own
    guards, matching the IntInputSpec/FloatInputSpec pattern.
- useColorWidget, useTextareaWidget, useGalleriaWidget, useChartWidget:
  gate on the type guard up front instead of casting `as XInputSpec`;
  read all spec fields at the top level.
- WidgetChart: replace `NonNullable<ChartInputSpec['options']>` type
  derivation with an explicit local ChartWidgetOptions; the schema no
  longer surfaces an `options` field.

Amp-Thread-ID: https://ampcode.com/threads/T-019e5168-0c71-7667-b1df-683cd9f8acf7
Co-authored-by: Amp <amp@ampcode.com>
2026-05-22 14:58:35 -07:00
Glary-Bot
604656fb28 refactor(schema): flatten V2 input spec defaults to top-level
The V1 to V2 input spec migration spreads input options at the top level
of the spec, but the V2 schema declared default (COLOR), default+rows+cols
(TEXTAREA), and content (MARKDOWN) nested under a separate `options`
object. Two shapes, one wire format — silent fallback bugs like FE-800
were the inevitable consequence.

Canonicalize on the top-level shape that the migration already produces:

- nodeDefSchemaV2: hoist default to top level for COLOR/MARKDOWN; hoist
  rows/cols/default to top level for TEXTAREA. Drop the now-empty nested
  options objects on those three. Leave CHART/GALLERIA/IMAGE/IMAGECOMPARE
  alone — they are V2-native (no V1 migration path) and their nested
  options bag carries through to the runtime widget's options.
- useColorWidget: single top-level read, no fallback chain.
- useTextareaWidget: read rows/cols/default at the top level.
- useColorWidget.test: drop the V1-vs-V2-shape parity cases; the schema
  now has one shape, so the tests pin the single contract.
2026-05-22 04:04:50 +00:00
Glary-Bot
86b371c273 test: distinguish persisted color from declared default
The workflow asset previously persisted the same value (#00ff00) as the
declared default, so the restore-from-workflow test would pass even if
load regressed and the widget silently fell back to the default. Use a
distinct persisted value (#ff00ff) so the test actually exercises
deserialization.

Tighten the AGENTS.md DevTools guidance to recommend the searchBox-based
add flow over programmatic nodeOps.addNode for tests where adding is
itself the behaviour under test.
2026-05-22 04:00:06 +00:00
Glary-Bot
b8d9d3ebd6 test: address review feedback on COLOR widget tests
- Drop shoehorn in favour of new LGraphNode() + vi.spyOn(), matching the
  pattern already used by useComboWidget.test.ts. No more 'as unknown as'.
- Extract hex literals to named constants.
- Add coverage for serialization, custom input name, and the no-default
  case (asserts widget still produces a usable color widget rather than
  pinning the specific fallback colour).
- Split the e2e into two scenarios per review: one adds the node via the
  search box (real user flow), one loads a workflow asset that persists
  the colour value. Both assert against the user-visible hex string on
  the ColorPicker trigger button instead of the internal widget value.
- Note: the FE-800 ticket reference in the describe block was a holdover
  from the synthetic-node draft; removed.
- AGENTS.md addendum documenting the devtools-node pattern for
  Python-side test fixtures.
2026-05-22 04:00:06 +00:00
Glary-Bot
4b16fc03c2 test: use DevToolsNodeWithColorInput in COLOR widget e2e
Replace the in-test /object_info interception with a real devtools node
that declares a non-black COLOR default, matching how legacy widget,
remote widget, and other widget e2e tests already exercise their
features.

Also drop the explanatory comment in useColorWidget.ts per review
feedback; the fallback chain is self-evident at the point of use.
2026-05-22 04:00:06 +00:00
Glary-Bot
bf66b8f157 fix(widgets): respect default value on COLOR widget
The V1 to V2 input spec migration spreads input options at the top level
of the spec, so a backend-declared default lands on inputSpec.default,
not inputSpec.options.default. useColorWidget only read the nested path
and silently fell back to #000000, causing every COLOR widget to render
black regardless of the declared default.

Read inputSpec.default first (matching every other widget composable),
falling back to options.default for V2 specs that nest the default.

Fixes FE-800
2026-05-22 04:00:05 +00:00
20 changed files with 523 additions and 138 deletions

View File

@@ -121,6 +121,16 @@ Reserve `toPass()` for blocks with multiple assertions or complex async logic th
| Context menu empty or wrong items | Node not selected | Select node first: `vueNodes.selectNode()` or `nodeRef.click('title')` |
| `navigateIntoSubgraph` timeout | Node too small in test asset JSON | Use node size `[400, 200]` minimum |
## Backend Test Nodes (DevTools)
When a test needs a node, input shape, or backend behavior that no production node exposes, prefer **adding a node to `tools/devtools/nodes/`** over intercepting `/object_info` from the test.
- Add the new class to the appropriate file (e.g. `inputs.py` for input-shape variants), register it in that file's `NODE_CLASS_MAPPINGS` / `NODE_DISPLAY_NAME_MAPPINGS` with a `DevTools` prefix, and re-export it through `nodes/__init__.py` and `dev_nodes.py`.
- In the spec, prefer real user actions for adding the node: `await comfyPage.searchBoxV2.addNode('Node Display Name')` (see `vueNodes/widgets/color/colorWidget.spec.ts`). Reach for `comfyPage.nodeOps.addNode(...)` only when the test is not specifically about the add flow and a programmatic shortcut keeps the test focused (e.g. `vueNodes/widgets/legacy.spec.ts`).
- Reserve `page.route(/\/object_info$/, ...)` for cases where the test needs to mutate an existing production node's shape mid-flight (e.g. `propertiesPanel/errorsTabMissingModels.spec.ts`).
Devtools nodes are loaded automatically into the test ComfyUI instance, so no fixture wiring is required.
## After Making Changes
- Run `pnpm typecheck:browser` after modifying TypeScript files in this directory

View File

@@ -0,0 +1,28 @@
{
"id": "c0107e57-c01047-c01047-c01047-c0101010c010",
"revision": 0,
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "DevToolsNodeWithColorInput",
"pos": [400, 300],
"size": [270, 58],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"Node name for S&R": "DevToolsNodeWithColorInput"
},
"widgets_values": ["#ff00ff"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {},
"version": 0.4
}

View File

@@ -0,0 +1,36 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
const COLOR_NODE_DISPLAY_NAME = 'Node With Color Input'
const DECLARED_DEFAULT = '#00ff00'
const PERSISTED_VALUE = '#ff00ff'
test.describe('Vue Color Widget defaults', { tag: '@vue-nodes' }, () => {
test('respects the declared default value in the input spec', async ({
comfyPage
}) => {
await comfyPage.searchBoxV2.addNode(COLOR_NODE_DISPLAY_NAME)
const node = comfyPage.vueNodes.getNodeByTitle(COLOR_NODE_DISPLAY_NAME)
const colorTrigger = node.getByRole('button', {
name: new RegExp(DECLARED_DEFAULT, 'i')
})
await expect(colorTrigger).toBeVisible()
})
test('restores a saved color value when loading a workflow', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('vueNodes/color-widget-default')
const node = comfyPage.vueNodes.getNodeByTitle(COLOR_NODE_DISPLAY_NAME)
const colorTrigger = node.getByRole('button', {
name: new RegExp(PERSISTED_VALUE, 'i')
})
await expect(colorTrigger).toBeVisible()
})
})

View File

@@ -4,16 +4,12 @@ import { describe, expect, it } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import type { ChartInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type { ChartWidgetOptions } from './WidgetChart.types'
import WidgetChart from './WidgetChart.vue'
import { createMockWidget } from './widgetTestUtils'
type ChartWidgetOptions = NonNullable<ChartInputSpec['options']> &
IWidgetOptions
const i18n = createI18n({
legacy: false,
locale: 'en',

View File

@@ -0,0 +1,6 @@
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import type { ChartInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
export type ChartWidgetOptions = IWidgetOptions & {
type?: ChartInputSpec['chartType']
}

View File

@@ -16,12 +16,9 @@ import type { ChartData } from 'chart.js'
import Chart from 'primevue/chart'
import { computed } from 'vue'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import type { ChartInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
type ChartWidgetOptions = NonNullable<ChartInputSpec['options']> &
IWidgetOptions
import type { ChartWidgetOptions } from './WidgetChart.types'
const value = defineModel<ChartData>({ required: true })

View File

@@ -0,0 +1,82 @@
import { describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useChartWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useChartWidget'
import type {
ChartInputSpec,
InputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
function createMockNode(): {
node: LGraphNode
addWidget: ReturnType<typeof vi.spyOn>
} {
const node = new LGraphNode('TestChartNode')
const addWidget = vi
.spyOn(node, 'addWidget')
.mockImplementation((type, name, value, _callback, options) => {
const widget = {
type,
name,
value,
options: typeof options === 'string' ? { property: options } : options,
y: 0
} as IBaseWidget
node.widgets ??= []
node.widgets.push(widget)
return widget
})
return { node, addWidget }
}
function createChartSpec(
overrides: Partial<ChartInputSpec> = {}
): ChartInputSpec {
return { type: 'CHART', name: 'chart', ...overrides }
}
describe('useChartWidget', () => {
it('uses the declared chartType and data from the input spec', () => {
const { node, addWidget } = createMockNode()
const data = { series: [1, 2, 3] }
const widget = useChartWidget()(
node,
createChartSpec({ chartType: 'bar', data })
)
expect(addWidget).toHaveBeenCalledWith(
'chart',
'chart',
data,
expect.any(Function),
{ serialize: true, type: 'bar' }
)
expect(widget.value).toBe(data)
})
it('defaults to a line chart with empty data', () => {
const { node, addWidget } = createMockNode()
const widget = useChartWidget()(node, createChartSpec())
expect(addWidget).toHaveBeenCalledWith(
'chart',
'chart',
{},
expect.any(Function),
{ serialize: true, type: 'line' }
)
expect(widget.type).toBe('chart')
})
it('throws when the input spec is not a chart spec', () => {
const { node } = createMockNode()
const inputSpec = { type: 'STRING', name: 'chart' } as unknown as InputSpec
expect(() => useChartWidget()(node, inputSpec)).toThrow(
'Invalid input spec for chart widget'
)
})
})

View File

@@ -1,10 +1,7 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IChartWidget } from '@/lib/litegraph/src/types/widgets'
import { isChartInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type {
ChartInputSpec,
InputSpec as InputSpecV2
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
export const useChartWidget = (): ComfyWidgetConstructorV2 => {
@@ -13,15 +10,17 @@ export const useChartWidget = (): ComfyWidgetConstructorV2 => {
throw new Error('Invalid input spec for chart widget')
}
const { name, options = {} } = inputSpec as ChartInputSpec
const { name, chartType = 'line', data = {} } = inputSpec
const chartType = options.type || 'line'
const widgetOptions = { serialize: true, type: chartType }
const widget = node.addWidget('chart', name, options.data || {}, () => {}, {
serialize: true,
type: chartType,
...options
}) as IChartWidget
const widget = node.addWidget(
'chart',
name,
data,
() => {},
widgetOptions
) as IChartWidget
return widget
}

View File

@@ -1,76 +1,101 @@
import { describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
IColorWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useColorWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useColorWidget'
import type {
ColorInputSpec,
InputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
function createMockNode(): LGraphNode {
const widgets: IColorWidget[] = []
const addWidget = vi.fn(
(
type: string,
name: string,
value: string,
_callback: () => void,
options: IWidgetOptions
) => {
const DECLARED_DEFAULT = '#00ff00'
const BLACK_FALLBACK = '#000000'
function createMockNode(): {
node: LGraphNode
addWidget: ReturnType<typeof vi.spyOn>
} {
const node = new LGraphNode('TestColorNode')
const addWidget = vi
.spyOn(node, 'addWidget')
.mockImplementation((type, name, value, _callback, options) => {
const widget = {
type,
name,
value,
options,
callback: _callback
} as unknown as IColorWidget
widgets.push(widget)
options: typeof options === 'string' ? { property: options } : options,
y: 0
} as IBaseWidget
node.widgets ??= []
node.widgets.push(widget)
return widget
}
)
return { widgets, addWidget } as unknown as LGraphNode
})
return { node, addWidget }
}
const colorSpec: InputSpec = {
type: 'COLOR',
name: 'color',
default: '#ffffff',
socketless: true
function createColorSpec(
overrides: Partial<ColorInputSpec> = {}
): ColorInputSpec {
return { type: 'COLOR', name: 'color', ...overrides }
}
describe('useColorWidget', () => {
it('reads the top-level default from the V2 spec', () => {
const node = createMockNode()
const widget = useColorWidget()(node, colorSpec)
expect(widget.value).toBe('#ffffff')
it('uses the declared default from the input spec', () => {
const { node, addWidget } = createMockNode()
const inputSpec = createColorSpec({ default: DECLARED_DEFAULT })
const widget = useColorWidget()(node, inputSpec)
expect(addWidget).toHaveBeenCalledWith(
'color',
'color',
DECLARED_DEFAULT,
expect.any(Function),
{ serialize: true }
)
expect(widget.value).toBe(DECLARED_DEFAULT)
})
it('falls back to nested options.default when top-level default is absent', () => {
const node = createMockNode()
const widget = useColorWidget()(node, {
type: 'COLOR',
name: 'color',
options: { default: '#abcdef' }
} as InputSpec)
expect(widget.value).toBe('#abcdef')
it('falls back to black when no default is supplied', () => {
const { node, addWidget } = createMockNode()
const widget = useColorWidget()(node, createColorSpec())
expect(addWidget).toHaveBeenCalledOnce()
expect(widget.type).toBe('color')
expect(widget.name).toBe('color')
expect(widget.value).toBe(BLACK_FALLBACK)
})
it('falls back to #000000 when no default is declared', () => {
const node = createMockNode()
const widget = useColorWidget()(node, {
type: 'COLOR',
name: 'color'
} as InputSpec)
expect(widget.value).toBe('#000000')
it('honours a custom input name from the spec', () => {
const { node, addWidget } = createMockNode()
useColorWidget()(
node,
createColorSpec({ name: 'bg_color', default: DECLARED_DEFAULT })
)
expect(addWidget.mock.calls[0]![1]).toBe('bg_color')
})
it('returns the existing widget instead of creating a duplicate', () => {
const node = createMockNode()
const first = useColorWidget()(node, colorSpec)
const second = useColorWidget()(node, colorSpec)
const { node, addWidget } = createMockNode()
const inputSpec = createColorSpec({ default: DECLARED_DEFAULT })
const first = useColorWidget()(node, inputSpec)
const second = useColorWidget()(node, inputSpec)
expect(second).toBe(first)
expect(addWidget).toHaveBeenCalledOnce()
expect(node.widgets).toHaveLength(1)
})
it('throws when the input spec is not a color spec', () => {
const { node } = createMockNode()
const inputSpec = { type: 'STRING', name: 'color' } as unknown as InputSpec
expect(() => useColorWidget()(node, inputSpec)).toThrow(
'Invalid input spec for color widget'
)
})
})

View File

@@ -1,16 +1,16 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IColorWidget } from '@/lib/litegraph/src/types/widgets'
import type {
ColorInputSpec,
InputSpec as InputSpecV2
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isColorInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
export const useColorWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IColorWidget => {
const colorSpec = inputSpec as ColorInputSpec
const { name, options } = colorSpec
const defaultValue = colorSpec.default ?? options?.default ?? '#000000'
if (!isColorInputSpec(inputSpec)) {
throw new Error('Invalid input spec for color widget')
}
const { name, default: defaultValue = '#000000' } = inputSpec
const existing = node.widgets?.find(
(w): w is IColorWidget => w.name === name && w.type === 'color'

View File

@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useGalleriaWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useGalleriaWidget'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
function createMockNode(): {
node: LGraphNode
addWidget: ReturnType<typeof vi.spyOn>
} {
const node = new LGraphNode('TestGalleriaNode')
const addWidget = vi
.spyOn(node, 'addWidget')
.mockImplementation((type, name, value, _callback, options) => {
const widget = {
type,
name,
value,
options: typeof options === 'string' ? { property: options } : options,
y: 0
} as IBaseWidget
node.widgets ??= []
node.widgets.push(widget)
return widget
})
return { node, addWidget }
}
function createGalleriaSpec(images?: string[]): InputSpec {
return { type: 'GALLERIA', name: 'gallery', images } as InputSpec
}
describe('useGalleriaWidget', () => {
it('uses the declared images from the input spec', () => {
const { node, addWidget } = createMockNode()
const images = ['a.png', 'b.png']
const widget = useGalleriaWidget()(node, createGalleriaSpec(images))
expect(addWidget).toHaveBeenCalledWith(
'galleria',
'gallery',
images,
expect.any(Function),
{ serialize: true }
)
expect(widget.value).toBe(images)
})
it('defaults to an empty image list', () => {
const { node, addWidget } = createMockNode()
const widget = useGalleriaWidget()(node, createGalleriaSpec())
expect(addWidget).toHaveBeenCalledWith(
'galleria',
'gallery',
[],
expect.any(Function),
{ serialize: true }
)
expect(widget.type).toBe('galleria')
})
it('throws when the input spec is not a galleria spec', () => {
const { node } = createMockNode()
const inputSpec = {
type: 'STRING',
name: 'gallery'
} as unknown as InputSpec
expect(() => useGalleriaWidget()(node, inputSpec)).toThrow(
'Invalid input spec for galleria widget'
)
})
})

View File

@@ -1,25 +1,20 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IGalleriaWidget } from '@/lib/litegraph/src/types/widgets'
import type {
GalleriaInputSpec,
InputSpec as InputSpecV2
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isGalleriaInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
export const useGalleriaWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IGalleriaWidget => {
const { name, options = {} } = inputSpec as GalleriaInputSpec
if (!isGalleriaInputSpec(inputSpec)) {
throw new Error('Invalid input spec for galleria widget')
}
const widget = node.addWidget(
'galleria',
name,
options.images || [],
() => {},
{
serialize: true,
...options
}
) as IGalleriaWidget
const { name, images = [] } = inputSpec
const widget = node.addWidget('galleria', name, images, () => {}, {
serialize: true
}) as IGalleriaWidget
return widget
}

View File

@@ -0,0 +1,82 @@
import { describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useTextareaWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useTextareaWidget'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
function createMockNode(): {
node: LGraphNode
addWidget: ReturnType<typeof vi.spyOn>
} {
const node = new LGraphNode('TestTextareaNode')
const addWidget = vi
.spyOn(node, 'addWidget')
.mockImplementation((type, name, value, _callback, options) => {
const widget = {
type,
name,
value,
options: typeof options === 'string' ? { property: options } : options,
y: 0
} as IBaseWidget
node.widgets ??= []
node.widgets.push(widget)
return widget
})
return { node, addWidget }
}
function createTextareaSpec(
overrides: Partial<{
default: string
rows: number
cols: number
}> = {}
): InputSpec {
return { type: 'TEXTAREA', name: 'text', ...overrides } as InputSpec
}
describe('useTextareaWidget', () => {
it('uses the declared default, rows, and cols from the input spec', () => {
const { node, addWidget } = createMockNode()
const widget = useTextareaWidget()(
node,
createTextareaSpec({ default: 'hello', rows: 10, cols: 80 })
)
expect(addWidget).toHaveBeenCalledWith(
'textarea',
'text',
'hello',
expect.any(Function),
{ serialize: true, rows: 10, cols: 80 }
)
expect(widget.value).toBe('hello')
})
it('falls back to an empty default with 5 rows and 50 cols', () => {
const { node, addWidget } = createMockNode()
const widget = useTextareaWidget()(node, createTextareaSpec())
expect(addWidget).toHaveBeenCalledWith(
'textarea',
'text',
'',
expect.any(Function),
{ serialize: true, rows: 5, cols: 50 }
)
expect(widget.type).toBe('textarea')
})
it('throws when the input spec is not a textarea spec', () => {
const { node } = createMockNode()
const inputSpec = { type: 'STRING', name: 'text' } as unknown as InputSpec
expect(() => useTextareaWidget()(node, inputSpec)).toThrow(
'Invalid input spec for textarea widget'
)
})
})

View File

@@ -1,27 +1,23 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ITextareaWidget } from '@/lib/litegraph/src/types/widgets'
import type {
InputSpec as InputSpecV2,
TextareaInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isTextareaInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
export const useTextareaWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): ITextareaWidget => {
const { name, options = {} } = inputSpec as TextareaInputSpec
if (!isTextareaInputSpec(inputSpec)) {
throw new Error('Invalid input spec for textarea widget')
}
const widget = node.addWidget(
'textarea',
name,
options.default || '',
() => {},
{
serialize: true,
rows: options.rows || 5,
cols: options.cols || 50,
...options
}
) as ITextareaWidget
const { name, default: defaultValue = '', rows = 5, cols = 50 } = inputSpec
const widgetOptions = { rows, cols }
const widget = node.addWidget('textarea', name, defaultValue, () => {}, {
serialize: true,
...widgetOptions
}) as ITextareaWidget
return widget
}

View File

@@ -199,6 +199,32 @@ describe('NodeDef Migration', () => {
expect(result.inputs['customInput'].default).toBe('custom value')
})
it('should map CHART option type to chartType', () => {
const plainObject = {
optional: {
chartInput: ['CHART', { type: 'bar', data: { labels: [] } }]
}
} as ComfyNodeDefV1['input']
const nodeDef: ComfyNodeDefV1 = {
name: 'TestNode',
display_name: 'Test Node',
category: 'Testing',
python_module: 'test_module',
description: 'A test node',
input: plainObject,
output: [],
output_is_list: [],
output_name: [],
output_node: false
}
const result = transformNodeDefV1ToV2(nodeDef)
const chartInput = result.inputs['chartInput']
expect(chartInput.type).toBe('CHART')
expect(chartInput).toMatchObject({ chartType: 'bar', data: { labels: [] } })
})
it('should not transform hidden fields', () => {
const plainObject = {
hidden: {

View File

@@ -121,6 +121,17 @@ export function transformInputSpecV1ToV2(
? inputSpecV1[0]
: getComboSpecComboOptions(inputSpecV1)
}
} else if (inputSpecV1[0] === 'CHART') {
// CHART uses `chartType` in V2 because `type` is the discriminator key
const { type, ...chartOptions } = options
const chartType = type === 'bar' || type === 'line' ? type : undefined
return {
type: 'CHART',
name,
isOptional,
...chartOptions,
chartType
}
} else if (typeof inputSpecV1[0] === 'string') {
// Handle standard types (INT, FLOAT, BOOLEAN, STRING) and custom types
return {

View File

@@ -44,11 +44,7 @@ const zColorInputSpec = zBaseInputOptions.extend({
type: z.literal('COLOR'),
name: z.string(),
isOptional: z.boolean().optional(),
options: z
.object({
default: z.string().optional()
})
.optional()
default: z.string().optional()
})
const zImageInputSpec = zBaseInputOptions.extend({
@@ -84,34 +80,22 @@ const zMarkdownInputSpec = zBaseInputOptions.extend({
type: z.literal('MARKDOWN'),
name: z.string(),
isOptional: z.boolean().optional(),
options: z
.object({
content: z.string().optional()
})
.optional()
default: z.string().optional()
})
const zChartInputSpec = zBaseInputOptions.extend({
type: z.literal('CHART'),
name: z.string(),
isOptional: z.boolean().optional(),
options: z
.object({
type: z.enum(['bar', 'line']).optional(),
data: z.object({}).optional()
})
.optional()
chartType: z.enum(['bar', 'line']).optional(),
data: z.object({}).optional()
})
const zGalleriaInputSpec = zBaseInputOptions.extend({
type: z.literal('GALLERIA'),
name: z.string(),
isOptional: z.boolean().optional(),
options: z
.object({
images: z.array(z.string()).optional()
})
.optional()
images: z.array(z.string()).optional()
})
const zColorsInputSpec = zBaseInputOptions.extend({
@@ -147,13 +131,9 @@ const zTextareaInputSpec = zBaseInputOptions.extend({
type: z.literal('TEXTAREA'),
name: z.string(),
isOptional: z.boolean().optional(),
options: z
.object({
rows: z.number().optional(),
cols: z.number().optional(),
default: z.string().optional()
})
.optional()
rows: z.number().optional(),
cols: z.number().optional(),
default: z.string().optional()
})
const zCurvePoint = z.tuple([z.number(), z.number()])
@@ -302,3 +282,21 @@ export const isChartInputSpec = (
): inputSpec is ChartInputSpec => {
return inputSpec.type === 'CHART'
}
export const isColorInputSpec = (
inputSpec: InputSpec
): inputSpec is ColorInputSpec => {
return inputSpec.type === 'COLOR'
}
export const isTextareaInputSpec = (
inputSpec: InputSpec
): inputSpec is TextareaInputSpec => {
return inputSpec.type === 'TEXTAREA'
}
export const isGalleriaInputSpec = (
inputSpec: InputSpec
): inputSpec is GalleriaInputSpec => {
return inputSpec.type === 'GALLERIA'
}

View File

@@ -10,6 +10,7 @@ from .nodes import (
LongComboDropdown,
MultiSelectNode,
NodeWithBooleanInput,
NodeWithColorInput,
NodeWithDefaultInput,
NodeWithForceInput,
NodeWithOptionalComboInput,
@@ -43,6 +44,7 @@ __all__ = [
"LongComboDropdown",
"MultiSelectNode",
"NodeWithBooleanInput",
"NodeWithColorInput",
"NodeWithDefaultInput",
"NodeWithForceInput",
"NodeWithOptionalComboInput",

View File

@@ -11,6 +11,7 @@ from .errors import (
from .inputs import (
LongComboDropdown,
NodeWithBooleanInput,
NodeWithColorInput,
NodeWithDefaultInput,
NodeWithForceInput,
NodeWithOptionalComboInput,
@@ -69,6 +70,7 @@ __all__ = [
"LongComboDropdown",
"MultiSelectNode",
"NodeWithBooleanInput",
"NodeWithColorInput",
"NodeWithDefaultInput",
"NodeWithForceInput",
"NodeWithOptionalComboInput",

View File

@@ -217,6 +217,20 @@ class NodeWithBooleanInput:
print(f"boolean_input: {boolean_input}")
class NodeWithColorInput:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"color_input": ("COLOR", {"default": "#00ff00"})}}
RETURN_TYPES = ()
FUNCTION = "node_with_color_input"
CATEGORY = "DevTools"
DESCRIPTION = "A node with a color input that declares a non-black default"
def node_with_color_input(self, color_input: str):
print(f"color_input: {color_input}")
class SimpleSlider:
@classmethod
def INPUT_TYPES(cls):
@@ -379,6 +393,7 @@ NODE_CLASS_MAPPINGS = {
"DevToolsNodeWithStringInput": NodeWithStringInput,
"DevToolsNodeWithUnionInput": NodeWithUnionInput,
"DevToolsNodeWithBooleanInput": NodeWithBooleanInput,
"DevToolsNodeWithColorInput": NodeWithColorInput,
"DevToolsSimpleSlider": SimpleSlider,
"DevToolsNodeWithSeedInput": NodeWithSeedInput,
"DevToolsNodeWithValidation": NodeWithValidation,
@@ -399,6 +414,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"DevToolsNodeWithStringInput": "Node With String Input",
"DevToolsNodeWithUnionInput": "Node With Union Input",
"DevToolsNodeWithBooleanInput": "Node With Boolean Input",
"DevToolsNodeWithColorInput": "Node With Color Input",
"DevToolsSimpleSlider": "Simple Slider",
"DevToolsNodeWithSeedInput": "Node With Seed Input",
"DevToolsNodeWithValidation": "Node With Validation",
@@ -419,6 +435,7 @@ __all__ = [
"NodeWithStringInput",
"NodeWithUnionInput",
"NodeWithBooleanInput",
"NodeWithColorInput",
"SimpleSlider",
"NodeWithSeedInput",
"NodeWithValidation",