Compare commits

..

7 Commits

Author SHA1 Message Date
huang47
8405717a6f test: use testing pinia in critical store specs 2026-07-06 21:14:45 -07:00
huang47
59eb3ed538 test: remove redundant about panel comment 2026-07-06 21:09:47 -07:00
huang47
6533961422 test: remove default-fallback change detector from queueStore 2026-07-06 12:04:53 -07:00
huang47
686bb8a9fe test: split create and update secret specs for failure isolation 2026-07-03 15:18:31 -07:00
huang47
c52d212796 test: replace process listener and unsafe casts per review feedback
- apiKeyAuthStore: remove process.once unhandledRejection listener; assert
  the error through toastErrorHandler which now receives it via .catch()
- executionError: replace double assertion with fromPartial<ComfyWorkflow>
- nodeBookmarkStore: replace satisfies+cast fixture helpers with
  fromPartial<ComfyNodeDefImpl> to keep type-checking without unsafe casts
- queueStore: replace fromAny<CustomEvent> with real CustomEvent instances;
  replace remaining fromAny<TaskOutput> with fromPartial or undefined
- queueStore.loadWorkflow: drop fromAny, use fromPartial<TaskOutput> and
  undefined where null was passed to TaskItemImpl
2026-07-03 09:24:11 -07:00
huang47
f916a3f8a7 fix(apiKeyAuthStore): handle rejected promise from initializeUserFromApiKey
Route the unhandled rejection from the async watcher through reportError
so failures during user initialization are surfaced via the toast handler
instead of propagating as unhandled rejections.
2026-07-03 09:23:38 -07:00
huang47
ccd33bb505 test: cover critical stores 2026-07-02 09:09:54 -07:00
29 changed files with 1153 additions and 2796 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1,3 +1,3 @@
<svg width="20" height="32" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="#F2FF59"/>
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector" d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="var(--fill-0, #F2FF59)"/>
</svg>

Before

Width:  |  Height:  |  Size: 279 B

After

Width:  |  Height:  |  Size: 380 B

View File

@@ -3799,7 +3799,7 @@ export class LGraphNode
const title = String(rawTitle) + (this.pinned ? '📌' : '')
if (title) {
if (selected) {
ctx.fillStyle = LiteGraph.NODE_SELECTED_TITLE_COLOR ?? '#FFF'
ctx.fillStyle = LiteGraph.NODE_SELECTED_TITLE_COLOR
} else {
ctx.fillStyle = this.constructor.title_text_color || default_title_color
}

View File

@@ -54,10 +54,10 @@ export class LiteGraphGlobal {
NODE_COLLAPSED_RADIUS = 10
NODE_COLLAPSED_WIDTH = 80
NODE_TITLE_COLOR = '#999'
NODE_SELECTED_TITLE_COLOR: string | undefined = '#FFF'
NODE_SELECTED_TITLE_COLOR = '#FFF'
NODE_TEXT_SIZE = 14
NODE_TEXT_COLOR = '#AAA'
NODE_TEXT_HIGHLIGHT_COLOR: string | undefined = '#EEE'
NODE_TEXT_HIGHLIGHT_COLOR = '#EEE'
NODE_SUBTEXT_SIZE = 12
NODE_DEFAULT_COLOR = '#333'
NODE_DEFAULT_BGCOLOR = '#353535'

View File

@@ -1,451 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fromPartial } from '@total-typescript/shoehorn'
import type {
DefaultConnectionColors,
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { SlotType } from '@/lib/litegraph/src/draw'
import {
LinkDirection,
RenderShape
} from '@/lib/litegraph/src/types/globalEnums'
import { toLinkId } from '@/types/linkId'
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
import { createTestSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { NodeInputSlot } from './NodeInputSlot'
import { NodeOutputSlot } from './NodeOutputSlot'
function createColorContext(): DefaultConnectionColors {
return {
getConnectedColor: vi.fn(() => '#0f0'),
getDisconnectedColor: vi.fn(() => '#f00')
}
}
function createNode(): LGraphNode {
const node = new LGraphNode('Test Node')
node.pos = [0, 0]
return node
}
function createInputSlot(
overrides: Partial<INodeInputSlot> = {},
node = createNode()
): NodeInputSlot {
return new NodeInputSlot(
{
name: 'in',
type: 'STRING',
link: null,
boundingRect: [10, 20, 20, 20] as const,
...overrides
},
node
)
}
function createOutputSlot(
overrides: Partial<INodeOutputSlot> = {},
node = createNode()
): NodeOutputSlot {
return new NodeOutputSlot(
{
name: 'out',
type: 'STRING',
links: null,
boundingRect: [10, 20, 20, 20] as const,
...overrides
},
node
)
}
describe('NodeSlot rendering', () => {
let ctx: CanvasRenderingContext2D
let colorContext: DefaultConnectionColors
beforeEach(() => {
ctx = createMockCanvasRenderingContext2D()
colorContext = createColorContext()
})
describe('draw', () => {
it('draws a disconnected circle slot with its label', () => {
const slot = createInputSlot()
slot.draw(ctx, { colorContext })
expect(colorContext.getDisconnectedColor).toHaveBeenCalledWith('STRING')
expect(ctx.arc).toHaveBeenCalledWith(
expect.any(Number),
expect.any(Number),
4,
0,
Math.PI * 2
)
expect(ctx.fill).toHaveBeenCalled()
expect(ctx.fillText).toHaveBeenCalledWith(
'in',
expect.any(Number),
expect.any(Number)
)
})
it('uses the connected colour and a larger radius when highlighted', () => {
const slot = createInputSlot({ link: toLinkId(1) })
slot.draw(ctx, { colorContext, highlight: true })
expect(colorContext.getConnectedColor).toHaveBeenCalledWith('STRING')
expect(ctx.arc).toHaveBeenCalledWith(
expect.any(Number),
expect.any(Number),
5,
0,
Math.PI * 2
)
})
it('prefers color_on over the colour context when connected', () => {
const slot = createInputSlot({ link: toLinkId(1), color_on: '#abc' })
let fillStyleAtFill: typeof ctx.fillStyle | undefined
vi.mocked(ctx.fill).mockImplementation(() => {
fillStyleAtFill = ctx.fillStyle
})
slot.draw(ctx, { colorContext })
expect(fillStyleAtFill).toBe('#abc')
expect(ctx.fillStyle).not.toBe('#abc') // restored after draw
})
it('draws a box for event slots', () => {
const slot = createInputSlot({ type: SlotType.Event })
slot.draw(ctx, { colorContext })
expect(ctx.rect).toHaveBeenCalledTimes(1)
expect(ctx.arc).not.toHaveBeenCalled()
})
it('draws a box for box-shaped slots', () => {
const slot = createInputSlot({ shape: RenderShape.BOX })
slot.draw(ctx, { colorContext })
expect(ctx.rect).toHaveBeenCalledTimes(1)
})
it('draws a triangle for arrow-shaped slots', () => {
const slot = createInputSlot({ shape: RenderShape.ARROW })
slot.draw(ctx, { colorContext })
expect(ctx.moveTo).toHaveBeenCalledTimes(1)
expect(ctx.lineTo).toHaveBeenCalledTimes(2)
expect(ctx.closePath).toHaveBeenCalled()
})
it('draws a 3x3 grid for array-typed slots', () => {
const slot = createInputSlot({ type: SlotType.Array })
slot.draw(ctx, { colorContext })
expect(ctx.rect).toHaveBeenCalledTimes(9)
})
it('draws a simple rect and no label in low quality mode', () => {
const slot = createInputSlot()
slot.draw(ctx, { colorContext, lowQuality: true })
expect(ctx.rect).toHaveBeenCalledTimes(1)
expect(ctx.fillText).not.toHaveBeenCalled()
})
it('clips hollow circle slots to a ring', () => {
const arc = vi.fn()
vi.stubGlobal(
'Path2D',
class {
arc = arc
}
)
try {
const slot = createInputSlot({ shape: RenderShape.HollowCircle })
slot.draw(ctx, { colorContext, highlight: true })
slot.draw(ctx, { colorContext })
expect(ctx.clip).toHaveBeenCalledTimes(2)
// Inner radius is larger while highlighted.
expect(arc).toHaveBeenCalledWith(
expect.any(Number),
expect.any(Number),
2.5,
0,
Math.PI * 2
)
expect(arc).toHaveBeenCalledWith(
expect.any(Number),
expect.any(Number),
1.5,
0,
Math.PI * 2
)
} finally {
vi.unstubAllGlobals()
}
})
it('draws one pie segment per type for multi-type slots', () => {
const slot = createInputSlot({ type: 'STRING,INT' })
slot.draw(ctx, { colorContext })
// Once for the base slot colour, then once per type in the pie.
expect(colorContext.getDisconnectedColor).toHaveBeenCalledTimes(3)
// One filled arc per type, plus the final outline arc.
expect(ctx.arc).toHaveBeenCalledTimes(3)
expect(ctx.stroke).toHaveBeenCalled()
})
it('hides the label for widget input slots', () => {
const slot = createInputSlot({ widget: { name: 'in' } })
slot.draw(ctx, { colorContext })
expect(ctx.fillText).not.toHaveBeenCalled()
})
it('skips the label when there is no text to render', () => {
const slot = createInputSlot({ name: '' })
slot.draw(ctx, { colorContext })
expect(ctx.fillText).not.toHaveBeenCalled()
})
it('draws input labels above the slot when directed up', () => {
const slot = createInputSlot({ dir: LinkDirection.UP })
slot.draw(ctx, { colorContext })
const [, x, y] = vi.mocked(ctx.fillText).mock.calls[0]
const slotCentre = [
slot.boundingRect[0] + 10 - slot.node.pos[0],
slot.boundingRect[1] + 10 - slot.node.pos[1]
]
expect(x).toBe(slotCentre[0])
expect(y).toBeLessThan(slotCentre[1])
})
it('draws output labels to the left of the slot', () => {
const slot = createOutputSlot()
slot.draw(ctx, { colorContext })
const [, x] = vi.mocked(ctx.fillText).mock.calls[0]
const slotCentreX = slot.boundingRect[0] + 10 - slot.node.pos[0]
expect(x).toBeLessThan(slotCentreX)
})
it('draws output labels above the slot when directed down', () => {
const slot = createOutputSlot({ dir: LinkDirection.DOWN })
slot.draw(ctx, { colorContext })
const [, , y] = vi.mocked(ctx.fillText).mock.calls[0]
const slotCentreY = slot.boundingRect[1] + 10 - slot.node.pos[1]
expect(y).toBeLessThan(slotCentreY)
})
it('strokes output slots in normal quality', () => {
const slot = createOutputSlot()
slot.draw(ctx, { colorContext })
expect(ctx.stroke).toHaveBeenCalled()
})
it('does not stroke output slots in low quality', () => {
const slot = createOutputSlot()
slot.draw(ctx, { colorContext, lowQuality: true })
expect(ctx.stroke).not.toHaveBeenCalled()
})
it('rings the slot in red when it has errors', () => {
const slot = createInputSlot({ hasErrors: true })
slot.draw(ctx, { colorContext })
expect(ctx.arc).toHaveBeenCalledWith(
expect.any(Number),
expect.any(Number),
12,
0,
Math.PI * 2
)
expect(ctx.stroke).toHaveBeenCalled()
})
})
describe('highlightColor', () => {
const original = {
highlight: LiteGraph.NODE_TEXT_HIGHLIGHT_COLOR,
selectedTitle: LiteGraph.NODE_SELECTED_TITLE_COLOR
}
afterEach(() => {
LiteGraph.NODE_TEXT_HIGHLIGHT_COLOR = original.highlight
LiteGraph.NODE_SELECTED_TITLE_COLOR = original.selectedTitle
})
it('prefers the dedicated text highlight colour', () => {
expect(createInputSlot().highlightColor).toBe(
LiteGraph.NODE_TEXT_HIGHLIGHT_COLOR
)
})
it('falls back to the selected title colour, then text colour', () => {
LiteGraph.NODE_TEXT_HIGHLIGHT_COLOR = undefined
expect(createInputSlot().highlightColor).toBe(
LiteGraph.NODE_SELECTED_TITLE_COLOR
)
LiteGraph.NODE_SELECTED_TITLE_COLOR = undefined
expect(createInputSlot().highlightColor).toBe(LiteGraph.NODE_TEXT_COLOR)
})
})
describe('renderingLabel', () => {
it.for<[string, Partial<NodeInputSlot>, string]>([
['label', { label: 'A Label', localized_name: 'Localized' }, 'A Label'],
['localized_name', { localized_name: 'Localized' }, 'Localized'],
['name', {}, 'in'],
['empty string', { name: '' }, '']
])('falls back through %s', ([, overrides, expected]) => {
expect(createInputSlot(overrides).renderingLabel).toBe(expected)
})
})
describe('drawCollapsed', () => {
it('draws a box for event slots', () => {
createInputSlot({ type: SlotType.Event }).drawCollapsed(ctx)
expect(ctx.rect).toHaveBeenCalledTimes(1)
expect(ctx.fill).toHaveBeenCalled()
})
it('draws a box for box-shaped slots', () => {
createInputSlot({ shape: RenderShape.BOX }).drawCollapsed(ctx)
expect(ctx.rect).toHaveBeenCalledTimes(1)
})
it('draws an input-facing arrow for arrow-shaped input slots', () => {
createInputSlot({ shape: RenderShape.ARROW }).drawCollapsed(ctx)
expect(ctx.moveTo).toHaveBeenCalledWith(8, expect.any(Number))
expect(ctx.closePath).toHaveBeenCalled()
})
it('draws an output-facing arrow for arrow-shaped output slots', () => {
const node = createNode()
node._collapsed_width = 60
createOutputSlot({ shape: RenderShape.ARROW }, node).drawCollapsed(ctx)
expect(ctx.moveTo).toHaveBeenCalledWith(66, expect.any(Number))
})
it('draws a circle by default', () => {
createInputSlot().drawCollapsed(ctx)
expect(ctx.arc).toHaveBeenCalledWith(
expect.any(Number),
expect.any(Number),
4,
0,
Math.PI * 2
)
})
})
describe('collapsedPos', () => {
it('places output slots at the collapsed node width', () => {
const node = createNode()
node._collapsed_width = 42
expect(createOutputSlot({}, node).collapsedPos[0]).toBe(42)
})
it('falls back to the default collapsed width', () => {
const slot = createOutputSlot()
expect(slot.collapsedPos[0]).toBe(LiteGraph.NODE_COLLAPSED_WIDTH)
})
it('places input slots at the node origin', () => {
expect(createInputSlot().collapsedPos[0]).toBe(0)
})
})
describe('isValidTarget', () => {
it('validates input slots against output slots', () => {
const input = createInputSlot()
const output = createOutputSlot()
expect(input.isValidTarget(output)).toBe(true)
expect(output.isValidTarget(input)).toBe(true)
})
it('rejects connections between incompatible slot types', () => {
const input = createInputSlot()
const output = createOutputSlot({ type: 'INT' })
expect(input.isValidTarget(output)).toBe(false)
})
it('validates output slots against subgraph outputs', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'value', type: 'STRING' }]
})
const subgraphOutput = subgraph.outputNode.slots[0]
expect(createOutputSlot().isValidTarget(subgraphOutput)).toBe(true)
expect(createOutputSlot({ type: 'INT' }).isValidTarget(subgraphOutput)) //
.toBe(false)
})
it('validates input slots against subgraph inputs', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'STRING' }]
})
const subgraphInput = subgraph.inputNode.slots[0]
expect(createInputSlot().isValidTarget(subgraphInput)).toBe(true)
})
it('rejects unknown slot shapes', () => {
const input = createInputSlot()
const output = createOutputSlot()
expect(input.isValidTarget(fromPartial({ type: 'STRING' }))).toBe(false)
expect(output.isValidTarget(fromPartial({ type: 'STRING' }))).toBe(false)
})
})
describe('isConnected', () => {
it('reports output connectivity from the links array', () => {
expect(createOutputSlot().isConnected).toBe(false)
expect(createOutputSlot({ links: [] }).isConnected).toBe(false)
expect(createOutputSlot({ links: [toLinkId(1)] }).isConnected).toBe(true)
})
})
})

