Files
ComfyUI_frontend/tests-ui/tests/groupNode.test.ts
Yuta Hayashibe 9f3696e70f Fix typos (#404)
* Fix typos: Interupt -> Interrupt

* Fix typos: tempateManagerRow -> templateManagerRow

* Fix some typos

* Fix typos: Convertable -> Convertible

* Fix some typos
2024-08-13 13:57:02 -04:00

1253 lines
40 KiB
TypeScript

import {
start,
createDefaultWorkflow,
getNodeDef,
checkBeforeAndAfterReload
} from '../utils'
import { EzNode } from '../utils/ezgraph'
import lg from '../utils/litegraph'
describe('group node', () => {
beforeEach(() => {
lg.setup(global)
})
afterEach(() => {
lg.teardown(global)
})
/**
*
* @param {*} app
* @param {*} graph
* @param {*} name
* @param {*} nodes
* @returns { Promise<InstanceType<import("../utils/ezgraph")["EzNode"]>> }
*/
async function convertToGroup(app, graph, name, nodes) {
// Select the nodes we are converting
for (const n of nodes) {
n.select(true)
}
expect(
Object.keys(app.canvas.selected_nodes).sort((a, b) => +a - +b)
).toEqual(nodes.map((n) => n.id + '').sort((a, b) => +a - +b))
global.prompt = jest.fn().mockImplementation(() => name)
const groupNode = await nodes[0].menu['Convert to Group Node'].call(false)
// Check group name was requested
expect(window.prompt).toHaveBeenCalled()
// Ensure old nodes are removed
for (const n of nodes) {
expect(n.isRemoved).toBeTruthy()
}
expect(groupNode.type).toEqual('workflow/' + name)
return graph.find(groupNode)
}
/**
* @param { Record<string, string | number> | number[] } idMap
* @param { Record<string, Record<string, unknown>> } valueMap
*/
function getOutput(idMap = {}, valueMap = {}) {
if (idMap instanceof Array) {
idMap = idMap.reduce((p, n) => {
p[n] = n + ''
return p
}, {})
}
const expected = {
1: {
inputs: { ckpt_name: 'model1.safetensors', ...valueMap?.[1] },
class_type: 'CheckpointLoaderSimple'
},
2: {
inputs: { text: 'positive', clip: ['1', 1], ...valueMap?.[2] },
class_type: 'CLIPTextEncode'
},
3: {
inputs: { text: 'negative', clip: ['1', 1], ...valueMap?.[3] },
class_type: 'CLIPTextEncode'
},
4: {
inputs: { width: 512, height: 512, batch_size: 1, ...valueMap?.[4] },
class_type: 'EmptyLatentImage'
},
5: {
inputs: {
seed: 0,
steps: 20,
cfg: 8,
sampler_name: 'euler',
scheduler: 'normal',
denoise: 1,
model: ['1', 0],
positive: ['2', 0],
negative: ['3', 0],
latent_image: ['4', 0],
...valueMap?.[5]
},
class_type: 'KSampler'
},
6: {
inputs: { samples: ['5', 0], vae: ['1', 2], ...valueMap?.[6] },
class_type: 'VAEDecode'
},
7: {
inputs: {
filename_prefix: 'ComfyUI',
images: ['6', 0],
...valueMap?.[7]
},
class_type: 'SaveImage'
}
}
// Map old IDs to new at the top level
const mapped = {}
for (const oldId in idMap) {
mapped[idMap[oldId]] = expected[oldId]
delete expected[oldId]
}
Object.assign(mapped, expected)
// Map old IDs to new inside links
for (const k in mapped) {
for (const input in mapped[k].inputs) {
const v = mapped[k].inputs[input]
if (v instanceof Array) {
if (v[0] in idMap) {
v[0] = idMap[v[0]] + ''
}
}
}
}
return mapped
}
test('can be created from selected nodes', async () => {
const { ez, graph, app } = await start()
const nodes = createDefaultWorkflow(ez, graph)
const group = await convertToGroup(app, graph, 'test', [
nodes.pos,
nodes.neg,
nodes.empty
])
// Ensure links are now to the group node
expect(group.inputs).toHaveLength(2)
expect(group.outputs).toHaveLength(3)
expect(group.inputs.map((i) => i.input.name)).toEqual([
'clip',
'CLIPTextEncode clip'
])
expect(group.outputs.map((i) => i.output.name)).toEqual([
'LATENT',
'CONDITIONING',
'CLIPTextEncode CONDITIONING'
])
// ckpt clip to both clip inputs on the group
expect(
nodes.ckpt.outputs.CLIP.connections.map((t) => [
t.targetNode.id,
t.targetInput.index
])
).toEqual([
[group.id, 0],
[group.id, 1]
])
// group conditioning to sampler
expect(
group.outputs['CONDITIONING'].connections.map((t) => [
t.targetNode.id,
t.targetInput.index
])
).toEqual([[nodes.sampler.id, 1]])
// group conditioning 2 to sampler
expect(
group.outputs['CLIPTextEncode CONDITIONING'].connections.map((t) => [
t.targetNode.id,
t.targetInput.index
])
).toEqual([[nodes.sampler.id, 2]])
// group latent to sampler
expect(
group.outputs['LATENT'].connections.map((t) => [
t.targetNode.id,
t.targetInput.index
])
).toEqual([[nodes.sampler.id, 3]])
})
test('maintains all output links on conversion', async () => {
const { ez, graph, app } = await start()
const nodes = createDefaultWorkflow(ez, graph)
const save2 = ez.SaveImage(...nodes.decode.outputs)
const save3 = ez.SaveImage(...nodes.decode.outputs)
// Ensure an output with multiple links maintains them on convert to group
const group = await convertToGroup(app, graph, 'test', [
nodes.sampler,
nodes.decode
])
expect(group.outputs[0].connections.length).toBe(3)
expect(group.outputs[0].connections[0].targetNode.id).toBe(nodes.save.id)
expect(group.outputs[0].connections[1].targetNode.id).toBe(save2.id)
expect(group.outputs[0].connections[2].targetNode.id).toBe(save3.id)
// and they're still linked when converting back to nodes
const newNodes = group.menu['Convert to nodes'].call()
const decode = graph.find(newNodes.find((n) => n.type === 'VAEDecode'))
expect(decode.outputs[0].connections.length).toBe(3)
expect(decode.outputs[0].connections[0].targetNode.id).toBe(nodes.save.id)
expect(decode.outputs[0].connections[1].targetNode.id).toBe(save2.id)
expect(decode.outputs[0].connections[2].targetNode.id).toBe(save3.id)
})
test('can be be converted back to nodes', async () => {
const { ez, graph, app } = await start()
const nodes = createDefaultWorkflow(ez, graph)
const toConvert = [nodes.pos, nodes.neg, nodes.empty, nodes.sampler]
const group = await convertToGroup(app, graph, 'test', toConvert)
// Edit some values to ensure they are set back onto the converted nodes
expect(group.widgets['text'].value).toBe('positive')
group.widgets['text'].value = 'pos'
expect(group.widgets['CLIPTextEncode text'].value).toBe('negative')
group.widgets['CLIPTextEncode text'].value = 'neg'
expect(group.widgets['width'].value).toBe(512)
group.widgets['width'].value = 1024
expect(group.widgets['sampler_name'].value).toBe('euler')
group.widgets['sampler_name'].value = 'ddim'
expect(group.widgets['control_after_generate'].value).toBe('randomize')
group.widgets['control_after_generate'].value = 'fixed'
/** @type { Array<any> } */
group.menu['Convert to nodes'].call()
// ensure widget values are set
const pos = graph.find(nodes.pos.id)
expect(pos.node.type).toBe('CLIPTextEncode')
expect(pos.widgets['text'].value).toBe('pos')
const neg = graph.find(nodes.neg.id)
expect(neg.node.type).toBe('CLIPTextEncode')
expect(neg.widgets['text'].value).toBe('neg')
const empty = graph.find(nodes.empty.id)
expect(empty.node.type).toBe('EmptyLatentImage')
expect(empty.widgets['width'].value).toBe(1024)
const sampler = graph.find(nodes.sampler.id)
expect(sampler.node.type).toBe('KSampler')
expect(sampler.widgets['sampler_name'].value).toBe('ddim')
expect(sampler.widgets['control_after_generate'].value).toBe('fixed')
// validate links
expect(
nodes.ckpt.outputs.CLIP.connections.map((t) => [
t.targetNode.id,
t.targetInput.index
])
).toEqual([
[pos.id, 0],
[neg.id, 0]
])
expect(
pos.outputs['CONDITIONING'].connections.map((t) => [
t.targetNode.id,
t.targetInput.index
])
).toEqual([[nodes.sampler.id, 1]])
expect(
neg.outputs['CONDITIONING'].connections.map((t) => [
t.targetNode.id,
t.targetInput.index
])
).toEqual([[nodes.sampler.id, 2]])
expect(
empty.outputs['LATENT'].connections.map((t) => [
t.targetNode.id,
t.targetInput.index
])
).toEqual([[nodes.sampler.id, 3]])
})
test('it can embed reroutes as inputs', async () => {
const { ez, graph, app } = await start()
const nodes = createDefaultWorkflow(ez, graph)
// Add and connect a reroute to the clip text encodes
const reroute = ez.Reroute()
nodes.ckpt.outputs.CLIP.connectTo(reroute.inputs[0])
reroute.outputs[0].connectTo(nodes.pos.inputs[0])
reroute.outputs[0].connectTo(nodes.neg.inputs[0])
// Convert to group and ensure we only have 1 input of the correct type
const group = await convertToGroup(app, graph, 'test', [
nodes.pos,
nodes.neg,
nodes.empty,
reroute
])
expect(group.inputs).toHaveLength(1)
expect(group.inputs[0].input.type).toEqual('CLIP')
expect((await graph.toPrompt()).output).toEqual(getOutput())
})
test('it can embed reroutes as outputs', async () => {
const { ez, graph, app } = await start()
const nodes = createDefaultWorkflow(ez, graph)
// Add a reroute with no output so we output IMAGE even though its used internally
const reroute = ez.Reroute()
nodes.decode.outputs.IMAGE.connectTo(reroute.inputs[0])
// Convert to group and ensure there is an IMAGE output
const group = await convertToGroup(app, graph, 'test', [
nodes.decode,
nodes.save,
reroute
])
expect(group.outputs).toHaveLength(1)
expect(group.outputs[0].output.type).toEqual('IMAGE')
expect((await graph.toPrompt()).output).toEqual(
getOutput([nodes.decode.id, nodes.save.id])
)
})
test('it can embed reroutes as pipes', async () => {
const { ez, graph, app } = await start()
const nodes = createDefaultWorkflow(ez, graph)
// Use reroutes as a pipe
const rerouteModel = ez.Reroute()
const rerouteClip = ez.Reroute()
const rerouteVae = ez.Reroute()
nodes.ckpt.outputs.MODEL.connectTo(rerouteModel.inputs[0])
nodes.ckpt.outputs.CLIP.connectTo(rerouteClip.inputs[0])
nodes.ckpt.outputs.VAE.connectTo(rerouteVae.inputs[0])
const group = await convertToGroup(app, graph, 'test', [
rerouteModel,
rerouteClip,
rerouteVae
])
expect(group.outputs).toHaveLength(3)
expect(group.outputs.map((o) => o.output.type)).toEqual([
'MODEL',
'CLIP',
'VAE'
])
expect(group.outputs).toHaveLength(3)
expect(group.outputs.map((o) => o.output.type)).toEqual([
'MODEL',
'CLIP',
'VAE'
])
group.outputs[0].connectTo(nodes.sampler.inputs.model)
group.outputs[1].connectTo(nodes.pos.inputs.clip)
group.outputs[1].connectTo(nodes.neg.inputs.clip)
})
test('can handle reroutes used internally', async () => {
const { ez, graph, app } = await start()
const nodes = createDefaultWorkflow(ez, graph)
let reroutes: EzNode[] = []
let prevNode = nodes.ckpt
for (let i = 0; i < 5; i++) {
const reroute = ez.Reroute()
prevNode.outputs[0].connectTo(reroute.inputs[0])
prevNode = reroute
reroutes.push(reroute)
}
prevNode.outputs[0].connectTo(nodes.sampler.inputs.model)
const group = await convertToGroup(app, graph, 'test', [
...reroutes,
...Object.values(nodes)
])
expect((await graph.toPrompt()).output).toEqual(getOutput())
group.menu['Convert to nodes'].call()
expect((await graph.toPrompt()).output).toEqual(getOutput())
})
test('creates with widget values from inner nodes', async () => {
const { ez, graph, app } = await start()
const nodes = createDefaultWorkflow(ez, graph)
nodes.ckpt.widgets.ckpt_name.value = 'model2.ckpt'
nodes.pos.widgets.text.value = 'hello'
nodes.neg.widgets.text.value = 'world'
nodes.empty.widgets.width.value = 256
nodes.empty.widgets.height.value = 1024
nodes.sampler.widgets.seed.value = 1
nodes.sampler.widgets.control_after_generate.value = 'increment'
nodes.sampler.widgets.steps.value = 8
nodes.sampler.widgets.cfg.value = 4.5
nodes.sampler.widgets.sampler_name.value = 'uni_pc'
nodes.sampler.widgets.scheduler.value = 'karras'
nodes.sampler.widgets.denoise.value = 0.9
const group = await convertToGroup(app, graph, 'test', [
nodes.ckpt,
nodes.pos,
nodes.neg,
nodes.empty,
nodes.sampler
])
expect(group.widgets['ckpt_name'].value).toEqual('model2.ckpt')
expect(group.widgets['text'].value).toEqual('hello')
expect(group.widgets['CLIPTextEncode text'].value).toEqual('world')
expect(group.widgets['width'].value).toEqual(256)
expect(group.widgets['height'].value).toEqual(1024)
expect(group.widgets['seed'].value).toEqual(1)
expect(group.widgets['control_after_generate'].value).toEqual('increment')
expect(group.widgets['steps'].value).toEqual(8)
expect(group.widgets['cfg'].value).toEqual(4.5)
expect(group.widgets['sampler_name'].value).toEqual('uni_pc')
expect(group.widgets['scheduler'].value).toEqual('karras')
expect(group.widgets['denoise'].value).toEqual(0.9)
expect((await graph.toPrompt()).output).toEqual(
getOutput(
[
nodes.ckpt.id,
nodes.pos.id,
nodes.neg.id,
nodes.empty.id,
nodes.sampler.id
],
{
[nodes.ckpt.id]: { ckpt_name: 'model2.ckpt' },
[nodes.pos.id]: { text: 'hello' },
[nodes.neg.id]: { text: 'world' },
[nodes.empty.id]: { width: 256, height: 1024 },
[nodes.sampler.id]: {
seed: 1,
steps: 8,
cfg: 4.5,
sampler_name: 'uni_pc',
scheduler: 'karras',
denoise: 0.9
}
}
)
)
})
test('group inputs can be reroutes', async () => {
const { ez, graph, app } = await start()
const nodes = createDefaultWorkflow(ez, graph)
const group = await convertToGroup(app, graph, 'test', [
nodes.pos,
nodes.neg
])
const reroute = ez.Reroute()
nodes.ckpt.outputs.CLIP.connectTo(reroute.inputs[0])
reroute.outputs[0].connectTo(group.inputs[0])
reroute.outputs[0].connectTo(group.inputs[1])
expect((await graph.toPrompt()).output).toEqual(
getOutput([nodes.pos.id, nodes.neg.id])
)
})
test('group outputs can be reroutes', async () => {
const { ez, graph, app } = await start()
const nodes = createDefaultWorkflow(ez, graph)
const group = await convertToGroup(app, graph, 'test', [
nodes.pos,
nodes.neg
])
const reroute1 = ez.Reroute()
const reroute2 = ez.Reroute()
group.outputs[0].connectTo(reroute1.inputs[0])
group.outputs[1].connectTo(reroute2.inputs[0])
reroute1.outputs[0].connectTo(nodes.sampler.inputs.positive)
reroute2.outputs[0].connectTo(nodes.sampler.inputs.negative)
expect((await graph.toPrompt()).output).toEqual(
getOutput([nodes.pos.id, nodes.neg.id])
)
})
test('groups can connect to each other', async () => {
const { ez, graph, app } = await start()
const nodes = createDefaultWorkflow(ez, graph)
const group1 = await convertToGroup(app, graph, 'test', [
nodes.pos,
nodes.neg
])
const group2 = await convertToGroup(app, graph, 'test2', [
nodes.empty,
nodes.sampler
])
group1.outputs[0].connectTo(group2.inputs['positive'])
group1.outputs[1].connectTo(group2.inputs['negative'])
expect((await graph.toPrompt()).output).toEqual(
getOutput([nodes.pos.id, nodes.neg.id, nodes.empty.id, nodes.sampler.id])
)
})
test('groups can connect to each other via internal reroutes', async () => {
const { ez, graph, app } = await start()
const latent = ez.EmptyLatentImage()
const vae = ez.VAELoader()
const latentReroute = ez.Reroute()
const vaeReroute = ez.Reroute()
latent.outputs[0].connectTo(latentReroute.inputs[0])
vae.outputs[0].connectTo(vaeReroute.inputs[0])
const group1 = await convertToGroup(app, graph, 'test', [
latentReroute,
vaeReroute
])
group1.menu.Clone.call()
expect(app.graph._nodes).toHaveLength(4)
const group2 = graph.find(app.graph._nodes[3])
expect(group2.node.type).toEqual('workflow/test')
expect(group2.id).not.toEqual(group1.id)
group1.outputs.VAE.connectTo(group2.inputs.VAE)
group1.outputs.LATENT.connectTo(group2.inputs.LATENT)
const decode = ez.VAEDecode(group2.outputs.LATENT, group2.outputs.VAE)
const preview = ez.PreviewImage(decode.outputs[0])
const output = {
[latent.id]: {
inputs: { width: 512, height: 512, batch_size: 1 },
class_type: 'EmptyLatentImage'
},
[vae.id]: {
inputs: { vae_name: 'vae1.safetensors' },
class_type: 'VAELoader'
},
[decode.id]: {
inputs: { samples: [latent.id + '', 0], vae: [vae.id + '', 0] },
class_type: 'VAEDecode'
},
[preview.id]: {
inputs: { images: [decode.id + '', 0] },
class_type: 'PreviewImage'
}
}
expect((await graph.toPrompt()).output).toEqual(output)
// Ensure missing connections dont cause errors
group2.inputs.VAE.disconnect()
delete output[decode.id].inputs.vae
expect((await graph.toPrompt()).output).toEqual(output)
})
test('displays generated image on group node', async () => {
const { ez, graph, app } = await start()
const nodes = createDefaultWorkflow(ez, graph)
let group = await convertToGroup(app, graph, 'test', [
nodes.pos,
nodes.neg,
nodes.empty,
nodes.sampler,
nodes.decode,
nodes.save
])
const { api } = await import('../../src/scripts/api')
api.dispatchEvent(new CustomEvent('execution_start', {}))
api.dispatchEvent(
new CustomEvent('executing', { detail: `${nodes.save.id}` })
)
// Event should be forwarded to group node id
expect(+app.runningNodeId).toEqual(group.id)
expect(group.node['imgs']).toBeFalsy()
api.dispatchEvent(
new CustomEvent('executed', {
detail: {
node: `${nodes.save.id}`,
display_node: `${nodes.save.id}`,
output: {
images: [
{
filename: 'test.png',
type: 'output'
}
]
}
}
})
)
// Trigger paint
group.node.onDrawBackground?.(app.canvas.ctx, app.canvas.canvas)
expect(group.node['images']).toEqual([
{
filename: 'test.png',
type: 'output'
}
])
// Reload
const workflow = JSON.stringify((await graph.toPrompt()).workflow)
await app.loadGraphData(JSON.parse(workflow))
group = graph.find(group)
// Trigger inner nodes to get created
group.node['getInnerNodes']()
// Check it works for internal node ids
api.dispatchEvent(new CustomEvent('execution_start', {}))
api.dispatchEvent(new CustomEvent('executing', { detail: `${group.id}:5` }))
// Event should be forwarded to group node id
expect(+app.runningNodeId).toEqual(group.id)
expect(group.node['imgs']).toBeFalsy()
api.dispatchEvent(
new CustomEvent('executed', {
detail: {
node: `${group.id}:5`,
display_node: `${group.id}:5`,
output: {
images: [
{
filename: 'test2.png',
type: 'output'
}
]
}
}
})
)
// Trigger paint
group.node.onDrawBackground?.(app.canvas.ctx, app.canvas.canvas)
expect(group.node['images']).toEqual([
{
filename: 'test2.png',
type: 'output'
}
])
})
test('allows widgets to be converted to inputs', async () => {
const { ez, graph, app } = await start()
const nodes = createDefaultWorkflow(ez, graph)
const group = await convertToGroup(app, graph, 'test', [
nodes.pos,
nodes.neg
])
group.widgets[0].convertToInput()
const primitive = ez.PrimitiveNode()
primitive.outputs[0].connectTo(group.inputs['text'])
primitive.widgets[0].value = 'hello'
expect((await graph.toPrompt()).output).toEqual(
getOutput([nodes.pos.id, nodes.neg.id], {
[nodes.pos.id]: { text: 'hello' }
})
)
})
test('can be copied', async () => {
const { ez, graph, app } = await start()
const nodes = createDefaultWorkflow(ez, graph)
const group1 = await convertToGroup(app, graph, 'test', [
nodes.pos,
nodes.neg,
nodes.empty,
nodes.sampler,
nodes.decode,
nodes.save
])
group1.widgets['text'].value = 'hello'
group1.widgets['width'].value = 256
group1.widgets['seed'].value = 1
// Clone the node
group1.menu.Clone.call()
expect(app.graph._nodes).toHaveLength(3)
const group2 = graph.find(app.graph._nodes[2])
expect(group2.node.type).toEqual('workflow/test')
expect(group2.id).not.toEqual(group1.id)
// Reconnect ckpt
nodes.ckpt.outputs.MODEL.connectTo(group2.inputs['model'])
nodes.ckpt.outputs.CLIP.connectTo(group2.inputs['clip'])
nodes.ckpt.outputs.CLIP.connectTo(group2.inputs['CLIPTextEncode clip'])
nodes.ckpt.outputs.VAE.connectTo(group2.inputs['vae'])
group2.widgets['text'].value = 'world'
group2.widgets['width'].value = 1024
group2.widgets['seed'].value = 100
let i = 0
expect((await graph.toPrompt()).output).toEqual({
...getOutput(
[
nodes.empty.id,
nodes.pos.id,
nodes.neg.id,
nodes.sampler.id,
nodes.decode.id,
nodes.save.id
],
{
[nodes.empty.id]: { width: 256 },
[nodes.pos.id]: { text: 'hello' },
[nodes.sampler.id]: { seed: 1 }
}
),
...getOutput(
{
[nodes.empty.id]: `${group2.id}:${i++}`,
[nodes.pos.id]: `${group2.id}:${i++}`,
[nodes.neg.id]: `${group2.id}:${i++}`,
[nodes.sampler.id]: `${group2.id}:${i++}`,
[nodes.decode.id]: `${group2.id}:${i++}`,
[nodes.save.id]: `${group2.id}:${i++}`
},
{
[nodes.empty.id]: { width: 1024 },
[nodes.pos.id]: { text: 'world' },
[nodes.sampler.id]: { seed: 100 }
}
)
})
graph.arrange()
})
test('is embedded in workflow', async () => {
let { ez, graph, app } = await start()
const nodes = createDefaultWorkflow(ez, graph)
let group = await convertToGroup(app, graph, 'test', [nodes.pos, nodes.neg])
const workflow = JSON.stringify((await graph.toPrompt()).workflow)
// Clear the environment
;({ ez, graph, app } = await start({
resetEnv: true
}))
// Ensure the node isnt registered
expect(() => ez['workflow/test']).toThrow()
// Reload the workflow
await app.loadGraphData(JSON.parse(workflow))
// Ensure the node is found
group = graph.find(group)
// Generate prompt and ensure it is as expected
expect((await graph.toPrompt()).output).toEqual(
getOutput({
[nodes.pos.id]: `${group.id}:0`,
[nodes.neg.id]: `${group.id}:1`
})
)
})
// Now reports zod validation error
test.skip('shows missing node error on missing internal node when loading graph data', async () => {
const { graph } = await start()
const dialogShow = jest.spyOn(graph.app.ui.dialog, 'show')
await graph.app.loadGraphData({
last_node_id: 3,
last_link_id: 1,
nodes: [
{
id: 3,
type: 'workflow/testerror'
}
],
links: [],
groups: [],
config: {},
extra: {
groupNodes: {
testerror: {
nodes: [
{
type: 'NotKSampler'
},
{
type: 'NotVAEDecode'
}
]
}
}
}
})
expect(dialogShow).toBeCalledTimes(1)
// @ts-expect-error
const call = dialogShow.mock.calls[0][0].innerHTML
expect(call).toContain('the following node types were not found')
expect(call).toContain('NotKSampler')
expect(call).toContain('NotVAEDecode')
expect(call).toContain('workflow/testerror')
})
test('maintains widget inputs on conversion back to nodes', async () => {
const { ez, graph, app } = await start()
let pos = ez.CLIPTextEncode({ text: 'positive' })
pos.node.title = 'Positive'
let neg = ez.CLIPTextEncode({ text: 'negative' })
neg.node.title = 'Negative'
pos.widgets.text.convertToInput()
neg.widgets.text.convertToInput()
let primitive = ez.PrimitiveNode()
primitive.outputs[0].connectTo(pos.inputs.text)
primitive.outputs[0].connectTo(neg.inputs.text)
const group = await convertToGroup(app, graph, 'test', [
pos,
neg,
primitive
])
// This will use a primitive widget named 'value'
expect(group.widgets.length).toBe(1)
expect(group.widgets['value'].value).toBe('positive')
const newNodes = group.menu['Convert to nodes'].call()
pos = graph.find(newNodes.find((n) => n.title === 'Positive'))
neg = graph.find(newNodes.find((n) => n.title === 'Negative'))
primitive = graph.find(newNodes.find((n) => n.type === 'PrimitiveNode'))
expect(pos.inputs).toHaveLength(2)
expect(neg.inputs).toHaveLength(2)
expect(primitive.outputs[0].connections).toHaveLength(2)
expect((await graph.toPrompt()).output).toEqual({
1: { inputs: { text: 'positive' }, class_type: 'CLIPTextEncode' },
2: { inputs: { text: 'positive' }, class_type: 'CLIPTextEncode' }
})
})
test('correctly handles widget inputs', async () => {
const { ez, graph, app } = await start()
const upscaleMethods = (await getNodeDef('ImageScaleBy')).input.required[
'upscale_method'
][0]
const image = ez.LoadImage()
const scale1 = ez.ImageScaleBy(image.outputs[0])
const scale2 = ez.ImageScaleBy(image.outputs[0])
const preview1 = ez.PreviewImage(scale1.outputs[0])
const preview2 = ez.PreviewImage(scale2.outputs[0])
scale1.widgets.upscale_method.value = upscaleMethods[1]
scale1.widgets.upscale_method.convertToInput()
const group = await convertToGroup(app, graph, 'test', [scale1, scale2])
expect(group.inputs.length).toBe(3)
expect(group.inputs[0].input.type).toBe('IMAGE')
expect(group.inputs[1].input.type).toBe('IMAGE')
expect(group.inputs[2].input.type).toBe('COMBO')
// Ensure links are maintained
expect(group.inputs[0].connection?.originNode?.id).toBe(image.id)
expect(group.inputs[1].connection?.originNode?.id).toBe(image.id)
expect(group.inputs[2].connection).toBeFalsy()
// Ensure primitive gets correct type
const primitive = ez.PrimitiveNode()
primitive.outputs[0].connectTo(group.inputs[2])
expect(primitive.widgets.value.widget.options.values).toBe(upscaleMethods)
expect(primitive.widgets.value.value).toBe(upscaleMethods[1]) // Ensure value is copied
primitive.widgets.value.value = upscaleMethods[1]
await checkBeforeAndAfterReload(graph, async (r) => {
const scale1id = r ? `${group.id}:0` : scale1.id
const scale2id = r ? `${group.id}:1` : scale2.id
// Ensure widget value is applied to prompt
expect((await graph.toPrompt()).output).toStrictEqual({
[image.id]: {
inputs: { image: 'example.png', upload: 'image' },
class_type: 'LoadImage'
},
[scale1id]: {
inputs: {
upscale_method: upscaleMethods[1],
scale_by: 1,
image: [`${image.id}`, 0]
},
class_type: 'ImageScaleBy'
},
[scale2id]: {
inputs: {
upscale_method: 'nearest-exact',
scale_by: 1,
image: [`${image.id}`, 0]
},
class_type: 'ImageScaleBy'
},
[preview1.id]: {
inputs: { images: [`${scale1id}`, 0] },
class_type: 'PreviewImage'
},
[preview2.id]: {
inputs: { images: [`${scale2id}`, 0] },
class_type: 'PreviewImage'
}
})
})
})
test('adds widgets in node execution order', async () => {
const { ez, graph, app } = await start()
const scale = ez.LatentUpscale()
const save = ez.SaveImage()
const empty = ez.EmptyLatentImage()
const decode = ez.VAEDecode()
scale.outputs.LATENT.connectTo(decode.inputs.samples)
decode.outputs.IMAGE.connectTo(save.inputs.images)
empty.outputs.LATENT.connectTo(scale.inputs.samples)
const group = await convertToGroup(app, graph, 'test', [
scale,
save,
empty,
decode
])
const widgets = group.widgets.map((w) => w.widget.name)
expect(widgets).toStrictEqual([
'width',
'height',
'batch_size',
'upscale_method',
'LatentUpscale width',
'LatentUpscale height',
'crop',
'filename_prefix'
])
})
test('adds output for external links when converting to group', async () => {
const { ez, graph, app } = await start()
const img = ez.EmptyLatentImage()
let decode = ez.VAEDecode(...img.outputs)
const preview1 = ez.PreviewImage(...decode.outputs)
const preview2 = ez.PreviewImage(...decode.outputs)
const group = await convertToGroup(app, graph, 'test', [
img,
decode,
preview1
])
// Ensure we have an output connected to the 2nd preview node
expect(group.outputs.length).toBe(1)
expect(group.outputs[0].connections.length).toBe(1)
expect(group.outputs[0].connections[0].targetNode.id).toBe(preview2.id)
// Convert back and ensure both previews are still connected
group.menu['Convert to nodes'].call()
decode = graph.find(decode)
expect(decode.outputs[0].connections.length).toBe(2)
expect(decode.outputs[0].connections[0].targetNode.id).toBe(preview1.id)
expect(decode.outputs[0].connections[1].targetNode.id).toBe(preview2.id)
})
test('adds output for external links when converting to group when nodes are not in execution order', async () => {
const { ez, graph, app } = await start()
const sampler = ez.KSampler()
const ckpt = ez.CheckpointLoaderSimple()
const empty = ez.EmptyLatentImage()
const pos = ez.CLIPTextEncode(ckpt.outputs.CLIP, { text: 'positive' })
const neg = ez.CLIPTextEncode(ckpt.outputs.CLIP, { text: 'negative' })
const decode1 = ez.VAEDecode(sampler.outputs.LATENT, ckpt.outputs.VAE)
const save = ez.SaveImage(decode1.outputs.IMAGE)
ckpt.outputs.MODEL.connectTo(sampler.inputs.model)
pos.outputs.CONDITIONING.connectTo(sampler.inputs.positive)
neg.outputs.CONDITIONING.connectTo(sampler.inputs.negative)
empty.outputs.LATENT.connectTo(sampler.inputs.latent_image)
const encode = ez.VAEEncode(decode1.outputs.IMAGE)
const vae = ez.VAELoader()
const decode2 = ez.VAEDecode(encode.outputs.LATENT, vae.outputs.VAE)
const preview = ez.PreviewImage(decode2.outputs.IMAGE)
vae.outputs.VAE.connectTo(encode.inputs.vae)
const group = await convertToGroup(app, graph, 'test', [
vae,
decode1,
encode,
sampler
])
expect(group.outputs.length).toBe(3)
expect(group.outputs[0].output.name).toBe('VAE')
expect(group.outputs[0].output.type).toBe('VAE')
expect(group.outputs[1].output.name).toBe('IMAGE')
expect(group.outputs[1].output.type).toBe('IMAGE')
expect(group.outputs[2].output.name).toBe('LATENT')
expect(group.outputs[2].output.type).toBe('LATENT')
expect(group.outputs[0].connections.length).toBe(1)
expect(group.outputs[0].connections[0].targetNode.id).toBe(decode2.id)
expect(group.outputs[0].connections[0].targetInput.index).toBe(1)
expect(group.outputs[1].connections.length).toBe(1)
expect(group.outputs[1].connections[0].targetNode.id).toBe(save.id)
expect(group.outputs[1].connections[0].targetInput.index).toBe(0)
expect(group.outputs[2].connections.length).toBe(1)
expect(group.outputs[2].connections[0].targetNode.id).toBe(decode2.id)
expect(group.outputs[2].connections[0].targetInput.index).toBe(0)
expect((await graph.toPrompt()).output).toEqual({
...getOutput({
1: ckpt.id,
2: pos.id,
3: neg.id,
4: empty.id,
5: sampler.id,
6: decode1.id,
7: save.id
}),
[vae.id]: {
inputs: { vae_name: 'vae1.safetensors' },
class_type: vae.node.type
},
[encode.id]: {
inputs: { pixels: ['6', 0], vae: [vae.id + '', 0] },
class_type: encode.node.type
},
[decode2.id]: {
inputs: { samples: [encode.id + '', 0], vae: [vae.id + '', 0] },
class_type: decode2.node.type
},
[preview.id]: {
inputs: { images: [decode2.id + '', 0] },
class_type: preview.node.type
}
})
})
test('works with IMAGEUPLOAD widget', async () => {
const { ez, graph, app } = await start()
const img = ez.LoadImage()
const preview1 = ez.PreviewImage(img.outputs[0])
const group = await convertToGroup(app, graph, 'test', [img, preview1])
const widget = group.widgets['upload']
expect(widget).toBeTruthy()
expect(widget.widget.type).toBe('button')
})
test('internal primitive populates widgets for all linked inputs', async () => {
const { ez, graph, app } = await start()
const img = ez.LoadImage()
const scale1 = ez.ImageScale(img.outputs[0])
const scale2 = ez.ImageScale(img.outputs[0])
ez.PreviewImage(scale1.outputs[0])
ez.PreviewImage(scale2.outputs[0])
scale1.widgets.width.convertToInput()
scale2.widgets.height.convertToInput()
const primitive = ez.PrimitiveNode()
primitive.outputs[0].connectTo(scale1.inputs.width)
primitive.outputs[0].connectTo(scale2.inputs.height)
const group = await convertToGroup(app, graph, 'test', [
img,
primitive,
scale1,
scale2
])
group.widgets.value.value = 100
expect((await graph.toPrompt()).output).toEqual({
1: {
inputs: { image: img.widgets.image.value, upload: 'image' },
class_type: 'LoadImage'
},
2: {
inputs: {
upscale_method: 'nearest-exact',
width: 100,
height: 512,
crop: 'disabled',
image: ['1', 0]
},
class_type: 'ImageScale'
},
3: {
inputs: {
upscale_method: 'nearest-exact',
width: 512,
height: 100,
crop: 'disabled',
image: ['1', 0]
},
class_type: 'ImageScale'
},
4: { inputs: { images: ['2', 0] }, class_type: 'PreviewImage' },
5: { inputs: { images: ['3', 0] }, class_type: 'PreviewImage' }
})
})
test('primitive control widgets values are copied on convert', async () => {
const { ez, graph, app } = await start()
const sampler = ez.KSampler()
sampler.widgets.seed.convertToInput()
sampler.widgets.sampler_name.convertToInput()
let p1 = ez.PrimitiveNode()
let p2 = ez.PrimitiveNode()
p1.outputs[0].connectTo(sampler.inputs.seed)
p2.outputs[0].connectTo(sampler.inputs.sampler_name)
p1.widgets.control_after_generate.value = 'increment'
p2.widgets.control_after_generate.value = 'decrement'
p2.widgets.control_filter_list.value = '/.*/'
p2.node.title = 'p2'
const group = await convertToGroup(app, graph, 'test', [sampler, p1, p2])
expect(group.widgets.control_after_generate.value).toBe('increment')
expect(group.widgets['p2 control_after_generate'].value).toBe('decrement')
expect(group.widgets['p2 control_filter_list'].value).toBe('/.*/')
group.widgets.control_after_generate.value = 'fixed'
group.widgets['p2 control_after_generate'].value = 'randomize'
group.widgets['p2 control_filter_list'].value = '/.+/'
group.menu['Convert to nodes'].call()
p1 = graph.find(p1)
p2 = graph.find(p2)
expect(p1.widgets.control_after_generate.value).toBe('fixed')
expect(p2.widgets.control_after_generate.value).toBe('randomize')
expect(p2.widgets.control_filter_list.value).toBe('/.+/')
})
test('internal reroutes work with converted inputs and merge options', async () => {
const { ez, graph, app } = await start()
const vae = ez.VAELoader()
const latent = ez.EmptyLatentImage()
const decode = ez.VAEDecode(latent.outputs.LATENT, vae.outputs.VAE)
const scale = ez.ImageScale(decode.outputs.IMAGE)
ez.PreviewImage(scale.outputs.IMAGE)
const r1 = ez.Reroute()
const r2 = ez.Reroute()
latent.widgets.width.value = 64
latent.widgets.height.value = 128
latent.widgets.width.convertToInput()
latent.widgets.height.convertToInput()
latent.widgets.batch_size.convertToInput()
scale.widgets.width.convertToInput()
scale.widgets.height.convertToInput()
r1.inputs[0].input.label = 'hbw'
r1.outputs[0].connectTo(latent.inputs.height)
r1.outputs[0].connectTo(latent.inputs.batch_size)
r1.outputs[0].connectTo(scale.inputs.width)
r2.inputs[0].input.label = 'wh'
r2.outputs[0].connectTo(latent.inputs.width)
r2.outputs[0].connectTo(scale.inputs.height)
const group = await convertToGroup(app, graph, 'test', [
r1,
r2,
latent,
decode,
scale
])
expect(group.inputs[0].input.type).toBe('VAE')
expect(group.inputs[1].input.type).toBe('INT')
expect(group.inputs[2].input.type).toBe('INT')
const p1 = ez.PrimitiveNode()
const p2 = ez.PrimitiveNode()
p1.outputs[0].connectTo(group.inputs[1])
p2.outputs[0].connectTo(group.inputs[2])
expect(p1.widgets.value.widget.options?.min).toBe(16) // width/height min
expect(p1.widgets.value.widget.options?.max).toBe(4096) // batch max
expect(p1.widgets.value.widget.options?.step).toBe(80) // width/height step * 10
expect(p2.widgets.value.widget.options?.min).toBe(16) // width/height min
expect(p2.widgets.value.widget.options?.max).toBe(16384) // width/height max
expect(p2.widgets.value.widget.options?.step).toBe(80) // width/height step * 10
expect(p1.widgets.value.value).toBe(128)
expect(p2.widgets.value.value).toBe(64)
p1.widgets.value.value = 16
p2.widgets.value.value = 32
await checkBeforeAndAfterReload(graph, async (r) => {
const id = (v) => (r ? `${group.id}:` : '') + v
expect((await graph.toPrompt()).output).toStrictEqual({
1: {
inputs: { vae_name: 'vae1.safetensors' },
class_type: 'VAELoader'
},
[id(2)]: {
inputs: { width: 32, height: 16, batch_size: 16 },
class_type: 'EmptyLatentImage'
},
[id(3)]: {
inputs: { samples: [id(2), 0], vae: ['1', 0] },
class_type: 'VAEDecode'
},
[id(4)]: {
inputs: {
upscale_method: 'nearest-exact',
width: 16,
height: 32,
crop: 'disabled',
image: [id(3), 0]
},
class_type: 'ImageScale'
},
5: { inputs: { images: [id(4), 0] }, class_type: 'PreviewImage' }
})
})
})
test('converted inputs with linked widgets map values correctly on creation', async () => {
const { ez, graph, app } = await start()
const k1 = ez.KSampler()
const k2 = ez.KSampler()
k1.widgets.seed.convertToInput()
k2.widgets.seed.convertToInput()
const rr = ez.Reroute()
rr.outputs[0].connectTo(k1.inputs.seed)
rr.outputs[0].connectTo(k2.inputs.seed)
const group = await convertToGroup(app, graph, 'test', [k1, k2, rr])
expect(group.widgets.steps.value).toBe(20)
expect(group.widgets.cfg.value).toBe(8)
expect(group.widgets.scheduler.value).toBe('normal')
expect(group.widgets['KSampler steps'].value).toBe(20)
expect(group.widgets['KSampler cfg'].value).toBe(8)
expect(group.widgets['KSampler scheduler'].value).toBe('normal')
})
test('allow multiple of the same node type to be added', async () => {
const { ez, graph, app } = await start()
const nodes = [...Array(10)].map(() => ez.ImageScaleBy())
const group = await convertToGroup(app, graph, 'test', nodes)
expect(group.inputs.length).toBe(10)
expect(group.outputs.length).toBe(10)
expect(group.widgets.length).toBe(20)
expect(group.widgets.map((w) => w.widget.name)).toStrictEqual(
[...Array(10)]
.map((_, i) => `${i > 0 ? 'ImageScaleBy ' : ''}${i > 1 ? i + ' ' : ''}`)
.flatMap((p) => [`${p}upscale_method`, `${p}scale_by`])
)
})
})