View File

@@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest'
import type { INodeOutputSlot } from '@/lib/litegraph/src/interfaces'
import type { IWidget } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
import { toLinkId } from '@/types/linkId'
import { outputAsSerialisable } from './slotUtils'
@@ -34,18 +33,4 @@ describe('outputAsSerialisable', () => {
const serialised = outputAsSerialisable(output as OutputSlotParam)
expect(serialised.links).toBeNull()
})
it('serialises only the widget name for outputs with widgets', () => {
const node = new LGraphNode('test')
const output = node.addOutput('out', 'number') as OutputSlotParam
output.widget = node.addWidget(
'number',
'my-widget',
0,
null
) as INumericWidget
const serialised = outputAsSerialisable(output)
expect(serialised.widget).toEqual({ name: 'my-widget' })
})
})

View File

@@ -8,10 +8,8 @@ import {
LGraphEventMode,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { NullGraphError } from '@/lib/litegraph/src/infrastructure/NullGraphError'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
import { widgetId } from '@/types/widgetId'
import {
createNestedSubgraphs,
@@ -716,373 +714,3 @@ describe('ExecutableNodeDTO Scale Testing', () => {
}
})
})
describe('ExecutableNodeDTO error and edge branches', () => {
it('throws NullGraphError when the node has no graph', () => {
const orphan = new LGraphNode('Orphan')
expect(() => new ExecutableNodeDTO(orphan, [], new Map())).toThrow(
NullGraphError
)
})
it('returns itself from getInnerNodes for regular nodes', () => {
const graph = new LGraph()
const node = new LGraphNode('Plain')
graph.add(node)
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
expect(dto.getInnerNodes()).toEqual([dto])
})
it('throws InvalidLinkError for dangling input link ids', () => {
const graph = new LGraph()
const node = new LGraphNode('Dangling')
node.addInput('in', 'IMAGE')
graph.add(node)
node.inputs[0].link = toLinkId(999)
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
expect(() => dto.resolveInput(0)).toThrow('No link found in parent graph')
})
function createBypassCycle() {
const graph = new LGraph()
const a = new LGraphNode('A')
a.addInput('in', 'IMAGE')
a.addOutput('out', 'IMAGE')
a.mode = LGraphEventMode.BYPASS
const b = new LGraphNode('B')
b.addInput('in', 'IMAGE')
b.addOutput('out', 'IMAGE')
b.mode = LGraphEventMode.BYPASS
graph.add(a)
graph.add(b)
a.connect(0, b, 0)
b.connect(0, a, 0)
const map = new Map()
const dtoA = new ExecutableNodeDTO(a, [], map, undefined)
const dtoB = new ExecutableNodeDTO(b, [], map, undefined)
map.set(dtoA.id, dtoA)
map.set(dtoB.id, dtoB)
return { dtoA }
}
it('throws a RecursionError when input resolution loops', () => {
const { dtoA } = createBypassCycle()
expect(() => dtoA.resolveInput(0)).toThrow('Circular reference detected')
})
it('throws a RecursionError when output resolution loops', () => {
const { dtoA } = createBypassCycle()
expect(() => dtoA.resolveOutput(0, 'IMAGE', new Set())).toThrow(
'Circular reference detected'
)
})
it('includes the subgraph path in recursion errors', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
a.addInput('in', 'IMAGE')
a.addOutput('out', 'IMAGE')
a.mode = LGraphEventMode.BYPASS
const b = new LGraphNode('B')
b.addInput('in', 'IMAGE')
b.addOutput('out', 'IMAGE')
b.mode = LGraphEventMode.BYPASS
graph.add(a)
graph.add(b)
a.connect(0, b, 0)
b.connect(0, a, 0)
const map = new Map()
const dtoA = new ExecutableNodeDTO(a, ['7'], map, undefined)
const dtoB = new ExecutableNodeDTO(b, ['7'], map, undefined)
map.set(dtoA.id, dtoA)
map.set(dtoB.id, dtoB)
expect(() => dtoA.resolveInput(0)).toThrow('at path 7')
})
describe('subgraph boundary resolution', () => {
function createBoundarySetup(options: { connectOuter?: boolean } = {}) {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'IMAGE' }],
outputs: [{ name: 'result', type: 'IMAGE' }]
})
const inner = new LGraphNode('Inner')
inner.addInput('in', 'IMAGE')
inner.addOutput('out', 'IMAGE')
subgraph.add(inner)
subgraph.inputs[0].connect(inner.inputs[0], inner)
subgraph.outputs[0].connect(inner.outputs[0], inner)
const subgraphNode = createTestSubgraphNode(subgraph)
subgraph.rootGraph.add(subgraphNode)
// DTOs snapshot their input links, so wire the outer graph first.
let outer: LGraphNode | undefined
if (options.connectOuter) {
outer = new LGraphNode('Outer')
outer.addOutput('out', 'IMAGE')
subgraph.rootGraph.add(outer)
outer.connect(0, subgraphNode, 0)
}
const map = new Map()
if (outer) {
const outerDto = new ExecutableNodeDTO(outer, [], map)
map.set(outerDto.id, outerDto)
}
const subgraphNodeDto = new ExecutableNodeDTO(subgraphNode, [], map)
map.set(subgraphNodeDto.id, subgraphNodeDto)
const innerDto = new ExecutableNodeDTO(
inner,
[String(subgraphNode.id)],
map,
subgraphNode
)
map.set(innerDto.id, innerDto)
return {
subgraph,
inner,
outer,
subgraphNode,
map,
subgraphNodeDto,
innerDto
}
}
it('resolves inner node inputs through to the outer graph', () => {
const { outer, innerDto } = createBoundarySetup({ connectOuter: true })
const resolved = innerDto.resolveInput(0)
expect(resolved?.origin_id).toBe(String(outer!.id))
expect(resolved?.origin_slot).toBe(0)
})
it('returns undefined for unconnected subgraph inputs without widgets', () => {
const { innerDto } = createBoundarySetup()
expect(innerDto.resolveInput(0)).toBeUndefined()
})
it('returns the promoted widget value for widget-backed subgraph inputs', () => {
const { subgraphNode, innerDto } = createBoundarySetup()
subgraphNode.inputs[0].widgetId = widgetId(
subgraphNode.graph!.id,
subgraphNode.id,
'value'
)
const resolved = innerDto.resolveInput(0)
expect(resolved?.origin_slot).toBe(-1)
expect(resolved?.widgetInfo).toBeDefined()
expect(resolved?.origin_id).toBe(innerDto.id)
})
it('throws SlotIndexError when the subgraph node lacks the input slot', () => {
const { subgraphNode, innerDto } = createBoundarySetup()
// Characterises corruption handling: the subgraph node lost its slots.
subgraphNode.inputs.length = 0
expect(() => innerDto.resolveInput(0)).toThrow('No input found for slot')
})
it('resolves subgraph node outputs through the inner node', () => {
const { inner, subgraphNode, subgraphNodeDto } = createBoundarySetup()
const resolved = subgraphNodeDto.resolveOutput(0, 'IMAGE', new Set())
expect(resolved?.origin_id).toBe(`${subgraphNode.id}:${inner.id}`)
expect(resolved?.origin_slot).toBe(0)
})
it('throws SlotIndexError for missing subgraph output slots', () => {
const { subgraphNodeDto } = createBoundarySetup()
expect(() =>
subgraphNodeDto.resolveOutput(5, 'IMAGE', new Set())
).toThrow('No output found for flattened id')
})
it('returns undefined when the subgraph output has no internal link', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'result', type: 'IMAGE' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
subgraph.rootGraph.add(subgraphNode)
const map = new Map()
const dto = new ExecutableNodeDTO(subgraphNode, [], map)
map.set(dto.id, dto)
expect(dto.resolveOutput(0, 'IMAGE', new Set())).toBeUndefined()
})
})
describe('bypass slot matching', () => {
it('matches by slot index for wildcard target types', () => {
const graph = new LGraph()
const node = new LGraphNode('Bypass')
node.addInput('in', 'IMAGE')
node.addOutput('out0', 'IMAGE')
node.addOutput('out1', 'IMAGE')
node.mode = LGraphEventMode.BYPASS
graph.add(node)
const map = new Map()
const dto = new ExecutableNodeDTO(node, [], map)
map.set(dto.id, dto)
// Both resolve through unconnected inputs, so the result is undefined,
// but neither is rejected as a failed type match.
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(dto.resolveOutput(0, '*', new Set())).toBeUndefined()
expect(dto.resolveOutput(1, '*', new Set())).toBeUndefined()
expect(warn).not.toHaveBeenCalled()
warn.mockRestore()
})
it('prefers an exact type match over the opposite slot index', () => {
const graph = new LGraph()
const source = new LGraphNode('Source')
source.addOutput('mask', 'MASK')
const node = new LGraphNode('Bypass')
node.addInput('image', 'IMAGE')
node.addInput('mask', 'MASK')
node.addOutput('mask', 'MASK')
node.mode = LGraphEventMode.BYPASS
graph.add(source)
graph.add(node)
source.connect(0, node, 1)
const map = new Map()
const sourceDto = new ExecutableNodeDTO(source, [], map)
map.set(sourceDto.id, sourceDto)
const dto = new ExecutableNodeDTO(node, [], map)
map.set(dto.id, dto)
const resolved = dto.resolveOutput(0, 'MASK', new Set())
expect(resolved?.origin_id).toBe(String(source.id))
})
it('warns and returns undefined when no input type matches', () => {
const graph = new LGraph()
const node = new LGraphNode('Bypass')
node.addInput('in', 'IMAGE')
node.addOutput('out', 'IMAGE')
node.mode = LGraphEventMode.BYPASS
graph.add(node)
const map = new Map()
const dto = new ExecutableNodeDTO(node, [], map)
map.set(dto.id, dto)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(dto.resolveOutput(0, 'MASK', new Set())).toBeUndefined()
expect(warn).toHaveBeenCalled()
warn.mockRestore()
})
})
it('resolves virtual nodes through their input link', () => {
const graph = new LGraph()
const source = new LGraphNode('Source')
source.addOutput('out', 'IMAGE')
const virtualNode = new LGraphNode('Virtual Passthrough')
virtualNode.addInput('in', 'IMAGE')
virtualNode.addOutput('out', 'IMAGE')
virtualNode.isVirtualNode = true
graph.add(source)
graph.add(virtualNode)
source.connect(0, virtualNode, 0)
const map = new Map()
const sourceDto = new ExecutableNodeDTO(source, [], map)
map.set(sourceDto.id, sourceDto)
const virtualDto = new ExecutableNodeDTO(virtualNode, [], map)
map.set(virtualDto.id, virtualDto)
const resolved = virtualDto.resolveOutput(0, 'IMAGE', new Set())
expect(resolved?.origin_id).toBe(String(source.id))
expect(resolved?.origin_slot).toBe(0)
})
})
describe('ExecutableNodeDTO missing DTO map entries', () => {
it('throws when the upstream node DTO is missing from the map', () => {
const graph = new LGraph()
const source = new LGraphNode('Source')
source.addOutput('out', 'IMAGE')
const target = new LGraphNode('Target')
target.addInput('in', 'IMAGE')
graph.add(source)
graph.add(target)
source.connect(0, target, 0)
const map = new Map()
const dto = new ExecutableNodeDTO(target, [], map)
map.set(dto.id, dto)
expect(() => dto.resolveInput(0)).toThrow('No output node DTO found')
})
it('throws when the containing subgraph node DTO is missing from the map', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'IMAGE' }]
})
const inner = new LGraphNode('Inner')
inner.addInput('in', 'IMAGE')
subgraph.add(inner)
subgraph.inputs[0].connect(inner.inputs[0], inner)
const subgraphNode = createTestSubgraphNode(subgraph)
subgraph.rootGraph.add(subgraphNode)
const outer = new LGraphNode('Outer')
outer.addOutput('out', 'IMAGE')
subgraph.rootGraph.add(outer)
outer.connect(0, subgraphNode, 0)
const map = new Map()
const innerDto = new ExecutableNodeDTO(
inner,
[String(subgraphNode.id)],
map,
subgraphNode
)
map.set(innerDto.id, innerDto)
expect(() => innerDto.resolveInput(0)).toThrow('No subgraph node DTO found')
})
it('throws when a virtual node input DTO is missing from the map', () => {
const graph = new LGraph()
const source = new LGraphNode('Source')
source.addOutput('out', 'IMAGE')
const virtualNode = new LGraphNode('Virtual')
virtualNode.addInput('in', 'IMAGE')
virtualNode.addOutput('out', 'IMAGE')
virtualNode.isVirtualNode = true
graph.add(source)
graph.add(virtualNode)
source.connect(0, virtualNode, 0)
// The virtual node resolves through its own input, whose DTO is missing.
const map = new Map()
const virtualDto = new ExecutableNodeDTO(virtualNode, [], map)
expect(() => virtualDto.resolveOutput(0, 'IMAGE', new Set())).toThrow(
'No input node DTO found'
)
})
})

View File

@@ -1,389 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fromPartial } from '@total-typescript/shoehorn'
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
import type {
IContextMenuOptions,
IContextMenuValue
} from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { CanvasItem } from '@/lib/litegraph/src/types/globalEnums'
import { toLinkId } from '@/types/linkId'
import {
createTestSubgraph,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
function pointerEvent(
canvasX: number,
canvasY: number,
button = 0
): CanvasPointerEvent {
return fromPartial({ canvasX, canvasY, button })
}
function createArrangedInputNode() {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'STRING' }]
})
const inputNode = subgraph.inputNode
inputNode.configure({ id: inputNode.id, bounding: [0, 0, 150, 100] })
inputNode.arrange()
return { subgraph, inputNode }
}
function slotCentre(slot: {
boundingRect: ArrayLike<number>
}): [number, number] {
const [x, y, width, height] = Array.from(slot.boundingRect)
return [x + width / 2, y + height / 2]
}
describe('SubgraphIONodeBase', () => {
beforeEach(() => {
resetSubgraphFixtureState()
LGraphCanvas._measureText = (text: string) => text.length * 8
})
afterEach(() => {
LGraphCanvas._measureText = undefined
vi.restoreAllMocks()
})
describe('pointer hover', () => {
it('tracks pointer enter, slot hover, and leave', () => {
const { inputNode } = createArrangedInputNode()
const [slotX, slotY] = slotCentre(inputNode.slots[0])
const overSlot = inputNode.onPointerMove(pointerEvent(slotX, slotY))
expect(inputNode.isPointerOver).toBe(true)
expect(overSlot & CanvasItem.SubgraphIoNode).toBeTruthy()
expect(overSlot & CanvasItem.SubgraphIoSlot).toBeTruthy()
expect(inputNode.slots[0].isPointerOver).toBe(true)
// Move within the node but off the slot
const overNode = inputNode.onPointerMove(pointerEvent(1, 1))
expect(overNode).toBe(CanvasItem.SubgraphIoNode)
// Leave the node entirely
const outside = inputNode.onPointerMove(pointerEvent(500, 500))
expect(outside).toBe(CanvasItem.Nothing)
expect(inputNode.isPointerOver).toBe(false)
expect(inputNode.slots[0].isPointerOver).toBe(false)
// Moving outside while already outside stays a no-op
expect(inputNode.onPointerMove(pointerEvent(500, 500))).toBe(
CanvasItem.Nothing
)
})
it('reports whether a point is inside the node', () => {
const { inputNode } = createArrangedInputNode()
expect(inputNode.containsPoint([1, 1])).toBe(true)
expect(inputNode.containsPoint([500, 500])).toBe(false)
})
})
describe('snapToGrid', () => {
it('does not snap pinned nodes', () => {
const { inputNode } = createArrangedInputNode()
inputNode.pinned = true
expect(inputNode.snapToGrid(10)).toBe(false)
})
it('snaps unpinned nodes to the grid', () => {
const { inputNode } = createArrangedInputNode()
inputNode.pos = [7, 13]
expect(inputNode.snapToGrid(10)).toBe(true)
expect([inputNode.pos[0], inputNode.pos[1]]).toEqual([10, 10])
})
})
describe('getSlotInPosition', () => {
it('returns the slot at the given canvas position', () => {
const { inputNode } = createArrangedInputNode()
const [slotX, slotY] = slotCentre(inputNode.slots[0])
expect(inputNode.getSlotInPosition(slotX, slotY)).toBe(inputNode.slots[0])
})
it('returns undefined when no slot contains the position', () => {
const { inputNode } = createArrangedInputNode()
expect(inputNode.getSlotInPosition(500, 500)).toBeUndefined()
})
})
describe('slot context menu', () => {
interface CapturedMenu {
options: (IContextMenuValue | null)[]
opts: IContextMenuOptions
}
let captured: CapturedMenu | undefined
const OriginalContextMenu = LiteGraph.ContextMenu
beforeEach(() => {
captured = undefined
LiteGraph.ContextMenu = fromPartial<typeof LiteGraph.ContextMenu>(
class {
constructor(
options: (IContextMenuValue | null)[],
opts: IContextMenuOptions
) {
captured = { options, opts }
}
}
)
})
afterEach(() => {
LiteGraph.ContextMenu = OriginalContextMenu
})
it('offers disconnect, rename, and remove for connected slots', () => {
const { subgraph, inputNode } = createArrangedInputNode()
const slot = inputNode.slots[0]
slot.linkIds.push(toLinkId(1))
const [slotX, slotY] = slotCentre(slot)
inputNode.onPointerDown(
pointerEvent(slotX, slotY, 2),
fromPartial<CanvasPointer>({}),
fromPartial<LinkConnector>({})
)
expect(captured).toBeDefined()
expect(captured?.options.map((o) => o?.value)).toEqual([
'disconnect',
'rename',
undefined,
'remove'
])
// Disconnect action clears the slot's links.
void captured?.opts.callback?.(fromPartial({ value: 'disconnect' }))
expect(slot.linkIds).toHaveLength(0)
// Remove action deletes the slot from the subgraph.
void captured?.opts.callback?.(fromPartial({ value: 'remove' }))
expect(subgraph.inputs).toHaveLength(0)
})
it('renames the slot through the canvas prompt', () => {
const { subgraph, inputNode } = createArrangedInputNode()
const slot = inputNode.slots[0]
const prompt = vi.fn(
(
_title: string,
_value: unknown,
callback: (value: string) => void
) => {
callback('renamed')
}
)
subgraph.list_of_graphcanvas = [
fromPartial<LGraphCanvas>({ prompt, setDirty: vi.fn() })
]
const [slotX, slotY] = slotCentre(slot)
inputNode.onPointerDown(
pointerEvent(slotX, slotY, 2),
fromPartial<CanvasPointer>({}),
fromPartial<LinkConnector>({})
)
void captured?.opts.callback?.(fromPartial({ value: 'rename' }))
expect(prompt).toHaveBeenCalledWith(
'Slot name',
'value',
expect.any(Function),
expect.anything()
)
// Renaming an input updates its display label.
expect(subgraph.inputs[0].displayName).toBe('renamed')
})
it('does not show a menu for the empty slot', () => {
const { inputNode } = createArrangedInputNode()
const [slotX, slotY] = slotCentre(inputNode.emptySlot)
inputNode.onPointerDown(
pointerEvent(slotX, slotY, 2),
fromPartial<CanvasPointer>({}),
fromPartial<LinkConnector>({})
)
expect(captured).toBeUndefined()
})
it('ignores right-clicks outside all slots', () => {
const { inputNode } = createArrangedInputNode()
inputNode.onPointerDown(
pointerEvent(500, 500, 2),
fromPartial<CanvasPointer>({}),
fromPartial<LinkConnector>({})
)
expect(captured).toBeUndefined()
})
})
describe('left-click drag and double-click', () => {
it('wires up drag handlers when a slot is clicked', () => {
const { subgraph, inputNode } = createArrangedInputNode()
const slot = inputNode.slots[0]
const pointer = fromPartial<CanvasPointer>({})
const linkConnector = fromPartial<LinkConnector>({
dragNewFromSubgraphInput: vi.fn(),
dropLinks: vi.fn(),
reset: vi.fn()
})
const [slotX, slotY] = slotCentre(slot)
inputNode.onPointerDown(
pointerEvent(slotX, slotY),
pointer,
linkConnector
)
pointer.onDragStart?.(pointer)
expect(linkConnector.dragNewFromSubgraphInput).toHaveBeenCalledWith(
subgraph,
inputNode,
slot
)
pointer.onDragEnd?.(fromPartial<CanvasPointerEvent>({}))
expect(linkConnector.dropLinks).toHaveBeenCalled()
pointer.finally?.()
expect(linkConnector.reset).toHaveBeenCalledWith(true)
})
it('prompts to rename on double-click of a regular slot', () => {
const { subgraph, inputNode } = createArrangedInputNode()
const slot = inputNode.slots[0]
const prompt = vi.fn()
subgraph.list_of_graphcanvas = [fromPartial<LGraphCanvas>({ prompt })]
const pointer = fromPartial<CanvasPointer>({})
const [slotX, slotY] = slotCentre(slot)
inputNode.onPointerDown(
pointerEvent(slotX, slotY),
pointer,
fromPartial<LinkConnector>({})
)
pointer.onDoubleClick?.(fromPartial<CanvasPointerEvent>({}))
expect(prompt).toHaveBeenCalled()
})
it('does not prompt to rename on double-click of the empty slot', () => {
const { subgraph, inputNode } = createArrangedInputNode()
const prompt = vi.fn()
subgraph.list_of_graphcanvas = [fromPartial<LGraphCanvas>({ prompt })]
const pointer = fromPartial<CanvasPointer>({})
const [slotX, slotY] = slotCentre(inputNode.emptySlot)
inputNode.onPointerDown(
pointerEvent(slotX, slotY),
pointer,
fromPartial<LinkConnector>({})
)
pointer.onDoubleClick?.(fromPartial<CanvasPointerEvent>({}))
expect(prompt).not.toHaveBeenCalled()
})
})
describe('arrange', () => {
it('sizes the node to fit its widest slot', () => {
LGraphCanvas._measureText = () => 300
const { inputNode } = createArrangedInputNode()
inputNode.arrange()
// Slot width (300 + slot height) exceeds the minimum width of 100.
expect(inputNode.size[0]).toBeGreaterThan(300)
})
it('falls back to zero-width labels without a text measurer', () => {
LGraphCanvas._measureText = undefined
const { inputNode } = createArrangedInputNode()
inputNode.arrange()
expect(inputNode.size[0]).toBeGreaterThan(0)
})
})
describe('serialisation', () => {
it('round-trips pinned state', () => {
const { inputNode } = createArrangedInputNode()
inputNode.configure({
id: inputNode.id,
bounding: [5, 6, 150, 100],
pinned: true
})
expect(inputNode.pinned).toBe(true)
expect(inputNode.asSerialisable().pinned).toBe(true)
inputNode.configure({ id: inputNode.id, bounding: [5, 6, 150, 100] })
expect(inputNode.pinned).toBe(false)
expect(inputNode.asSerialisable().pinned).toBeUndefined()
})
})
describe('draw', () => {
it('draws with hover-dependent stroke styling and restores context state', () => {
const { inputNode } = createArrangedInputNode()
const strokeStyles: unknown[] = []
const ctx = fromPartial<CanvasRenderingContext2D>({
getTransform: vi.fn(() => new DOMMatrix()),
setTransform: vi.fn(),
translate: vi.fn(),
beginPath: vi.fn(),
arc: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(() => {
strokeStyles.push(ctx.strokeStyle)
}),
fill: vi.fn(),
rect: vi.fn(),
fillText: vi.fn(),
lineWidth: 1,
strokeStyle: 'original',
fillStyle: 'original',
font: 'original',
textBaseline: 'alphabetic'
})
const colorContext = {
getConnectedColor: () => '#0f0',
getDisconnectedColor: () => '#f00'
}
inputNode.draw(ctx, colorContext)
const [defaultStroke] = strokeStyles
inputNode.onPointerEnter()
inputNode.draw(ctx, colorContext)
const [, hoverStroke] = strokeStyles
expect(defaultStroke).not.toBe(hoverStroke)
expect(ctx.strokeStyle).toBe('original')
expect(ctx.fillStyle).toBe('original')
expect(ctx.font).toBe('original')
})
})
})

View File

@@ -1,657 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { fromPartial } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import type { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
import type {
INodeInputSlot,
INodeOutputSlot,
Point
} from '@/lib/litegraph/src/interfaces'
import { RenderShape } from '@/lib/litegraph/src/types/globalEnums'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { SubgraphIO } from '@/lib/litegraph/src/types/serialisation'
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { toLinkId } from '@/types/linkId'
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
import { SubgraphInput } from './SubgraphInput'
import {
createTestSubgraph,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
afterEach(() => {
vi.restoreAllMocks()
})
function createIoSubgraph() {
return createTestSubgraph({
inputs: [{ name: 'in', type: 'STRING' }],
outputs: [{ name: 'out', type: 'STRING' }]
})
}
function addInnerNode(subgraph: Subgraph, type = 'STRING') {
const node = new LGraphNode('Inner')
node.addInput('in', type)
node.addOutput('out', type)
subgraph.add(node)
return node
}
const colorContext = {
getConnectedColor: () => '#0f0',
getDisconnectedColor: () => '#f00'
}
describe('SubgraphOutput.connect', () => {
it('rejects type-incompatible connections', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph, 'INT')
const link = subgraph.outputs[0].connect(node.outputs[0], node)
expect(link).toBeUndefined()
expect(subgraph.outputs[0].linkIds).toHaveLength(0)
})
it('throws when the slot does not belong to the given node', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
const otherNode = addInnerNode(subgraph)
expect(() =>
subgraph.outputs[0].connect(otherNode.outputs[0], node)
).toThrow('Slot is not an output of the given node')
})
it('lets nodes veto the connection via onConnectOutput', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
node.onConnectOutput = () => false
expect(subgraph.outputs[0].connect(node.outputs[0], node)).toBeUndefined()
})
it('replaces an existing connection', () => {
const subgraph = createIoSubgraph()
const first = addInnerNode(subgraph)
const second = addInnerNode(subgraph)
subgraph.outputs[0].connect(first.outputs[0], first)
const replacement = subgraph.outputs[0].connect(second.outputs[0], second)
expect(replacement).toBeDefined()
expect(subgraph.outputs[0].linkIds).toEqual([replacement?.id])
expect(first.outputs[0].links).toEqual([])
expect(second.outputs[0].links).toEqual([replacement?.id])
})
})
describe('SubgraphOutput.disconnect', () => {
it('skips dangling link ids', () => {
const subgraph = createIoSubgraph()
subgraph.outputs[0].linkIds.push(toLinkId(999))
subgraph.outputs[0].disconnect()
expect(subgraph.outputs[0].linkIds).toHaveLength(0)
})
it('removes link references from the origin output', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
const onConnectionsChange = vi.fn()
node.onConnectionsChange = onConnectionsChange
subgraph.outputs[0].connect(node.outputs[0], node)
subgraph.outputs[0].disconnect()
expect(node.outputs[0].links).toEqual([])
expect(onConnectionsChange).toHaveBeenLastCalledWith(
expect.anything(),
0,
false,
expect.anything(),
subgraph.outputs[0]
)
})
})
describe('SubgraphOutput.isValidTarget', () => {
it('accepts a compatible subgraph input as source', () => {
const subgraph = createIoSubgraph()
expect(subgraph.outputs[0].isValidTarget(subgraph.inputs[0])).toBe(true)
})
})
describe('SubgraphInput.connect', () => {
it('lets nodes veto the connection via onConnectInput', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
node.onConnectInput = () => false
expect(subgraph.inputs[0].connect(node.inputs[0], node)).toBeUndefined()
})
it('disconnects an existing link on the target input first', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'a', type: 'STRING' },
{ name: 'b', type: 'STRING' }
]
})
const node = addInnerNode(subgraph)
subgraph.inputs[0].connect(node.inputs[0], node)
const replacement = subgraph.inputs[1].connect(node.inputs[0], node)
expect(replacement).toBeDefined()
expect(node.inputs[0].link).toBe(replacement?.id)
expect(subgraph.inputs[0].linkIds).toHaveLength(0)
})
it('rejects widget inputs that do not match the bound widget', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const subgraph = createIoSubgraph()
const textNode = new LGraphNode('Text')
const textInput = textNode.addInput('value', 'STRING')
textNode.addWidget('text', 'value', '', () => {})
textInput.widget = { name: 'value' }
subgraph.add(textNode)
const numberNode = new LGraphNode('Number')
const numberInput = numberNode.addInput('value', 'STRING')
numberNode.addWidget('number', 'value', 0, () => {})
numberInput.widget = { name: 'value' }
subgraph.add(numberNode)
const first = subgraph.inputs[0].connect(textInput, textNode)
const second = subgraph.inputs[0].connect(numberInput, numberNode)
expect(first).toBeDefined()
expect(second).toBeUndefined()
expect(warn).toHaveBeenCalledWith(
'Target input has invalid widget.',
numberInput,
numberNode
)
})
})
describe('SubgraphInput.matchesWidget', () => {
it('accepts any widget when none is bound', () => {
const subgraph = createIoSubgraph()
expect(
subgraph.inputs[0].matchesWidget(
fromPartial({ type: 'text', options: {} })
)
).toBe(true)
})
it('compares type and numeric constraint options', () => {
const subgraph = createIoSubgraph()
const node = new LGraphNode('Widget Host')
const input = node.addInput('value', 'STRING')
node.addWidget('number', 'value', 0, () => {}, { min: 0, max: 10 })
input.widget = { name: 'value' }
subgraph.add(node)
subgraph.inputs[0].connect(input, node)
const boundOptions = { min: 0, max: 10 }
expect(
subgraph.inputs[0].matchesWidget(
fromPartial({ type: 'number', options: { ...boundOptions } })
)
).toBe(true)
expect(
subgraph.inputs[0].matchesWidget(
fromPartial({ type: 'number', options: { ...boundOptions, min: 5 } })
)
).toBe(false)
expect(
subgraph.inputs[0].matchesWidget(
fromPartial({ type: 'text', options: { ...boundOptions } })
)
).toBe(false)
})
})
describe('SubgraphInput.getConnectedWidgets', () => {
it('reports an error for dangling link ids', () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const subgraph = createIoSubgraph()
subgraph.inputs[0].linkIds.push(toLinkId(999))
expect(subgraph.inputs[0].getConnectedWidgets()).toEqual([])
expect(error).toHaveBeenCalledWith('Link not found', 999)
})
it('skips inputs without widgets', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
node.addWidget('text', 'unrelated', '', () => {})
subgraph.inputs[0].connect(node.inputs[0], node)
expect(subgraph.inputs[0].getConnectedWidgets()).toEqual([])
})
it('warns when the referenced widget cannot be found', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const subgraph = createIoSubgraph()
const node = new LGraphNode('Widget Host')
const input = node.addInput('value', 'STRING')
node.addWidget('text', 'value', '', () => {})
input.widget = { name: 'value' }
subgraph.add(node)
subgraph.inputs[0].connect(input, node)
input.widget = { name: 'missing' }
expect(subgraph.inputs[0].getConnectedWidgets()).toEqual([])
expect(warn).toHaveBeenCalledWith('Widget not found', { name: 'missing' })
input.widget = { name: '' }
expect(subgraph.inputs[0].getConnectedWidgets()).toEqual([])
expect(warn).toHaveBeenCalledWith('Invalid widget name', { name: '' })
})
it('returns widgets for connected widget inputs', () => {
const subgraph = createIoSubgraph()
const node = new LGraphNode('Widget Host')
const input = node.addInput('value', 'STRING')
const widget = node.addWidget('text', 'value', '', () => {})
input.widget = { name: 'value' }
subgraph.add(node)
subgraph.inputs[0].connect(input, node)
expect(subgraph.inputs[0].getConnectedWidgets()).toEqual([widget])
})
})
describe('SubgraphInput.isValidTarget', () => {
it('accepts a compatible subgraph output as source', () => {
const subgraph = createIoSubgraph()
expect(subgraph.inputs[0].isValidTarget(subgraph.outputs[0])).toBe(true)
})
})
describe('SubgraphSlot base behaviour', () => {
it('ignores malformed positions', () => {
const subgraph = createIoSubgraph()
const slot = subgraph.inputs[0]
slot.pos = [3, 4]
slot.pos = fromPartial<Point>([5])
expect([slot.pos[0], slot.pos[1]]).toEqual([3, 4])
})
it('generates an id when the serialised slot has none', () => {
const subgraph = createIoSubgraph()
const slot = new SubgraphInput(
fromPartial<SubgraphIO>({ name: 'anon', type: 'STRING', linkIds: [] }),
subgraph.inputNode
)
expect(slot.id).toEqual(expect.any(String))
expect(slot.id.length).toBeGreaterThan(0)
})
it('skips dangling link ids in getLinks', () => {
const subgraph = createIoSubgraph()
subgraph.inputs[0].linkIds.push(toLinkId(999))
expect(subgraph.inputs[0].getLinks()).toEqual([])
})
it('decrements link slot indices and warns on dangling ids', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
const link = subgraph.outputs[0].connect(node.outputs[0], node)
const slot = subgraph.outputs[0]
slot.decrementSlots('outputs')
expect(link?.target_slot).toBe(-1)
slot.linkIds.push(toLinkId(999))
slot.decrementSlots('outputs')
expect(warn).toHaveBeenCalledWith('decrementSlots: link ID not found', 999)
})
describe('draw', () => {
it('draws a simple square in low quality', () => {
const subgraph = createIoSubgraph()
const ctx = createMockCanvasRenderingContext2D()
subgraph.inputs[0].draw({ ctx, colorContext, lowQuality: true })
expect(ctx.rect).toHaveBeenCalledTimes(1)
expect(ctx.arc).not.toHaveBeenCalled()
})
it('strokes hollow circles with a hover-dependent radius', () => {
const subgraph = createIoSubgraph()
const slot = subgraph.inputs[0]
slot.shape = RenderShape.HollowCircle
const ctx = createMockCanvasRenderingContext2D()
slot.draw({ ctx, colorContext })
slot.isPointerOver = true
slot.draw({ ctx, colorContext })
expect(ctx.arc).toHaveBeenNthCalledWith(1, 0, 0, 3, 0, Math.PI * 2)
expect(ctx.arc).toHaveBeenNthCalledWith(2, 0, 0, 4, 0, Math.PI * 2)
expect(ctx.stroke).toHaveBeenCalledTimes(2)
})
it('enlarges the highlighted filled circle', () => {
const subgraph = createIoSubgraph()
const slot = subgraph.inputs[0]
slot.isPointerOver = true
const ctx = createMockCanvasRenderingContext2D()
slot.draw({ ctx, colorContext })
expect(ctx.arc).toHaveBeenCalledWith(0, 0, 5, 0, Math.PI * 2)
})
it('dims slots that are invalid targets for the dragged link', () => {
const subgraph = createIoSubgraph()
const slot = subgraph.inputs[0]
const alphas: number[] = []
const ctx = createMockCanvasRenderingContext2D({
fill: vi.fn(() => {
alphas.push(ctx.globalAlpha)
})
})
// Dragging from an incompatible output slot.
const incompatible = fromPartial<INodeOutputSlot>({
name: 'other',
type: 'INT',
links: null,
boundingRect: [0, 0, 0, 0]
})
slot.draw({ ctx, colorContext, fromSlot: incompatible })
expect(alphas[0]).toBeCloseTo(0.4)
})
it('falls back to the default label colour when unset', () => {
const originalColor = LiteGraph.NODE_TEXT_COLOR
try {
LiteGraph.NODE_TEXT_COLOR = ''
const subgraph = createIoSubgraph()
const fillStyles: unknown[] = []
const ctx = createMockCanvasRenderingContext2D({
fillText: vi.fn(() => {
fillStyles.push(ctx.fillStyle)
})
})
subgraph.inputs[0].draw({ ctx, colorContext })
expect(fillStyles).toEqual(['#AAA'])
} finally {
LiteGraph.NODE_TEXT_COLOR = originalColor
}
})
})
})
describe('SubgraphOutputNode interaction', () => {
function createArrangedOutputNode() {
const subgraph = createIoSubgraph()
const outputNode = subgraph.outputNode
outputNode.configure({ id: outputNode.id, bounding: [0, 0, 150, 100] })
outputNode.arrange()
return { subgraph, outputNode }
}
function slotCentre(slot: { boundingRect: ArrayLike<number> }) {
const [x, y, width, height] = Array.from(slot.boundingRect)
return [x + width / 2, y + height / 2] as const
}
it('wires drag handlers on left-click over a slot', () => {
const { subgraph, outputNode } = createArrangedOutputNode()
const slot = outputNode.slots[0]
const pointer = fromPartial<CanvasPointer>({})
const linkConnector = fromPartial<LinkConnector>({
dragNewFromSubgraphOutput: vi.fn(),
dropLinks: vi.fn(),
reset: vi.fn()
})
const [x, y] = slotCentre(slot)
outputNode.onPointerDown(
fromPartial<CanvasPointerEvent>({ canvasX: x, canvasY: y, button: 0 }),
pointer,
linkConnector
)
pointer.onDragStart?.(pointer)
expect(linkConnector.dragNewFromSubgraphOutput).toHaveBeenCalledWith(
subgraph,
outputNode,
slot
)
pointer.onDragEnd?.(fromPartial<CanvasPointerEvent>({}))
expect(linkConnector.dropLinks).toHaveBeenCalled()
pointer.finally?.()
expect(linkConnector.reset).toHaveBeenCalledWith(true)
})
it('shows the slot context menu on right-click', () => {
const { outputNode } = createArrangedOutputNode()
const OriginalContextMenu = LiteGraph.ContextMenu
let constructed = false
LiteGraph.ContextMenu = fromPartial<typeof LiteGraph.ContextMenu>(
class {
constructor() {
constructed = true
}
}
)
try {
const [x, y] = slotCentre(outputNode.slots[0])
outputNode.onPointerDown(
fromPartial<CanvasPointerEvent>({ canvasX: x, canvasY: y, button: 2 }),
fromPartial<CanvasPointer>({}),
fromPartial<LinkConnector>({})
)
expect(constructed).toBe(true)
constructed = false
outputNode.onPointerDown(
fromPartial<CanvasPointerEvent>({
canvasX: 500,
canvasY: 500,
button: 2
}),
fromPartial<CanvasPointer>({}),
fromPartial<LinkConnector>({})
)
expect(constructed).toBe(false)
} finally {
LiteGraph.ContextMenu = OriginalContextMenu
}
})
it('connects by type through connectByTypeOutput', () => {
const { subgraph, outputNode } = createArrangedOutputNode()
const node = addInnerNode(subgraph)
const link = outputNode.connectByTypeOutput(0, node, 'STRING')
expect(link).toBeDefined()
expect(subgraph.outputs[0].linkIds).toEqual([link?.id])
})
it('returns undefined when no output of the requested type exists', () => {
const { outputNode } = createArrangedOutputNode()
const node = new LGraphNode('No Outputs')
expect(outputNode.connectByTypeOutput(0, node, 'STRING')).toBeUndefined()
})
})
describe('SubgraphInputNode connections', () => {
it('throws for invalid slot indices in connectSlots', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
expect(() =>
subgraph.inputNode.connectSlots(
fromPartial<SubgraphInput>({}),
node,
node.inputs[0],
undefined
)
).toThrow('Invalid slot indices.')
})
it('creates links via connectSlots, preferring the input type', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
const link = subgraph.inputNode.connectSlots(
subgraph.inputs[0],
node,
node.inputs[0],
undefined
)
expect(link.type).toBe('STRING')
expect(String(link.origin_id)).toBe(String(subgraph.inputNode.id))
})
it('falls back to the subgraph slot type for untyped inputs', () => {
const subgraph = createIoSubgraph()
const node = new LGraphNode('Untyped')
node.addInput('in', '')
subgraph.add(node)
const link = subgraph.inputNode.connectSlots(
subgraph.inputs[0],
node,
node.inputs[0],
undefined
)
expect(link.type).toBe('STRING')
})
it('connects an existing slot directly via connectByType', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
const link = subgraph.inputNode.connectByType(0, node, 'STRING')
expect(link).toBeDefined()
expect(subgraph.inputs[0].linkIds).toEqual([link?.id])
})
it('returns undefined from connectByType when no input matches', () => {
const subgraph = createIoSubgraph()
const node = new LGraphNode('No Inputs')
expect(subgraph.inputNode.connectByType(0, node, 'STRING')).toBeUndefined()
})
it('finds output slots by name and type', () => {
const subgraph = createIoSubgraph()
expect(subgraph.inputNode.findOutputSlot('in')).toBe(subgraph.inputs[0])
expect(subgraph.inputNode.findOutputSlot('nope')).toBeUndefined()
expect(subgraph.inputNode.findOutputByType('STRING')).toBe(
subgraph.inputs[0]
)
})
describe('_disconnectNodeInput corruption handling', () => {
it('clears the input without a link', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
subgraph.inputs[0].connect(node.inputs[0], node)
subgraph.inputNode._disconnectNodeInput(node, node.inputs[0], undefined)
expect(node.inputs[0].link).toBeNull()
})
it('warns when the link references a missing subgraph input slot', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
const link = subgraph.inputs[0].connect(node.inputs[0], node)
if (!link) throw new Error('Failed to connect')
// Characterises corruption handling: link points at a nonexistent slot.
link.origin_slot = 99
subgraph.inputNode._disconnectNodeInput(node, node.inputs[0], link)
expect(warn).toHaveBeenCalledWith(
'disconnectNodeInput: subgraphInput not found',
subgraph.inputNode,
99
)
})
it('warns when the slot does not list the link id', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
const link = subgraph.inputs[0].connect(node.inputs[0], node)
if (!link) throw new Error('Failed to connect')
// Characterises corruption handling: the slot lost its link id.
subgraph.inputs[0].linkIds.length = 0
subgraph.inputNode._disconnectNodeInput(node, node.inputs[0], link)
expect(warn).toHaveBeenCalledWith(
'disconnectNodeInput: link ID not found in subgraphInput linkIds',
link.id
)
})
it('skips connection callbacks for foreign inputs', () => {
const subgraph = createIoSubgraph()
const node = addInnerNode(subgraph)
const onConnectionsChange = vi.fn()
node.onConnectionsChange = onConnectionsChange
const link = subgraph.inputs[0].connect(node.inputs[0], node)
if (!link) throw new Error('Failed to connect')
const foreignInput = fromPartial<INodeInputSlot>({
name: 'foreign',
type: 'STRING',
link: null,
boundingRect: [0, 0, 0, 0]
})
subgraph.inputNode._disconnectNodeInput(node, foreignInput, link)
expect(onConnectionsChange).not.toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
false,
expect.anything(),
expect.anything()
)
})
})
})

View File

@@ -1,69 +1,20 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { fromPartial } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it } from 'vitest'
import type {
INodeInputSlot,
INodeOutputSlot,
Positionable
} from '@/lib/litegraph/src/interfaces'
import {
LGraph,
LGraphGroup,
LGraphNode,
LLink,
LiteGraph,
findUsedSubgraphIds,
getDirectSubgraphIds
} from '@/lib/litegraph/src/litegraph'
import type { UUID } from '@/lib/litegraph/src/litegraph'
import type { ResolvedConnection } from '@/lib/litegraph/src/LLink'
import type { Reroute } from '@/lib/litegraph/src/Reroute'
import type { SerialisableLLink } from '@/lib/litegraph/src/types/serialisation'
import { toRerouteId } from '@/types/rerouteId'
import { toLinkId } from '@/types/linkId'
import { createMockPositionable } from '@/utils/__tests__/litegraphTestUtils'
import type { SubgraphInput } from './SubgraphInput'
import {
getBoundaryLinks,
groupResolvedByOutput,
mapSubgraphInputsAndLinks,
mapSubgraphOutputsAndLinks,
multiClone,
reorderSubgraphInputs,
splitPositionables
} from './subgraphUtils'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
/** Creates a graph with three chained nodes: a -> b -> c. */
function createChainedGraph() {
const graph = new LGraph()
const a = new LGraphNode('A')
a.addOutput('out', 'number')
const b = new LGraphNode('B')
b.addInput('in', 'number')
b.addOutput('out', 'number')
const c = new LGraphNode('C')
c.addInput('in', 'number')
graph.add(a)
graph.add(b)
graph.add(c)
const linkAb = a.connect(0, b, 0)
const linkBc = b.connect(0, c, 0)
if (!linkAb || !linkBc) throw new Error('Failed to connect test nodes')
return { graph, a, b, c, linkAb, linkBc }
}
describe('subgraphUtils', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -194,389 +145,4 @@ describe('subgraphUtils', () => {
expect(result.has(subgraph2.id)).toBe(true) // Still found, just can't recurse into it
})
})
describe('splitPositionables', () => {
it('splits items into typed buckets', () => {
const { graph, a, linkAb } = createChainedGraph()
const group = new LGraphGroup('Test Group')
const reroute = graph.createReroute([0, 0], linkAb)
if (!reroute) throw new Error('Failed to create reroute')
const subgraph = createTestSubgraph()
const unknown = createMockPositionable()
const result = splitPositionables([
a,
group,
reroute,
subgraph.inputNode,
subgraph.outputNode,
unknown
])
expect(result.nodes).toEqual(new Set([a]))
expect(result.groups).toEqual(new Set([group]))
expect(result.reroutes).toEqual(new Set([reroute]))
expect(result.subgraphInputNodes).toEqual(new Set([subgraph.inputNode]))
expect(result.subgraphOutputNodes).toEqual(new Set([subgraph.outputNode]))
expect(result.unknown).toEqual(new Set([unknown]))
})
})
describe('getBoundaryLinks', () => {
it('classifies links crossing into and out of the item set', () => {
const { graph, b, linkAb, linkBc } = createChainedGraph()
const result = getBoundaryLinks(graph, new Set<Positionable>([b]))
expect(result.boundaryInputLinks.map((l) => l.id)).toEqual([linkAb.id])
expect(result.boundaryOutputLinks.map((l) => l.id)).toEqual([linkBc.id])
expect(result.internalLinks).toEqual([])
})
it('classifies links between selected nodes as internal', () => {
const { graph, a, b, linkAb, linkBc } = createChainedGraph()
const result = getBoundaryLinks(graph, new Set<Positionable>([a, b]))
expect(result.internalLinks.map((l) => l.id)).toEqual([linkAb.id])
expect(result.boundaryInputLinks).toEqual([])
expect(result.boundaryOutputLinks.map((l) => l.id)).toEqual([linkBc.id])
})
it('treats subgraph IO links as boundary links', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'in', type: 'number' }],
outputs: [{ name: 'out', type: 'number' }]
})
const node = new LGraphNode('Inner')
node.addInput('in', 'number')
node.addOutput('out', 'number')
subgraph.add(node)
subgraph.inputs[0].connect(node.inputs[0], node)
subgraph.outputs[0].connect(node.outputs[0], node)
const result = getBoundaryLinks(subgraph, new Set<Positionable>([node]))
expect(result.boundaryInputLinks).toHaveLength(1)
expect(result.boundaryOutputLinks).toHaveLength(1)
})
it('marks reroute links as boundary when an endpoint is outside the set', () => {
const { graph, a, b, linkAb } = createChainedGraph()
const reroute = graph.createReroute([0, 0], linkAb)
if (!reroute) throw new Error('Failed to create reroute')
const boundary = getBoundaryLinks(graph, new Set<Positionable>([reroute]))
expect(boundary.boundaryLinks.map((l) => l.id)).toEqual([linkAb.id])
const contained = getBoundaryLinks(
graph,
new Set<Positionable>([a, b, reroute])
)
expect(contained.boundaryLinks).toEqual([])
})
it('collects floating links whose reroutes cross the boundary', () => {
const { graph, a, b, linkAb } = createChainedGraph()
const reroute = graph.createReroute([0, 0], linkAb)
if (!reroute) throw new Error('Failed to create reroute')
// Removing the output side turns the link into a floating link.
graph.remove(a)
expect(graph.floatingLinks.size).toBe(1)
// The floating link's reroute is outside the item set.
const crossing = getBoundaryLinks(graph, new Set<Positionable>([b]))
expect(crossing.boundaryFloatingLinks).toHaveLength(1)
// With the reroute inside the set, the floating link does not cross.
const contained = getBoundaryLinks(
graph,
new Set<Positionable>([b, reroute])
)
expect(contained.boundaryFloatingLinks).toEqual([])
})
})
describe('multiClone', () => {
class CloneTestNode extends LGraphNode {
constructor() {
super('CloneTest')
this.addInput('in', 'number')
}
}
beforeEach(() => {
LiteGraph.registerNodeType('test/CloneTest', CloneTestNode)
})
afterEach(() => {
LiteGraph.unregisterNodeType('test/CloneTest')
vi.restoreAllMocks()
})
it('clones registered nodes preserving ids', () => {
const graph = new LGraph()
const node = LiteGraph.createNode('test/CloneTest')
if (!node) throw new Error('Failed to create node')
graph.add(node)
const cloned = multiClone([node])
expect(cloned).toHaveLength(1)
expect(String(cloned[0].id)).toBe(String(node.id))
expect(cloned[0].type).toBe('test/CloneTest')
})
it('falls back to serialised data for unregistered node types', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const graph = new LGraph()
const node = new LGraphNode('Mystery')
node.type = 'test/UnregisteredType'
graph.add(node)
const cloned = multiClone([node])
expect(cloned).toHaveLength(1)
expect(cloned[0].type).toBe('test/UnregisteredType')
expect(warn).toHaveBeenCalled()
})
})
describe('groupResolvedByOutput', () => {
it('groups connections sharing an output and isolates unresolvable ones', () => {
const output = fromPartial<ResolvedConnection['output']>({
name: 'shared'
})
const first = fromPartial<ResolvedConnection>({ output })
const second = fromPartial<ResolvedConnection>({ output })
const bySubgraphInput = fromPartial<ResolvedConnection>({
subgraphInput: fromPartial<SubgraphInput>({ name: 'sub' })
})
const unresolvable = fromPartial<ResolvedConnection>({})
const grouped = groupResolvedByOutput([
first,
second,
bySubgraphInput,
unresolvable
])
expect(grouped.size).toBe(3)
expect(grouped.get(output!)).toEqual([first, second])
})
})
describe('mapSubgraphInputsAndLinks', () => {
function createResolvedInput(
linkId: number,
inputOverrides: Record<string, unknown> = {}
): { resolved: ResolvedConnection; link: LLink } {
const link = new LLink(toLinkId(linkId), 'number', 1, 0, 2, 0)
const resolved = fromPartial<ResolvedConnection>({
link,
input: fromPartial<INodeInputSlot>({
name: 'in',
type: 'number',
...inputOverrides
}),
output: fromPartial<INodeOutputSlot>({ name: 'out', type: 'number' })
})
return { resolved, link }
}
it('creates one subgraph input per distinct output', () => {
const { resolved } = createResolvedInput(1)
const links: SerialisableLLink[] = []
const inputs = mapSubgraphInputsAndLinks([resolved], links, new Map())
expect(inputs).toHaveLength(1)
expect(inputs[0].name).toBe('in')
expect(inputs[0].localized_name).toBeUndefined()
expect(links).toHaveLength(1)
expect(links[0].origin_slot).toBe(0)
})
it('deduplicates names and localised names across inputs', () => {
const first = createResolvedInput(1, { localized_name: 'In' })
const second = createResolvedInput(2, { localized_name: 'In' })
const links: SerialisableLLink[] = []
const inputs = mapSubgraphInputsAndLinks(
[first.resolved, second.resolved],
links,
new Map()
)
expect(inputs.map((i) => i.name)).toEqual(['in', 'in_1'])
expect(inputs.map((i) => i.localized_name)).toEqual(['In', 'In_1'])
})
it('skips connections without a resolved input', () => {
const link = new LLink(toLinkId(1), 'number', 1, 0, 2, 0)
const resolved = fromPartial<ResolvedConnection>({
link,
output: fromPartial<INodeOutputSlot>({ name: 'out', type: 'number' })
})
const inputs = mapSubgraphInputsAndLinks([resolved], [], new Map())
expect(inputs).toEqual([])
})
it('rewires reroute parents to the last reroute outside the subgraph', () => {
const { resolved, link } = createResolvedInput(1)
link.parentId = toRerouteId(10)
const insideReroute = fromPartial<Reroute>({ parentId: toRerouteId(99) })
const reroutes = new Map<ReturnType<typeof toRerouteId>, Reroute>([
[toRerouteId(10), insideReroute]
])
mapSubgraphInputsAndLinks([resolved], [], reroutes)
// The chain terminated at reroute 99, which is not in the map.
expect(link.parentId).toBe(toRerouteId(99))
expect(insideReroute.parentId).toBeUndefined()
})
})
describe('mapSubgraphOutputsAndLinks', () => {
function createResolvedOutput(
linkId: number,
outputOverrides: Record<string, unknown> = {}
): { resolved: ResolvedConnection; link: LLink } {
const link = new LLink(toLinkId(linkId), 'number', 1, 0, 2, 0)
const resolved = fromPartial<ResolvedConnection>({
link,
input: fromPartial<INodeInputSlot>({ name: 'in', type: 'number' }),
output: fromPartial<INodeOutputSlot>({
name: 'out',
type: 'number',
...outputOverrides
})
})
return { resolved, link }
}
it('creates one subgraph output per distinct output slot', () => {
const { resolved } = createResolvedOutput(1)
const links: SerialisableLLink[] = []
const outputs = mapSubgraphOutputsAndLinks([resolved], links, new Map())
expect(outputs).toHaveLength(1)
expect(outputs[0].name).toBe('out')
expect(outputs[0].localized_name).toBeUndefined()
expect(links).toHaveLength(1)
expect(links[0].target_slot).toBe(0)
})
it('deduplicates localised names across outputs', () => {
const first = createResolvedOutput(1, { localized_name: 'Out' })
const second = createResolvedOutput(2, { localized_name: 'Out' })
const outputs = mapSubgraphOutputsAndLinks(
[first.resolved, second.resolved],
[],
new Map()
)
expect(outputs.map((o) => o.name)).toEqual(['out', 'out_1'])
expect(outputs.map((o) => o.localized_name)).toEqual(['Out', 'Out_1'])
})
it('skips connections without a resolved output', () => {
const link = new LLink(toLinkId(1), 'number', 1, 0, 2, 0)
const resolved = fromPartial<ResolvedConnection>({
link,
input: fromPartial<INodeInputSlot>({ name: 'in', type: 'number' })
})
const outputs = mapSubgraphOutputsAndLinks([resolved], [], new Map())
expect(outputs).toEqual([])
})
})
describe('reorderSubgraphInputs', () => {
it('returns silently when the node has no subgraph', () => {
const subgraphNode = createTestSubgraphNode(createTestSubgraph())
Object.defineProperty(subgraphNode, 'subgraph', {
value: undefined,
configurable: true
})
expect(() => reorderSubgraphInputs(subgraphNode, [])).not.toThrow()
})
it('rejects indices that are not a permutation', () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const subgraph = createTestSubgraph({ inputCount: 2 })
const subgraphNode = createTestSubgraphNode(subgraph)
const originalOrder = subgraph.inputs.map((i) => i.id)
reorderSubgraphInputs(subgraphNode, [0]) // wrong length
reorderSubgraphInputs(subgraphNode, [0, 0]) // duplicate
reorderSubgraphInputs(subgraphNode, [0, 2]) // out of range
expect(error).toHaveBeenCalledTimes(3)
expect(subgraph.inputs.map((i) => i.id)).toEqual(originalOrder)
vi.restoreAllMocks()
})
it('does not dispatch an event for an identity permutation', () => {
const subgraph = createTestSubgraph({ inputCount: 2 })
const subgraphNode = createTestSubgraphNode(subgraph)
const listener = vi.fn()
subgraph.events.addEventListener('inputs-reordered', listener)
reorderSubgraphInputs(subgraphNode, [0, 1])
expect(listener).not.toHaveBeenCalled()
})
it('reorders slots and updates link slot indices', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'alpha', type: 'number' },
{ name: 'beta', type: 'number' }
]
})
const inner = new LGraphNode('Inner')
inner.addInput('a', 'number')
inner.addInput('b', 'number')
subgraph.add(inner)
subgraph.inputs[0].connect(inner.inputs[0], inner)
subgraph.inputs[1].connect(inner.inputs[1], inner)
const subgraphNode = createTestSubgraphNode(subgraph)
subgraph.rootGraph.add(subgraphNode)
const outer = new LGraphNode('Outer')
outer.addOutput('out', 'number')
subgraph.rootGraph.add(outer)
outer.connect(0, subgraphNode, 0)
const listener = vi.fn()
subgraph.events.addEventListener('inputs-reordered', listener)
reorderSubgraphInputs(subgraphNode, [1, 0])
expect(subgraph.inputs.map((i) => i.name)).toEqual(['beta', 'alpha'])
// Inner links follow their reordered slots.
const innerLinkSlots = subgraph.inputs.map((input) =>
input.linkIds.map((id) => subgraph.getLink(id)?.origin_slot)
)
expect(innerLinkSlots).toEqual([[0], [1]])
// The outer link now targets the moved slot.
const outerLinkId = subgraphNode.inputs.find(
(input) => input.link != null
)?.link
expect(outerLinkId).not.toBeNull()
const outerLink = subgraph.rootGraph.getLink(outerLinkId!)
expect(outerLink?.target_slot).toBe(1)
expect(listener).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -1,6 +1,5 @@
import { isEqual } from 'es-toolkit'
import type { LGraph, SubgraphId } from '@/lib/litegraph/src/LGraph'
import type { SubgraphNode } from './SubgraphNode'
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
@@ -29,6 +28,7 @@ import type {
import type { GraphOrSubgraph } from './Subgraph'
import type { SubgraphInput } from './SubgraphInput'
import { SubgraphInputNode } from './SubgraphInputNode'
import type { SubgraphNode } from './SubgraphNode'
import type { SubgraphOutput } from './SubgraphOutput'
import { SubgraphOutputNode } from './SubgraphOutputNode'

View File

@@ -1,135 +0,0 @@
import { describe, expect, it } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { Direction } from '@/lib/litegraph/src/interfaces'
import { alignNodes, distributeNodes, getBoundaryNodes } from './arrange'
function createNode(x: number, y: number, width: number, height: number) {
const node = new LGraphNode('Test Node')
node.pos = [x, y]
node.size = [width, height]
return node
}
describe('getBoundaryNodes', () => {
it('returns null when no nodes are supplied', () => {
expect(getBoundaryNodes([])).toBeNull()
expect(getBoundaryNodes(undefined)).toBeNull()
})
it('returns null when all nodes are falsy', () => {
expect(getBoundaryNodes([undefined, null])).toBeNull()
})
it('returns the same node for all edges with a single node', () => {
const node = createNode(10, 20, 100, 50)
expect(getBoundaryNodes([node])).toEqual({
top: node,
right: node,
bottom: node,
left: node
})
})
it('finds the farthest node in each direction', () => {
const topLeft = createNode(0, 0, 10, 10)
const bottomRight = createNode(200, 200, 50, 50)
const middle = createNode(100, 100, 10, 10)
const boundary = getBoundaryNodes([middle, topLeft, bottomRight])
expect(boundary).not.toBeNull()
expect(boundary?.top).toBe(topLeft)
expect(boundary?.left).toBe(topLeft)
expect(boundary?.right).toBe(bottomRight)
expect(boundary?.bottom).toBe(bottomRight)
})
it('skips falsy entries while finding boundaries', () => {
const node = createNode(5, 5, 10, 10)
const other = createNode(50, 50, 10, 10)
const boundary = getBoundaryNodes([node, undefined, other])
expect(boundary?.left).toBe(node)
expect(boundary?.right).toBe(other)
})
})
describe('distributeNodes', () => {
it('returns an empty array when fewer than two nodes are supplied', () => {
expect(distributeNodes([])).toEqual([])
expect(distributeNodes([createNode(0, 0, 10, 10)])).toEqual([])
expect(distributeNodes(undefined)).toEqual([])
})
it('distributes nodes evenly along the horizontal plane', () => {
const first = createNode(0, 0, 10, 10)
const last = createNode(100, 0, 20, 10)
const middle = createNode(30, 0, 10, 10)
const positions = distributeNodes([first, last, middle], true)
expect(positions.map(({ node }) => node)).toEqual([first, middle, last])
// Total span 0..120, widths 10 + 10 + 20 = 40, gap = (120 - 40) / 2 = 40
expect(first.pos[0]).toBe(0)
expect(middle.pos[0]).toBe(50)
expect(last.pos[0]).toBe(100)
expect(positions.map(({ newPos }) => newPos.x)).toEqual([0, 50, 100])
})
it('distributes nodes evenly along the vertical plane by default', () => {
const first = createNode(0, 0, 10, 10)
const last = createNode(0, 100, 10, 20)
const middle = createNode(0, 30, 10, 10)
const positions = distributeNodes([first, last, middle])
// Total span 0..120, heights 10 + 10 + 20 = 40, gap = (120 - 40) / 2 = 40
expect(first.pos[1]).toBe(0)
expect(middle.pos[1]).toBe(50)
expect(last.pos[1]).toBe(100)
expect(positions.map(({ newPos }) => newPos.y)).toEqual([0, 50, 100])
})
})
describe('alignNodes', () => {
it('returns an empty array when nodes are not supplied', () => {
expect(alignNodes(undefined, 'left')).toEqual([])
})
it('returns an empty array when boundary nodes cannot be determined', () => {
expect(alignNodes([], 'left')).toEqual([])
})
it.for<[Direction, [number, number], [number, number]]>([
// Anchor is at [100, 100] with size [50, 50]; node is at [0, 0] size [10, 10].
['left', [100, 0], [100, 100]],
['right', [140, 0], [100, 100]],
['top', [0, 100], [100, 100]],
['bottom', [0, 140], [100, 100]]
])(
'aligns nodes to the %s edge of the anchor node',
([direction, expectedNodePos, expectedAnchorPos]) => {
const node = createNode(0, 0, 10, 10)
const anchor = createNode(100, 100, 50, 50)
const positions = alignNodes([node, anchor], direction, anchor)
expect(positions).toHaveLength(2)
expect([node.pos[0], node.pos[1]]).toEqual(expectedNodePos)
expect([anchor.pos[0], anchor.pos[1]]).toEqual(expectedAnchorPos)
}
)
it('uses boundary nodes when no anchor is supplied', () => {
const left = createNode(0, 0, 10, 10)
const right = createNode(100, 50, 20, 10)
alignNodes([left, right], 'left')
expect(left.pos[0]).toBe(0)
expect(right.pos[0]).toBe(0)
})
})

View File

@@ -7,11 +7,8 @@ import type { Direction, IBoundaryNodes, NewNodePosition } from '../interfaces'
* @returns An object listing the furthest node (edge) in all four directions.
* `null` if no nodes were supplied or the first node was falsy.
*/
export function getBoundaryNodes(
nodes: Array<LGraphNode | null | undefined> | undefined
): IBoundaryNodes | null {
if (!nodes) return null
const valid = nodes.find((x) => x)
export function getBoundaryNodes(nodes: LGraphNode[]): IBoundaryNodes | null {
const valid = nodes?.find((x) => x)
if (!valid) return null
let top = valid
@@ -44,11 +41,11 @@ export function getBoundaryNodes(
* @param horizontal If true, distributes along the horizontal plane. Otherwise, the vertical plane.
*/
export function distributeNodes(
nodes: LGraphNode[] | undefined,
nodes: LGraphNode[],
horizontal?: boolean
): NewNodePosition[] {
if (!nodes || nodes.length <= 1) return []
const nodeCount = nodes.length
const nodeCount = nodes?.length
if (!(nodeCount > 1)) return []
const index = horizontal ? 0 : 1
@@ -90,7 +87,7 @@ export function distributeNodes(
* @param align_to The node to align all other nodes to. If undefined, the farthest node will be used.
*/
export function alignNodes(
nodes: LGraphNode[] | undefined,
nodes: LGraphNode[],
direction: Direction,
align_to?: LGraphNode
): NewNodePosition[] {

View File

@@ -1,150 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { Positionable } from '@/lib/litegraph/src/interfaces'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { createMockPositionable } from '@/utils/__tests__/litegraphTestUtils'
import {
findFirstNode,
findFreeSlotOfType,
getAllNestedItems
} from './collections'
describe('getAllNestedItems', () => {
it('returns an empty set when items are not supplied', () => {
expect(getAllNestedItems(undefined).size).toBe(0)
})
it('excludes pinned items', () => {
const pinned = createMockPositionable({ pinned: true })
const unpinned = createMockPositionable()
const result = getAllNestedItems(new Set([pinned, unpinned]))
expect(result.has(pinned)).toBe(false)
expect(result.has(unpinned)).toBe(true)
})
it('recurses into children and deduplicates shared children', () => {
const shared = createMockPositionable()
const parentA: Positionable = createMockPositionable({
children: new Set([shared])
})
const parentB: Positionable = createMockPositionable({
children: new Set([shared])
})
const result = getAllNestedItems(new Set([parentA, parentB]))
expect(result).toEqual(new Set([parentA, parentB, shared]))
})
it('does not recurse into pinned children', () => {
const pinnedChild = createMockPositionable({ pinned: true })
const parent: Positionable = createMockPositionable({
children: new Set([pinnedChild])
})
const result = getAllNestedItems(new Set([parent]))
expect(result).toEqual(new Set([parent]))
})
})
describe('findFirstNode', () => {
it('returns the first LGraphNode in the collection', () => {
const notANode = createMockPositionable()
const node = new LGraphNode('Test Node')
const otherNode = new LGraphNode('Other Node')
expect(findFirstNode([notANode, node, otherNode])).toBe(node)
})
it('returns undefined when the collection has no nodes', () => {
expect(findFirstNode([createMockPositionable()])).toBeUndefined()
expect(findFirstNode([])).toBeUndefined()
})
})
describe('findFreeSlotOfType', () => {
interface TestSlot {
type: string
free: boolean
}
const hasNoLinks = (slot: TestSlot) => slot.free
it('returns undefined when no slots are supplied', () => {
expect(findFreeSlotOfType([], 'A', hasNoLinks)).toBeUndefined()
expect(findFreeSlotOfType(undefined, 'A', hasNoLinks)).toBeUndefined()
})
it('returns the first free slot with an exact type match', () => {
const slots: TestSlot[] = [
{ type: 'a', free: false },
{ type: 'a', free: true }
]
expect(findFreeSlotOfType(slots, 'A', hasNoLinks)).toEqual({
index: 1,
slot: slots[1]
})
})
it('falls back to an occupied slot with a matching type', () => {
const slots: TestSlot[] = [{ type: 'a', free: false }]
expect(findFreeSlotOfType(slots, 'A', hasNoLinks)).toEqual({
index: 0,
slot: slots[0]
})
})
it('falls back to a free wildcard slot when no types match', () => {
const slots: TestSlot[] = [
{ type: 'b', free: true },
{ type: '*', free: true }
]
expect(findFreeSlotOfType(slots, 'A', hasNoLinks)).toEqual({
index: 1,
slot: slots[1]
})
})
it('falls back to an occupied wildcard slot as a last resort', () => {
const slots: TestSlot[] = [{ type: '*', free: false }]
expect(findFreeSlotOfType(slots, 'A', hasNoLinks)).toEqual({
index: 0,
slot: slots[0]
})
})
it('matches wildcard search types against occupied concrete slots', () => {
const slots: TestSlot[] = [{ type: 'b', free: false }]
expect(findFreeSlotOfType(slots, '*', hasNoLinks)).toEqual({
index: 0,
slot: slots[0]
})
})
it('returns undefined when nothing matches', () => {
const slots: TestSlot[] = [{ type: 'b', free: true }]
expect(findFreeSlotOfType(slots, 'A', hasNoLinks)).toBeUndefined()
})
it('matches any comma-delimited type in the search list', () => {
const slots: TestSlot[] = [
{ type: 'c', free: true },
{ type: 'b,c', free: true }
]
expect(findFreeSlotOfType(slots, 'A,B', hasNoLinks)).toEqual({
index: 1,
slot: slots[1]
})
})
})

View File

@@ -11,7 +11,7 @@ import type { ISlotType, Positionable } from '../interfaces'
* @returns All unpinned items in the original set, and recursively, their children
*/
export function getAllNestedItems(
items: ReadonlySet<Positionable> | undefined
items: ReadonlySet<Positionable>
): Set<Positionable> {
const allItems = new Set<Positionable>()
if (items) {
@@ -61,7 +61,7 @@ type FreeSlotResult<T extends { type: ISlotType }> =
* @returns The index and slot if found, otherwise `undefined`.
*/
export function findFreeSlotOfType<T extends { type: ISlotType }>(
slots: T[] | undefined,
slots: T[],
type: ISlotType,
hasNoLinks: (slot: T) => boolean
) {

View File

@@ -1,70 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { defineDeprecatedProperty, warnDeprecated } from './feedback'
let messageId = 0
/** Unique message per test; warnDeprecated deduplicates per session. */
function uniqueMessage(): string {
messageId += 1
return `test deprecation message ${messageId}`
}
describe('warnDeprecated', () => {
const originalAlwaysRepeat = LiteGraph.alwaysRepeatWarnings
afterEach(() => {
LiteGraph.alwaysRepeatWarnings = originalAlwaysRepeat
LiteGraph.onDeprecationWarning.length = 0
})
it('notifies callbacks once per unique message', () => {
const callback = vi.fn()
LiteGraph.onDeprecationWarning.push(callback)
const message = uniqueMessage()
const source = {}
warnDeprecated(message, source)
warnDeprecated(message, source)
expect(callback).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith(message, source)
})
it('repeats warnings when alwaysRepeatWarnings is enabled', () => {
const callback = vi.fn()
LiteGraph.onDeprecationWarning.push(callback)
LiteGraph.alwaysRepeatWarnings = true
const message = uniqueMessage()
warnDeprecated(message)
warnDeprecated(message)
expect(callback).toHaveBeenCalledTimes(2)
})
})
describe('defineDeprecatedProperty', () => {
afterEach(() => {
LiteGraph.onDeprecationWarning.length = 0
})
it('proxies reads and writes to the current property with a warning', () => {
const callback = vi.fn()
LiteGraph.onDeprecationWarning.push(callback)
const message = uniqueMessage()
const target: { current: number } & Record<string, unknown> = { current: 1 }
defineDeprecatedProperty(target, 'legacy', 'current', message)
expect(target.legacy).toBe(1)
target.legacy = 2
expect(target.current).toBe(2)
expect(callback).toHaveBeenCalledTimes(1)
expect(callback).toHaveBeenCalledWith(message, undefined)
})
})

View File

@@ -1,22 +0,0 @@
import { describe, expect, it } from 'vitest'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { resolveConnectingLinkColor } from './linkColors'
describe('resolveConnectingLinkColor', () => {
it('uses the event link colour for event slots', () => {
expect(resolveConnectingLinkColor(LiteGraph.EVENT)).toBe(
LiteGraph.EVENT_LINK_COLOR
)
})
it('uses the connecting link colour for other slot types', () => {
expect(resolveConnectingLinkColor('STRING')).toBe(
LiteGraph.CONNECTING_LINK_COLOR
)
expect(resolveConnectingLinkColor(undefined)).toBe(
LiteGraph.CONNECTING_LINK_COLOR
)
})
})

View File

@@ -89,13 +89,6 @@ describe('evaluateMathExpression', () => {
}
)
test.for(['-', '2*', '3/-'])(
'dangling operator returns undefined: "%s"',
(input) => {
expect(evaluateMathExpression(input)).toBeUndefined()
}
)
test('division by zero returns Infinity', () => {
expect(evaluateMathExpression('1/0')).toBe(Infinity)
})

View File

@@ -1,59 +0,0 @@
import { describe, expect, it } from 'vitest'
import { commonType, isColorable, isNodeBindable } from './type'
describe('isColorable', () => {
it.for<[string, unknown]>([
['a primitive', 42],
['null', null],
['an object without setColorOption', { getColorOption: () => null }],
['an object without getColorOption', { setColorOption: () => {} }]
])('returns false for %s', ([, value]) => {
expect(isColorable(value)).toBe(false)
})
it('returns true for an object with both color option methods', () => {
const colorable = {
setColorOption: () => {},
getColorOption: () => null
}
expect(isColorable(colorable)).toBe(true)
})
})
describe('isNodeBindable', () => {
it.for<[string, unknown]>([
['a primitive', 'widget'],
['null', null],
['an object without setNodeId', {}],
['an object with a non-function setNodeId', { setNodeId: true }]
])('returns false for %s', ([, value]) => {
expect(isNodeBindable(value)).toBe(false)
})
it('returns true for an object with a setNodeId function', () => {
expect(isNodeBindable({ setNodeId: () => {} })).toBe(true)
})
})
describe('commonType', () => {
it('returns undefined when any type is not a string', () => {
expect(commonType('STRING', -1)).toBeUndefined()
})
it('returns the wildcard when all types are wildcards', () => {
expect(commonType('*', '*')).toBe('*')
})
it('ignores wildcards when other types are present', () => {
expect(commonType('*', 'STRING')).toBe('STRING')
})
it('returns the intersection of comma-delimited type lists', () => {
expect(commonType('A,B', 'B,C')).toBe('B')
})
it('returns undefined when types do not intersect', () => {
expect(commonType('A', 'B')).toBeUndefined()
})
})

View File

@@ -0,0 +1,142 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
import {
createSecret,
deleteSecret,
listSecrets,
updateSecret
} from './secretsApi'
import type { SecretsApiError } from './secretsApi'
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn()
}
}))
const fetchApi = vi.mocked(api.fetchApi)
const secret = {
id: 'secret-1',
name: 'HF token',
provider: 'huggingface' as const,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z'
}
function jsonResponse(body: unknown, init: ResponseInit = {}) {
const headers = new Headers(init.headers)
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
return new Response(JSON.stringify(body), {
status: 200,
...init,
headers
})
}
describe('secretsApi', () => {
beforeEach(() => {
fetchApi.mockReset()
})
it('lists secrets from the API response data field', async () => {
fetchApi.mockResolvedValue(jsonResponse({ data: [secret] }))
await expect(listSecrets()).resolves.toEqual([secret])
expect(fetchApi).toHaveBeenCalledWith('/secrets')
})
it('creates secrets with a JSON payload', async () => {
fetchApi.mockResolvedValue(jsonResponse(secret))
await expect(
createSecret({
name: 'HF token',
secret_value: 'token',
provider: 'huggingface'
})
).resolves.toEqual(secret)
expect(fetchApi).toHaveBeenCalledWith('/secrets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'HF token',
secret_value: 'token',
provider: 'huggingface'
})
})
})
it('updates secrets with a JSON payload', async () => {
fetchApi.mockResolvedValue(jsonResponse(secret))
await expect(
updateSecret('secret-1', { name: 'New name' })
).resolves.toEqual(secret)
expect(fetchApi).toHaveBeenCalledWith('/secrets/secret-1', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'New name' })
})
})
it('deletes secrets without reading a success body', async () => {
fetchApi.mockResolvedValue(new Response(null, { status: 204 }))
await expect(deleteSecret('secret-1')).resolves.toBeUndefined()
expect(fetchApi).toHaveBeenCalledWith('/secrets/secret-1', {
method: 'DELETE'
})
})
it('throws a typed error for known API error codes', async () => {
fetchApi.mockResolvedValue(
jsonResponse(
{ message: 'Duplicate provider', code: 'DUPLICATE_PROVIDER' },
{ status: 409, statusText: 'Conflict' }
)
)
await expect(listSecrets()).rejects.toMatchObject({
name: 'SecretsApiError',
message: 'Duplicate provider',
status: 409,
code: 'DUPLICATE_PROVIDER'
} satisfies Partial<SecretsApiError>)
})
it('falls back to status text for non-JSON error responses', async () => {
fetchApi.mockResolvedValue(
new Response('not-json', {
status: 500,
statusText: 'Server Error'
})
)
await expect(listSecrets()).rejects.toMatchObject({
message: 'Server Error',
status: 500,
code: undefined
} satisfies Partial<SecretsApiError>)
})
it('ignores unknown API error codes', async () => {
fetchApi.mockResolvedValue(
jsonResponse(
{ message: 'Unexpected', code: 'SOMETHING_ELSE' },
{ status: 400, statusText: 'Bad Request' }
)
)
await expect(listSecrets()).rejects.toMatchObject({
message: 'Unexpected',
status: 400,
code: undefined
} satisfies Partial<SecretsApiError>)
})
})

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { getProviderLabel, getProviderLogo } from './providers'
import type { SecretProvider } from './types'
describe('secret providers', () => {
it('returns empty display values when provider is missing', () => {
expect(getProviderLabel(undefined)).toBe('')
expect(getProviderLogo(undefined)).toBeUndefined()
})
it('returns configured labels and logos', () => {
expect(getProviderLabel('huggingface')).toBe('HuggingFace')
expect(getProviderLogo('civitai')).toBe('/assets/images/civitai.svg')
})
it('falls back to the provider value for unknown labels', () => {
const provider = 'custom' as SecretProvider
expect(getProviderLabel(provider)).toBe('custom')
expect(getProviderLogo(provider)).toBeUndefined()
})
})

View File

@@ -0,0 +1,142 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SystemStats } from '@/schemas/apiSchema'
import type { AboutPageBadge } from '@/types/comfy'
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
type SystemInfo = Partial<SystemStats['system']>
const { dist, stats, exts } = vi.hoisted(() => ({
dist: { isCloud: false, isDesktop: false },
stats: { system: {} as SystemInfo },
exts: { list: [] as { aboutPageBadges?: AboutPageBadge[] }[] }
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return dist.isCloud
},
get isDesktop() {
return dist.isDesktop
}
}))
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: () => ({
staticUrls: {
github: 'https://github.com/comfyanonymous/ComfyUI',
githubFrontend: 'https://github.com/Comfy-Org/ComfyUI_frontend',
comfyOrg: 'https://comfy.org',
discord: 'https://discord.com'
}
})
}))
vi.mock('@/utils/envUtil', () => ({
electronAPI: () => ({ getComfyUIVersion: () => '9.9.9' })
}))
vi.mock('@/stores/extensionStore', () => ({
useExtensionStore: () => ({ extensions: exts.list })
}))
vi.mock('@/stores/systemStatsStore', () => ({
useSystemStatsStore: () => ({ systemStats: stats })
}))
function label(badges: AboutPageBadge[], includes: string) {
return badges.find((b) => b.label.includes(includes))
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
dist.isCloud = false
dist.isDesktop = false
stats.system = {}
exts.list = []
})
describe('aboutPanelStore', () => {
it('builds the default desktop-less, non-cloud core badges', () => {
stats.system = { comfyui_version: 'abc1234' }
const store = useAboutPanelStore()
const core = label(store.badges, 'ComfyUI ')!
expect(core.icon).toBe('pi pi-github')
expect(core.url).toContain('github.com/comfyanonymous')
expect(label(store.badges, 'ComfyUI_frontend')).toBeDefined()
expect(label(store.badges, 'Discord')).toBeDefined()
expect(label(store.badges, 'Templates')).toBeUndefined()
})
it('uses cloud url and icon for the core badge when running on cloud', () => {
dist.isCloud = true
const store = useAboutPanelStore()
const core = label(store.badges, 'ComfyUI ')!
expect(core.icon).toBe('pi pi-cloud')
expect(core.url).toBe('https://comfy.org')
})
it('uses the electron-reported version label on desktop', () => {
dist.isDesktop = true
const store = useAboutPanelStore()
expect(label(store.badges, 'ComfyUI v9.9.9')).toBeDefined()
})
it('adds a danger templates badge when the installed version is outdated', () => {
stats.system = {
installed_templates_version: '1.0.0',
required_templates_version: '1.1.0'
}
const store = useAboutPanelStore()
const templates = label(store.badges, 'Templates v1.0.0')!
expect(templates.severity).toBe('danger')
})
it('adds a templates badge without severity when versions match', () => {
stats.system = {
installed_templates_version: '1.1.0',
required_templates_version: '1.1.0'
}
const store = useAboutPanelStore()
const templates = label(store.badges, 'Templates v1.1.0')!
expect(templates.severity).toBeUndefined()
})
it('does not mark templates outdated when the required version is missing', () => {
stats.system = {
installed_templates_version: '1.1.0'
}
const store = useAboutPanelStore()
const templates = label(store.badges, 'Templates v1.1.0')!
expect(templates.severity).toBeUndefined()
})
it('omits the templates badge when the installed version is missing', () => {
stats.system = {
required_templates_version: '1.1.0'
}
const store = useAboutPanelStore()
expect(label(store.badges, 'Templates')).toBeUndefined()
})
it('appends extension badges and tolerates extensions without any', () => {
exts.list = [
{
aboutPageBadges: [{ label: 'My Ext', url: 'https://ext', icon: 'pi' }]
},
{}
]
const store = useAboutPanelStore()
expect(label(store.badges, 'My Ext')).toBeDefined()
})
})

View File

@@ -0,0 +1,134 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
const authStoreMock = vi.hoisted(() => ({
createCustomer: vi.fn()
}))
const toastStoreMock = vi.hoisted(() => ({
add: vi.fn()
}))
const errorHandlingMock = vi.hoisted(() => ({
toastErrorHandler: vi.fn(),
forceGenericFailure: false,
forceStorageFailure: false
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => authStoreMock
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => toastStoreMock
}))
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
toastErrorHandler: errorHandlingMock.toastErrorHandler,
wrapWithErrorHandlingAsync:
(
fn: (value?: string) => Promise<boolean>,
onError: (e: unknown) => void
) =>
async (value?: string) => {
try {
if (errorHandlingMock.forceStorageFailure) {
throw new Error('STORAGE_FAILED')
}
if (errorHandlingMock.forceGenericFailure) {
throw new Error('OTHER_FAILED')
}
return await fn(value)
} catch (e) {
onError(e)
return false
}
}
})
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
describe('apiKeyAuthStore', () => {
beforeEach(() => {
localStorage.clear()
setActivePinia(createTestingPinia({ stubActions: false }))
authStoreMock.createCustomer.mockReset()
toastStoreMock.add.mockClear()
errorHandlingMock.toastErrorHandler.mockClear()
errorHandlingMock.forceGenericFailure = false
errorHandlingMock.forceStorageFailure = false
})
it('stores an API key, initializes the user, and returns an auth header', async () => {
authStoreMock.createCustomer.mockResolvedValue({ id: 'user-1' })
const store = useApiKeyAuthStore()
await expect(store.storeApiKey('secret')).resolves.toBe(true)
await vi.waitFor(() => expect(store.currentUser).toEqual({ id: 'user-1' }))
expect(store.isAuthenticated).toBe(true)
expect(store.getApiKey()).toBe('secret')
expect(store.getAuthHeader()).toEqual({ 'X-API-KEY': 'secret' })
expect(toastStoreMock.add).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
it('clears the API key when user initialization cannot create a customer', async () => {
authStoreMock.createCustomer.mockResolvedValue(undefined)
const store = useApiKeyAuthStore()
await expect(store.storeApiKey('secret')).resolves.toBe(true)
await vi.waitFor(() => expect(store.getApiKey()).toBeNull())
expect(store.currentUser).toBeNull()
expect(errorHandlingMock.toastErrorHandler).toHaveBeenCalledWith(
expect.objectContaining({ message: 'auth.login.noAssociatedUser' })
)
})
it('clears the user when the API key is cleared', async () => {
authStoreMock.createCustomer.mockResolvedValue({ id: 'user-1' })
const store = useApiKeyAuthStore()
await store.storeApiKey('secret')
await vi.waitFor(() => expect(store.currentUser).toEqual({ id: 'user-1' }))
await expect(store.clearStoredApiKey()).resolves.toBe(true)
expect(store.currentUser).toBeNull()
expect(store.isAuthenticated).toBe(false)
expect(store.getAuthHeader()).toBeNull()
})
it('reports storage failures through the API-key toast copy', async () => {
errorHandlingMock.forceStorageFailure = true
const store = useApiKeyAuthStore()
await expect(store.storeApiKey('secret')).resolves.toBe(false)
expect(toastStoreMock.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'auth.apiKey.storageFailed',
detail: 'auth.apiKey.storageFailedDetail'
})
expect(errorHandlingMock.toastErrorHandler).not.toHaveBeenCalled()
})
it('reports non-storage failures through the generic toast handler', async () => {
errorHandlingMock.forceGenericFailure = true
const store = useApiKeyAuthStore()
await expect(store.storeApiKey('secret')).resolves.toBe(false)
expect(errorHandlingMock.toastErrorHandler).toHaveBeenCalledWith(
expect.any(Error)
)
})
})

View File

@@ -37,20 +37,6 @@ export const useApiKeyAuthStore = defineStore('apiKeyAuth', () => {
currentUser.value = createCustomerResponse
}
watch(
apiKey,
() => {
if (apiKey.value) {
// IF API key is set, initialize user
void initializeUserFromApiKey()
} else {
// IF API key is cleared, clear user
currentUser.value = null
}
},
{ immediate: true }
)
const reportError = (error: unknown) => {
if (error instanceof Error && error.message === 'STORAGE_FAILED') {
toastStore.add({
@@ -63,6 +49,18 @@ export const useApiKeyAuthStore = defineStore('apiKeyAuth', () => {
}
}
watch(
apiKey,
() => {
if (apiKey.value) {
initializeUserFromApiKey().catch(reportError)
} else {
currentUser.value = null
}
},
{ immediate: true }
)
const storeApiKey = wrapWithErrorHandlingAsync(async (newApiKey: string) => {
apiKey.value = newApiKey
toastStore.add({

View File

@@ -0,0 +1,190 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { createTestingPinia } from '@pinia/testing'
import { 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,
errorStore,
dist,
resolvePrecondition,
classifyCloud
} = vi.hoisted(() => ({
handlers: {} as Record<string, (e: { detail: unknown }) => void>,
openSet: new Set<unknown>(),
errorStore: {
clearExecutionStartErrors: () => {},
clearPromptError: () => {}
} as Record<string, unknown>,
dist: { isCloud: false },
resolvePrecondition: vi.fn(),
classifyCloud: vi.fn()
}))
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: () => errorStore
}))
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: resolvePrecondition
}))
vi.mock('@/utils/executionErrorUtil', () => ({
classifyCloudValidationError: classifyCloud
}))
function workflow(path: string): ComfyWorkflow {
return fromPartial<ComfyWorkflow>({ path })
}
function promptOutput(): ComfyApiWorkflow {
return {}
}
function setup() {
const store = useExecutionStore()
store.bindExecutionEvents()
return store
}
function fireError(detail: Record<string, unknown>) {
handlers['execution_error']?.({ detail })
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
for (const key of Object.keys(handlers)) delete handlers[key]
openSet.clear()
dist.isCloud = false
resolvePrecondition.mockReturnValue(null)
classifyCloud.mockReturnValue(null)
for (const key of ['lastExecutionError', 'lastPromptError', 'lastNodeErrors'])
delete errorStore[key]
})
describe('executionStore error handling', () => {
it('marks an open workflow failed and records the raw execution error', () => {
const store = setup()
const wf = workflow('a.json')
openSet.add(wf)
store.storeJob({
nodes: [],
id: 'job-1',
promptOutput: promptOutput(),
workflow: wf
})
const detail = {
prompt_id: 'job-1',
node_id: '5',
exception_message: 'boom'
}
fireError(detail)
expect(store.getWorkflowStatus(wf)).toBe('failed')
expect(errorStore.lastExecutionError).toBe(detail)
})
it('routes account-precondition errors away from the failed badge', () => {
resolvePrecondition.mockReturnValue({ type: 'credits' })
const store = setup()
const wf = workflow('b.json')
openSet.add(wf)
store.storeJob({
nodes: [],
id: 'job-2',
promptOutput: promptOutput(),
workflow: wf
})
fireError({ prompt_id: 'job-2', exception_type: 'AccountError' })
expect(store.getWorkflowStatus(wf)).toBeUndefined()
expect(errorStore.lastExecutionError).toBeUndefined()
})
it('records a node-less service-level error as a prompt error', () => {
setup()
fireError({
prompt_id: 'job-3',
exception_type: 'StagnationError',
exception_message: 'stuck',
traceback: ['line1', 'line2']
})
expect(errorStore.lastPromptError).toEqual({
type: 'StagnationError',
message: 'StagnationError: stuck',
details: 'line1\nline2'
})
})
it('records classified cloud validation node errors without a failed badge', () => {
dist.isCloud = true
classifyCloud.mockReturnValue({
kind: 'nodeErrors',
nodeErrors: { '5': { errors: [] } }
})
const store = setup()
const wf = workflow('c.json')
openSet.add(wf)
store.storeJob({
nodes: [],
id: 'job-4',
promptOutput: promptOutput(),
workflow: wf
})
fireError({ prompt_id: 'job-4', exception_message: '{"nodeErrors":{}}' })
expect(store.getWorkflowStatus(wf)).toBeUndefined()
expect(errorStore.lastNodeErrors).toEqual({ '5': { errors: [] } })
})
})

View File

@@ -0,0 +1,231 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
const BOOKMARK_ID = 'Comfy.NodeLibrary.Bookmarks.V2'
const CUSTOMIZATION_ID = 'Comfy.NodeLibrary.BookmarksCustomization'
const { settings, setSpy, nodeDefs } = vi.hoisted(() => ({
settings: {} as Record<string, unknown>,
setSpy: vi.fn(),
nodeDefs: {} as Record<string, unknown>
}))
vi.mock('@/platform/settings/settingStore', async () => {
const { reactive } = await import('vue')
const reactiveSettings = reactive(settings)
setSpy.mockImplementation(async (id: string, value: unknown) => {
reactiveSettings[id] = value
})
return {
useSettingStore: () => ({
get: (id: string) => reactiveSettings[id],
set: setSpy
})
}
})
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => ({ allNodeDefsByName: nodeDefs }),
buildNodeDefTree: (defs: unknown[]) => ({ key: 'root', children: defs }),
createDummyFolderNodeDef: (path: string) => ({
isDummyFolder: true,
nodePath: path,
name: path
})
}))
function folderNode(nodePath: string): ComfyNodeDefImpl {
return fromPartial<ComfyNodeDefImpl>({
isDummyFolder: true,
nodePath,
category: nodePath.replace(/\/$/, ''),
name: nodePath
})
}
function leafNode(name: string, nodePath = name): ComfyNodeDefImpl {
return fromPartial<ComfyNodeDefImpl>({
isDummyFolder: false,
name,
nodePath,
category: ''
})
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
for (const key of Object.keys(settings)) delete settings[key]
for (const key of Object.keys(nodeDefs)) delete nodeDefs[key]
settings[BOOKMARK_ID] = []
settings[CUSTOMIZATION_ID] = {}
setSpy.mockClear()
})
describe('nodeBookmarkStore', () => {
it('reports isBookmarked by either nodePath or top-level name', () => {
settings[BOOKMARK_ID] = ['sampling/KSampler', 'LoadImage']
const store = useNodeBookmarkStore()
expect(store.isBookmarked(leafNode('KSampler', 'sampling/KSampler'))).toBe(
true
)
expect(store.isBookmarked(leafNode('LoadImage'))).toBe(true)
expect(store.isBookmarked(leafNode('VAEDecode'))).toBe(false)
})
it('adds a bookmark by appending to the current list', async () => {
settings[BOOKMARK_ID] = ['A']
const store = useNodeBookmarkStore()
await store.addBookmark('B')
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, ['A', 'B'])
})
it('toggles an un-bookmarked node by adding its name', async () => {
const store = useNodeBookmarkStore()
await store.toggleBookmark(leafNode('KSampler'))
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, ['KSampler'])
})
it('toggles a bookmarked node by deleting both nodePath and name', async () => {
settings[BOOKMARK_ID] = ['sampling/KSampler', 'KSampler']
const store = useNodeBookmarkStore()
await store.toggleBookmark(leafNode('KSampler', 'sampling/KSampler'))
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, ['KSampler'])
expect(setSpy).toHaveBeenLastCalledWith(BOOKMARK_ID, [])
expect(store.bookmarks).toEqual([])
})
it('creates a folder under a parent and at the root', async () => {
const store = useNodeBookmarkStore()
const rootPath = await store.addNewBookmarkFolder(undefined, 'Favorites')
expect(rootPath).toBe('Favorites/')
const childPath = await store.addNewBookmarkFolder(
folderNode('Favorites/'),
'Nested'
)
expect(childPath).toBe('Favorites/Nested/')
})
it('builds the bookmark tree, dropping unknown node defs', () => {
nodeDefs['KSampler'] = leafNode('KSampler')
settings[BOOKMARK_ID] = ['sampling/KSampler', 'sampling/Unknown', 'Folder/']
const store = useNodeBookmarkStore()
const children = (store.bookmarkedRoot as { children: unknown[] }).children
expect(children).toHaveLength(2)
})
describe('renameBookmarkFolder', () => {
it('rejects renaming a non-folder node', async () => {
const store = useNodeBookmarkStore()
await expect(
store.renameBookmarkFolder(leafNode('KSampler'), 'New')
).rejects.toThrow('Cannot rename non-folder node')
})
it('rejects a name containing a slash', async () => {
const store = useNodeBookmarkStore()
await expect(
store.renameBookmarkFolder(folderNode('Old/'), 'a/b')
).rejects.toThrow('cannot contain')
})
it('rejects a rename that collides with an existing folder', async () => {
settings[BOOKMARK_ID] = ['Taken/']
const store = useNodeBookmarkStore()
await expect(
store.renameBookmarkFolder(folderNode('Old/'), 'Taken')
).rejects.toThrow('already exists')
})
it('rewrites matching bookmark paths on a valid rename', async () => {
settings[BOOKMARK_ID] = ['Old/', 'Old/KSampler', 'Other/Node']
const store = useNodeBookmarkStore()
await store.renameBookmarkFolder(folderNode('Old/'), 'New')
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, [
'New/',
'New/KSampler',
'Other/Node'
])
})
it('does nothing when the folder keeps the same path', async () => {
const store = useNodeBookmarkStore()
await store.renameBookmarkFolder(folderNode('Old/'), 'Old')
expect(setSpy).not.toHaveBeenCalled()
})
})
it('deletes a folder and all its descendants', async () => {
settings[BOOKMARK_ID] = ['Old/', 'Old/KSampler', 'Keep/Node']
const store = useNodeBookmarkStore()
await store.deleteBookmarkFolder(folderNode('Old/'))
expect(setSpy).toHaveBeenCalledWith(BOOKMARK_ID, ['Keep/Node'])
})
it('rejects deleting a non-folder node', async () => {
const store = useNodeBookmarkStore()
await expect(
store.deleteBookmarkFolder(leafNode('KSampler'))
).rejects.toThrow('Cannot delete non-folder node')
})
describe('updateBookmarkCustomization', () => {
it('persists a non-default customization', async () => {
const store = useNodeBookmarkStore()
await store.updateBookmarkCustomization('Folder/', {
color: '#ff0000',
icon: 'pi-star'
})
expect(setSpy).toHaveBeenCalledWith(CUSTOMIZATION_ID, {
'Folder/': { color: '#ff0000', icon: 'pi-star' }
})
})
it('drops attributes set to their default values', async () => {
const store = useNodeBookmarkStore()
await store.updateBookmarkCustomization('Folder/', {
color: store.defaultBookmarkColor,
icon: store.defaultBookmarkIcon
})
expect(setSpy).toHaveBeenCalledWith(CUSTOMIZATION_ID, {
'Folder/': undefined
})
})
})
it('renames a customization entry, moving the old key to the new one', async () => {
settings[CUSTOMIZATION_ID] = { 'Old/': { color: '#abc' } }
const store = useNodeBookmarkStore()
await store.renameBookmarkCustomization('Old/', 'New/')
expect(setSpy).toHaveBeenCalledWith(CUSTOMIZATION_ID, {
'New/': { color: '#abc' }
})
})
})

View File

@@ -10,6 +10,8 @@ import type {
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyApp } from '@/scripts/app'
import * as jobOutputCache from '@/services/jobOutputCache'
import type { TaskOutput } from '@/schemas/apiSchema'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { TaskItemImpl } from '@/stores/queueStore'
vi.mock('@/services/extensionService', () => ({
@@ -44,7 +46,9 @@ const mockJobDetail = {
}
},
outputs: {
'1': { images: [{ filename: 'test.png', subfolder: '', type: 'output' }] }
'1': {
images: [{ filename: 'test.png', subfolder: '', type: 'output' as const }]
}
}
}
@@ -137,4 +141,98 @@ describe('TaskItemImpl.loadWorkflow - workflow fetching', () => {
expect(jobOutputCache.getJobDetail).toHaveBeenCalled()
expect(mockApp.loadGraphData).not.toHaveBeenCalled()
})
it('should load full outputs for history tasks', async () => {
const job = createHistoryJob('test-job-id')
const task = new TaskItemImpl(job)
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(
mockJobDetail as JobDetail
)
const loaded = await task.loadFullOutputs()
expect(loaded).not.toBe(task)
expect(loaded.flatOutputs[0].filename).toBe('test.png')
})
it('should not load full outputs for running tasks', async () => {
const job = createRunningJob('test-job-id')
const task = new TaskItemImpl(job)
const detailSpy = vi.spyOn(jobOutputCache, 'getJobDetail')
const loaded = await task.loadFullOutputs()
expect(loaded).toBe(task)
expect(detailSpy).not.toHaveBeenCalled()
})
it('should keep history tasks when full outputs are unavailable', async () => {
const job = createHistoryJob('test-job-id')
const task = new TaskItemImpl(job)
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(
fromPartial<JobDetail>({ id: 'test-job-id', status: 'completed' })
)
const loaded = await task.loadFullOutputs()
expect(loaded).toBe(task)
})
it('should load workflow outputs from the task when job detail has none', async () => {
const job = createHistoryJob('test-job-id')
const task = new TaskItemImpl(job, mockJobDetail.outputs)
const nodeOutputStore = useNodeOutputStore()
const setOutputsSpy = vi.spyOn(
nodeOutputStore,
'setNodeOutputsByExecutionId'
)
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(
fromPartial<JobDetail>({ ...mockJobDetail, outputs: undefined })
)
await task.loadWorkflow(mockApp)
expect(mockApp.loadGraphData).toHaveBeenCalledWith(mockWorkflow)
expect(setOutputsSpy).toHaveBeenCalledOnce()
})
it('should skip workflow output loading when no outputs exist', async () => {
const job = createHistoryJob('test-job-id')
const task = new TaskItemImpl(job, undefined)
const nodeOutputStore = useNodeOutputStore()
const setOutputsSpy = vi.spyOn(
nodeOutputStore,
'setNodeOutputsByExecutionId'
)
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(
fromPartial<JobDetail>({ ...mockJobDetail, outputs: undefined })
)
await task.loadWorkflow(mockApp)
expect(mockApp.loadGraphData).toHaveBeenCalledWith(mockWorkflow)
expect(setOutputsSpy).not.toHaveBeenCalled()
})
it('should skip invalid node execution ids while loading outputs', async () => {
const job = createHistoryJob('test-job-id')
const outputs = fromPartial<TaskOutput>({
'': { images: [{ filename: 'skip.png', subfolder: '', type: 'output' }] },
'1': { images: [{ filename: 'keep.png', subfolder: '', type: 'output' }] }
})
const task = new TaskItemImpl(job, outputs)
const nodeOutputStore = useNodeOutputStore()
const setOutputsSpy = vi.spyOn(
nodeOutputStore,
'setNodeOutputsByExecutionId'
)
vi.spyOn(jobOutputCache, 'getJobDetail').mockResolvedValue(
fromPartial<JobDetail>({ ...mockJobDetail, outputs: undefined })
)
await task.loadWorkflow(mockApp)
expect(setOutputsSpy).toHaveBeenCalledOnce()
expect(setOutputsSpy).toHaveBeenCalledWith('1', outputs['1'])
})
})

View File

@@ -3,10 +3,17 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type { TaskOutput } from '@/schemas/apiSchema'
import type { StatusWsMessageStatus, TaskOutput } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useExecutionStore } from '@/stores/executionStore'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import {
isInstantMode,
isInstantRunningMode,
ResultItemImpl,
TaskItemImpl,
useQueuePendingTaskCountStore,
useQueueStore
} from '@/stores/queueStore'
// Fixture factory for JobListItem
function createJob(
@@ -67,6 +74,72 @@ vi.mock('@/scripts/api', () => ({
}))
describe('TaskItemImpl', () => {
it('should use the raw URL as preview URL for non-images', () => {
const output = new ResultItemImpl({
nodeId: 'node-1',
mediaType: 'video',
filename: 'clip.webm',
type: 'output',
subfolder: ''
})
expect(output.previewUrl).toBe(output.url)
})
it('should recognize VHS mp4 and unsupported video formats', () => {
const webm = new ResultItemImpl({
nodeId: 'node-1',
mediaType: 'gifs',
filename: 'clip',
type: 'output',
subfolder: '',
format: 'video/webm',
frame_rate: 24
})
const mp4 = new ResultItemImpl({
nodeId: 'node-1',
mediaType: 'gifs',
filename: 'clip',
type: 'output',
subfolder: '',
format: 'video/mp4',
frame_rate: 24
})
const avi = new ResultItemImpl({
nodeId: 'node-1',
mediaType: 'gifs',
filename: 'clip',
type: 'output',
subfolder: '',
format: 'video/avi',
frame_rate: 24
})
expect(webm.htmlVideoType).toBe('video/webm')
expect(mp4.htmlVideoType).toBe('video/mp4')
expect(avi.htmlVideoType).toBeUndefined()
})
it('should detect image media type without an image suffix', () => {
const image = new ResultItemImpl({
nodeId: 'node-1',
mediaType: 'images',
filename: 'generated',
type: 'output',
subfolder: ''
})
const audioFile = new ResultItemImpl({
nodeId: 'node-1',
mediaType: 'images',
filename: 'generated.wav',
type: 'output',
subfolder: ''
})
expect(image.isImage).toBe(true)
expect(audioFile.isImage).toBe(false)
})
it('should exclude animated from flatOutputs', () => {
const job = createHistoryJob(0, 'job-id')
const taskItem = new TaskItemImpl(job, {
@@ -259,6 +332,38 @@ describe('TaskItemImpl', () => {
expect(taskItem.executionError).toEqual(errorDetail)
})
})
it('should expose queue API task type for running tasks', () => {
const task = new TaskItemImpl(createRunningJob(1, 'run-1'))
expect(task.apiTaskType).toBe('queue')
})
it('should return empty flat outputs when outputs are missing', () => {
const task = new TaskItemImpl(createHistoryJob(0, 'job-id'), undefined)
expect(task.calculateFlatOutputs()).toEqual([])
})
it('should calculate execution time in seconds', () => {
const task = new TaskItemImpl({
...createHistoryJob(0, 'job-id'),
execution_start_time: 1000,
execution_end_time: 3500
})
expect(task.executionStartTimestamp).toBe(1000)
expect(task.executionEndTimestamp).toBe(3500)
expect(task.executionTime).toBe(2500)
expect(task.executionTimeInSeconds).toBe(2.5)
})
it('should return undefined execution seconds without both timestamps', () => {
const task = new TaskItemImpl(createHistoryJob(0, 'job-id'))
expect(task.executionTime).toBeUndefined()
expect(task.executionTimeInSeconds).toBeUndefined()
})
})
describe('useQueueStore', () => {
@@ -314,6 +419,23 @@ describe('useQueueStore', () => {
expect(store.pendingTasks[1].jobId).toBe('pend-1')
})
it('should register workflow ids for active jobs', async () => {
const executionStore = useExecutionStore()
const registerSpy = vi.spyOn(
executionStore,
'registerJobWorkflowIdMapping'
)
mockGetQueue.mockResolvedValue({
Running: [{ ...createRunningJob(1, 'run-1'), workflow_id: 'wf-1' }],
Pending: []
})
mockGetHistory.mockResolvedValue([])
await store.update()
expect(registerSpy).toHaveBeenCalledWith('run-1', 'wf-1')
})
it('should load history tasks from API', async () => {
const historyJob1 = createHistoryJob(5, 'hist-1')
const historyJob2 = createHistoryJob(4, 'hist-2')
@@ -1115,3 +1237,43 @@ describe('useQueueStore', () => {
})
})
})
describe('useQueuePendingTaskCountStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('updates from status websocket messages', () => {
const store = useQueuePendingTaskCountStore()
store.update(
new CustomEvent<StatusWsMessageStatus>('status', {
detail: { exec_info: { queue_remaining: 3 } }
})
)
expect(store.count).toBe(3)
})
it('falls back to zero when status details are missing', () => {
const store = useQueuePendingTaskCountStore()
store.count = 3
store.update(new CustomEvent<StatusWsMessageStatus>('status'))
expect(store.count).toBe(0)
})
})
describe('queue mode helpers', () => {
it('detect instant queue modes', () => {
expect(isInstantMode('instant-idle')).toBe(true)
expect(isInstantMode('instant-running')).toBe(true)
expect(isInstantMode('change')).toBe(false)
})
it('detect instant running mode', () => {
expect(isInstantRunningMode('instant-running')).toBe(true)
expect(isInstantRunningMode('instant-idle')).toBe(false)
})
})