Browse Source
* setup ui unit tests * Refactoring, adding connections * Few tweaks * Fix type * Add general test * Refactored and extended test * move to describe * for groups * Add test for converted widgets on missing nodes + fix crash * tidy * mores tests + refactor * throw earlier to get less confusing error * support outputs * more test * add ci action * use lts node * Fix? * Prevent connecting non matching combos * update * accidently removed npm i * Disable logging extension * added step to generate object_info * fix python * install python * install deps * fix cwd? * logging * Fix double resolve * create dir * update pkgpull/1814/head
pythongosssss
1 year ago
committed by
GitHub
16 changed files with 6680 additions and 18 deletions
@ -0,0 +1,25 @@ |
|||||||
|
name: Tests CI |
||||||
|
|
||||||
|
on: [push, pull_request] |
||||||
|
|
||||||
|
jobs: |
||||||
|
test: |
||||||
|
runs-on: ubuntu-latest |
||||||
|
steps: |
||||||
|
- uses: actions/checkout@v4 |
||||||
|
- uses: actions/setup-node@v3 |
||||||
|
with: |
||||||
|
node-version: 18 |
||||||
|
- uses: actions/setup-python@v4 |
||||||
|
with: |
||||||
|
python-version: '3.10' |
||||||
|
- name: Install requirements |
||||||
|
run: | |
||||||
|
python -m pip install --upgrade pip |
||||||
|
pip install -r requirements.txt |
||||||
|
- name: Run Tests |
||||||
|
run: | |
||||||
|
npm install |
||||||
|
npm run test:generate |
||||||
|
npm test |
||||||
|
working-directory: ./tests-ui |
@ -0,0 +1,14 @@ |
|||||||
|
module.exports = async function () { |
||||||
|
global.ResizeObserver = class ResizeObserver { |
||||||
|
observe() {} |
||||||
|
unobserve() {} |
||||||
|
disconnect() {} |
||||||
|
}; |
||||||
|
|
||||||
|
const { nop } = require("./utils/nopProxy"); |
||||||
|
global.enableWebGLCanvas = nop; |
||||||
|
|
||||||
|
HTMLCanvasElement.prototype.getContext = nop; |
||||||
|
|
||||||
|
localStorage["Comfy.Settings.Comfy.Logging.Enabled"] = "false"; |
||||||
|
}; |
@ -0,0 +1,9 @@ |
|||||||
|
/** @type {import('jest').Config} */ |
||||||
|
const config = { |
||||||
|
testEnvironment: "jsdom", |
||||||
|
setupFiles: ["./globalSetup.js"], |
||||||
|
clearMocks: true, |
||||||
|
resetModules: true, |
||||||
|
}; |
||||||
|
|
||||||
|
module.exports = config; |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,30 @@ |
|||||||
|
{ |
||||||
|
"name": "comfui-tests", |
||||||
|
"version": "1.0.0", |
||||||
|
"description": "UI tests", |
||||||
|
"main": "index.js", |
||||||
|
"scripts": { |
||||||
|
"test": "jest", |
||||||
|
"test:generate": "node setup.js" |
||||||
|
}, |
||||||
|
"repository": { |
||||||
|
"type": "git", |
||||||
|
"url": "git+https://github.com/comfyanonymous/ComfyUI.git" |
||||||
|
}, |
||||||
|
"keywords": [ |
||||||
|
"comfyui", |
||||||
|
"test" |
||||||
|
], |
||||||
|
"author": "comfyanonymous", |
||||||
|
"license": "GPL-3.0", |
||||||
|
"bugs": { |
||||||
|
"url": "https://github.com/comfyanonymous/ComfyUI/issues" |
||||||
|
}, |
||||||
|
"homepage": "https://github.com/comfyanonymous/ComfyUI#readme", |
||||||
|
"devDependencies": { |
||||||
|
"@babel/preset-env": "^7.22.20", |
||||||
|
"@types/jest": "^29.5.5", |
||||||
|
"jest": "^29.7.0", |
||||||
|
"jest-environment-jsdom": "^29.7.0" |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,87 @@ |
|||||||
|
const { spawn } = require("child_process"); |
||||||
|
const { resolve } = require("path"); |
||||||
|
const { existsSync, mkdirSync, writeFileSync } = require("fs"); |
||||||
|
const http = require("http"); |
||||||
|
|
||||||
|
async function setup() { |
||||||
|
// Wait up to 30s for it to start
|
||||||
|
let success = false; |
||||||
|
let child; |
||||||
|
for (let i = 0; i < 30; i++) { |
||||||
|
try { |
||||||
|
await new Promise((res, rej) => { |
||||||
|
http |
||||||
|
.get("http://127.0.0.1:8188/object_info", (resp) => { |
||||||
|
let data = ""; |
||||||
|
resp.on("data", (chunk) => { |
||||||
|
data += chunk; |
||||||
|
}); |
||||||
|
resp.on("end", () => { |
||||||
|
// Modify the response data to add some checkpoints
|
||||||
|
const objectInfo = JSON.parse(data); |
||||||
|
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name[0] = ["model1.safetensors", "model2.ckpt"]; |
||||||
|
|
||||||
|
data = JSON.stringify(objectInfo, undefined, "\t"); |
||||||
|
|
||||||
|
const outDir = resolve("./data"); |
||||||
|
if (!existsSync(outDir)) { |
||||||
|
mkdirSync(outDir); |
||||||
|
} |
||||||
|
|
||||||
|
const outPath = resolve(outDir, "object_info.json"); |
||||||
|
console.log(`Writing ${Object.keys(objectInfo).length} nodes to ${outPath}`); |
||||||
|
writeFileSync(outPath, data, { |
||||||
|
encoding: "utf8", |
||||||
|
}); |
||||||
|
res(); |
||||||
|
}); |
||||||
|
}) |
||||||
|
.on("error", rej); |
||||||
|
}); |
||||||
|
success = true; |
||||||
|
break; |
||||||
|
} catch (error) { |
||||||
|
console.log(i + "/30", error); |
||||||
|
if (i === 0) { |
||||||
|
// Start the server on first iteration if it fails to connect
|
||||||
|
console.log("Starting ComfyUI server..."); |
||||||
|
|
||||||
|
let python = resolve("../../python_embeded/python.exe"); |
||||||
|
let args; |
||||||
|
let cwd; |
||||||
|
if (existsSync(python)) { |
||||||
|
args = ["-s", "ComfyUI/main.py"]; |
||||||
|
cwd = "../.."; |
||||||
|
} else { |
||||||
|
python = "python"; |
||||||
|
args = ["main.py"]; |
||||||
|
cwd = ".."; |
||||||
|
} |
||||||
|
args.push("--cpu"); |
||||||
|
console.log(python, ...args); |
||||||
|
child = spawn(python, args, { cwd }); |
||||||
|
child.on("error", (err) => { |
||||||
|
console.log(`Server error (${err})`); |
||||||
|
i = 30; |
||||||
|
}); |
||||||
|
child.on("exit", (code) => { |
||||||
|
if (!success) { |
||||||
|
console.log(`Server exited (${code})`); |
||||||
|
i = 30; |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
await new Promise((r) => { |
||||||
|
setTimeout(r, 1000); |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
child?.kill(); |
||||||
|
|
||||||
|
if (!success) { |
||||||
|
throw new Error("Waiting for server failed..."); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
setup(); |
@ -0,0 +1,319 @@ |
|||||||
|
// @ts-check
|
||||||
|
/// <reference path="../node_modules/@types/jest/index.d.ts" />
|
||||||
|
|
||||||
|
const { start, makeNodeDef, checkBeforeAndAfterReload, assertNotNullOrUndefined } = require("../utils"); |
||||||
|
const lg = require("../utils/litegraph"); |
||||||
|
|
||||||
|
/** |
||||||
|
* @typedef { import("../utils/ezgraph") } Ez |
||||||
|
* @typedef { ReturnType<Ez["Ez"]["graph"]>["ez"] } EzNodeFactory |
||||||
|
*/ |
||||||
|
|
||||||
|
/** |
||||||
|
* @param { EzNodeFactory } ez |
||||||
|
* @param { InstanceType<Ez["EzGraph"]> } graph |
||||||
|
* @param { InstanceType<Ez["EzInput"]> } input |
||||||
|
* @param { string } widgetType |
||||||
|
* @param { boolean } hasControlWidget |
||||||
|
* @returns |
||||||
|
*/ |
||||||
|
async function connectPrimitiveAndReload(ez, graph, input, widgetType, hasControlWidget) { |
||||||
|
// Connect to primitive and ensure its still connected after
|
||||||
|
let primitive = ez.PrimitiveNode(); |
||||||
|
primitive.outputs[0].connectTo(input); |
||||||
|
|
||||||
|
await checkBeforeAndAfterReload(graph, async () => { |
||||||
|
primitive = graph.find(primitive); |
||||||
|
let { connections } = primitive.outputs[0]; |
||||||
|
expect(connections).toHaveLength(1); |
||||||
|
expect(connections[0].targetNode.id).toBe(input.node.node.id); |
||||||
|
|
||||||
|
// Ensure widget is correct type
|
||||||
|
const valueWidget = primitive.widgets.value; |
||||||
|
expect(valueWidget.widget.type).toBe(widgetType); |
||||||
|
|
||||||
|
// Check if control_after_generate should be added
|
||||||
|
if (hasControlWidget) { |
||||||
|
const controlWidget = primitive.widgets.control_after_generate; |
||||||
|
expect(controlWidget.widget.type).toBe("combo"); |
||||||
|
} |
||||||
|
|
||||||
|
// Ensure we dont have other widgets
|
||||||
|
expect(primitive.node.widgets).toHaveLength(1 + +!!hasControlWidget); |
||||||
|
}); |
||||||
|
|
||||||
|
return primitive; |
||||||
|
} |
||||||
|
|
||||||
|
describe("widget inputs", () => { |
||||||
|
beforeEach(() => { |
||||||
|
lg.setup(global); |
||||||
|
}); |
||||||
|
|
||||||
|
afterEach(() => { |
||||||
|
lg.teardown(global); |
||||||
|
}); |
||||||
|
|
||||||
|
[ |
||||||
|
{ name: "int", type: "INT", widget: "number", control: true }, |
||||||
|
{ name: "float", type: "FLOAT", widget: "number", control: true }, |
||||||
|
{ name: "text", type: "STRING" }, |
||||||
|
{ |
||||||
|
name: "customtext", |
||||||
|
type: "STRING", |
||||||
|
opt: { multiline: true }, |
||||||
|
}, |
||||||
|
{ name: "toggle", type: "BOOLEAN" }, |
||||||
|
{ name: "combo", type: ["a", "b", "c"], control: true }, |
||||||
|
].forEach((c) => { |
||||||
|
test(`widget conversion + primitive works on ${c.name}`, async () => { |
||||||
|
const { ez, graph } = await start({ |
||||||
|
mockNodeDefs: makeNodeDef("TestNode", { [c.name]: [c.type, c.opt ?? {}] }), |
||||||
|
}); |
||||||
|
|
||||||
|
// Create test node and convert to input
|
||||||
|
const n = ez.TestNode(); |
||||||
|
const w = n.widgets[c.name]; |
||||||
|
w.convertToInput(); |
||||||
|
expect(w.isConvertedToInput).toBeTruthy(); |
||||||
|
const input = w.getConvertedInput(); |
||||||
|
expect(input).toBeTruthy(); |
||||||
|
|
||||||
|
// @ts-ignore : input is valid here
|
||||||
|
await connectPrimitiveAndReload(ez, graph, input, c.widget ?? c.name, c.control); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
test("converted widget works after reload", async () => { |
||||||
|
const { ez, graph } = await start(); |
||||||
|
let n = ez.CheckpointLoaderSimple(); |
||||||
|
|
||||||
|
const inputCount = n.inputs.length; |
||||||
|
|
||||||
|
// Convert ckpt name to an input
|
||||||
|
n.widgets.ckpt_name.convertToInput(); |
||||||
|
expect(n.widgets.ckpt_name.isConvertedToInput).toBeTruthy(); |
||||||
|
expect(n.inputs.ckpt_name).toBeTruthy(); |
||||||
|
expect(n.inputs.length).toEqual(inputCount + 1); |
||||||
|
|
||||||
|
// Convert back to widget and ensure input is removed
|
||||||
|
n.widgets.ckpt_name.convertToWidget(); |
||||||
|
expect(n.widgets.ckpt_name.isConvertedToInput).toBeFalsy(); |
||||||
|
expect(n.inputs.ckpt_name).toBeFalsy(); |
||||||
|
expect(n.inputs.length).toEqual(inputCount); |
||||||
|
|
||||||
|
// Convert again and reload the graph to ensure it maintains state
|
||||||
|
n.widgets.ckpt_name.convertToInput(); |
||||||
|
expect(n.inputs.length).toEqual(inputCount + 1); |
||||||
|
|
||||||
|
const primitive = await connectPrimitiveAndReload(ez, graph, n.inputs.ckpt_name, "combo", true); |
||||||
|
|
||||||
|
// Disconnect & reconnect
|
||||||
|
primitive.outputs[0].connections[0].disconnect(); |
||||||
|
let { connections } = primitive.outputs[0]; |
||||||
|
expect(connections).toHaveLength(0); |
||||||
|
|
||||||
|
primitive.outputs[0].connectTo(n.inputs.ckpt_name); |
||||||
|
({ connections } = primitive.outputs[0]); |
||||||
|
expect(connections).toHaveLength(1); |
||||||
|
expect(connections[0].targetNode.id).toBe(n.node.id); |
||||||
|
|
||||||
|
// Convert back to widget and ensure input is removed
|
||||||
|
n.widgets.ckpt_name.convertToWidget(); |
||||||
|
expect(n.widgets.ckpt_name.isConvertedToInput).toBeFalsy(); |
||||||
|
expect(n.inputs.ckpt_name).toBeFalsy(); |
||||||
|
expect(n.inputs.length).toEqual(inputCount); |
||||||
|
}); |
||||||
|
|
||||||
|
test("converted widget works on clone", async () => { |
||||||
|
const { graph, ez } = await start(); |
||||||
|
let n = ez.CheckpointLoaderSimple(); |
||||||
|
|
||||||
|
// Convert the widget to an input
|
||||||
|
n.widgets.ckpt_name.convertToInput(); |
||||||
|
expect(n.widgets.ckpt_name.isConvertedToInput).toBeTruthy(); |
||||||
|
|
||||||
|
// Clone the node
|
||||||
|
n.menu["Clone"].call(); |
||||||
|
expect(graph.nodes).toHaveLength(2); |
||||||
|
const clone = graph.nodes[1]; |
||||||
|
expect(clone.id).not.toEqual(n.id); |
||||||
|
|
||||||
|
// Ensure the clone has an input
|
||||||
|
expect(clone.widgets.ckpt_name.isConvertedToInput).toBeTruthy(); |
||||||
|
expect(clone.inputs.ckpt_name).toBeTruthy(); |
||||||
|
|
||||||
|
// Ensure primitive connects to both nodes
|
||||||
|
let primitive = ez.PrimitiveNode(); |
||||||
|
primitive.outputs[0].connectTo(n.inputs.ckpt_name); |
||||||
|
primitive.outputs[0].connectTo(clone.inputs.ckpt_name); |
||||||
|
expect(primitive.outputs[0].connections).toHaveLength(2); |
||||||
|
|
||||||
|
// Convert back to widget and ensure input is removed
|
||||||
|
clone.widgets.ckpt_name.convertToWidget(); |
||||||
|
expect(clone.widgets.ckpt_name.isConvertedToInput).toBeFalsy(); |
||||||
|
expect(clone.inputs.ckpt_name).toBeFalsy(); |
||||||
|
}); |
||||||
|
|
||||||
|
test("shows missing node error on custom node with converted input", 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: 4, |
||||||
|
nodes: [ |
||||||
|
{ |
||||||
|
id: 1, |
||||||
|
type: "TestNode", |
||||||
|
pos: [41.87329101561909, 389.7381480823742], |
||||||
|
size: { 0: 220, 1: 374 }, |
||||||
|
flags: {}, |
||||||
|
order: 1, |
||||||
|
mode: 0, |
||||||
|
inputs: [{ name: "test", type: "FLOAT", link: 4, widget: { name: "test" }, slot_index: 0 }], |
||||||
|
outputs: [], |
||||||
|
properties: { "Node name for S&R": "TestNode" }, |
||||||
|
widgets_values: [1], |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: 3, |
||||||
|
type: "PrimitiveNode", |
||||||
|
pos: [-312, 433], |
||||||
|
size: { 0: 210, 1: 82 }, |
||||||
|
flags: {}, |
||||||
|
order: 0, |
||||||
|
mode: 0, |
||||||
|
outputs: [{ links: [4], widget: { name: "test" } }], |
||||||
|
title: "test", |
||||||
|
properties: {}, |
||||||
|
}, |
||||||
|
], |
||||||
|
links: [[4, 3, 0, 1, 6, "FLOAT"]], |
||||||
|
groups: [], |
||||||
|
config: {}, |
||||||
|
extra: {}, |
||||||
|
version: 0.4, |
||||||
|
}); |
||||||
|
|
||||||
|
expect(dialogShow).toBeCalledTimes(1); |
||||||
|
expect(dialogShow.mock.calls[0][0]).toContain("the following node types were not found"); |
||||||
|
expect(dialogShow.mock.calls[0][0]).toContain("TestNode"); |
||||||
|
}); |
||||||
|
|
||||||
|
test("defaultInput widgets can be converted back to inputs", async () => { |
||||||
|
const { graph, ez } = await start({ |
||||||
|
mockNodeDefs: makeNodeDef("TestNode", { example: ["INT", { defaultInput: true }] }), |
||||||
|
}); |
||||||
|
|
||||||
|
// Create test node and ensure it starts as an input
|
||||||
|
let n = ez.TestNode(); |
||||||
|
let w = n.widgets.example; |
||||||
|
expect(w.isConvertedToInput).toBeTruthy(); |
||||||
|
let input = w.getConvertedInput(); |
||||||
|
expect(input).toBeTruthy(); |
||||||
|
|
||||||
|
// Ensure it can be converted to
|
||||||
|
w.convertToWidget(); |
||||||
|
expect(w.isConvertedToInput).toBeFalsy(); |
||||||
|
expect(n.inputs.length).toEqual(0); |
||||||
|
// and from
|
||||||
|
w.convertToInput(); |
||||||
|
expect(w.isConvertedToInput).toBeTruthy(); |
||||||
|
input = w.getConvertedInput(); |
||||||
|
|
||||||
|
// Reload and ensure it still only has 1 converted widget
|
||||||
|
if (!assertNotNullOrUndefined(input)) return; |
||||||
|
|
||||||
|
await connectPrimitiveAndReload(ez, graph, input, "number", true); |
||||||
|
n = graph.find(n); |
||||||
|
expect(n.widgets).toHaveLength(1); |
||||||
|
w = n.widgets.example; |
||||||
|
expect(w.isConvertedToInput).toBeTruthy(); |
||||||
|
|
||||||
|
// Convert back to widget and ensure it is still a widget after reload
|
||||||
|
w.convertToWidget(); |
||||||
|
await graph.reload(); |
||||||
|
n = graph.find(n); |
||||||
|
expect(n.widgets).toHaveLength(1); |
||||||
|
expect(n.widgets[0].isConvertedToInput).toBeFalsy(); |
||||||
|
expect(n.inputs.length).toEqual(0); |
||||||
|
}); |
||||||
|
|
||||||
|
test("forceInput widgets can not be converted back to inputs", async () => { |
||||||
|
const { graph, ez } = await start({ |
||||||
|
mockNodeDefs: makeNodeDef("TestNode", { example: ["INT", { forceInput: true }] }), |
||||||
|
}); |
||||||
|
|
||||||
|
// Create test node and ensure it starts as an input
|
||||||
|
let n = ez.TestNode(); |
||||||
|
let w = n.widgets.example; |
||||||
|
expect(w.isConvertedToInput).toBeTruthy(); |
||||||
|
const input = w.getConvertedInput(); |
||||||
|
expect(input).toBeTruthy(); |
||||||
|
|
||||||
|
// Convert to widget should error
|
||||||
|
expect(() => w.convertToWidget()).toThrow(); |
||||||
|
|
||||||
|
// Reload and ensure it still only has 1 converted widget
|
||||||
|
if (assertNotNullOrUndefined(input)) { |
||||||
|
await connectPrimitiveAndReload(ez, graph, input, "number", true); |
||||||
|
n = graph.find(n); |
||||||
|
expect(n.widgets).toHaveLength(1); |
||||||
|
expect(n.widgets.example.isConvertedToInput).toBeTruthy(); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
test("primitive can connect to matching combos on converted widgets", async () => { |
||||||
|
const { ez } = await start({ |
||||||
|
mockNodeDefs: { |
||||||
|
...makeNodeDef("TestNode1", { example: [["A", "B", "C"], { forceInput: true }] }), |
||||||
|
...makeNodeDef("TestNode2", { example: [["A", "B", "C"], { forceInput: true }] }), |
||||||
|
}, |
||||||
|
}); |
||||||
|
|
||||||
|
const n1 = ez.TestNode1(); |
||||||
|
const n2 = ez.TestNode2(); |
||||||
|
const p = ez.PrimitiveNode(); |
||||||
|
p.outputs[0].connectTo(n1.inputs[0]); |
||||||
|
p.outputs[0].connectTo(n2.inputs[0]); |
||||||
|
expect(p.outputs[0].connections).toHaveLength(2); |
||||||
|
const valueWidget = p.widgets.value; |
||||||
|
expect(valueWidget.widget.type).toBe("combo"); |
||||||
|
expect(valueWidget.widget.options.values).toEqual(["A", "B", "C"]); |
||||||
|
}); |
||||||
|
|
||||||
|
test("primitive can not connect to non matching combos on converted widgets", async () => { |
||||||
|
const { ez } = await start({ |
||||||
|
mockNodeDefs: { |
||||||
|
...makeNodeDef("TestNode1", { example: [["A", "B", "C"], { forceInput: true }] }), |
||||||
|
...makeNodeDef("TestNode2", { example: [["A", "B"], { forceInput: true }] }), |
||||||
|
}, |
||||||
|
}); |
||||||
|
|
||||||
|
const n1 = ez.TestNode1(); |
||||||
|
const n2 = ez.TestNode2(); |
||||||
|
const p = ez.PrimitiveNode(); |
||||||
|
p.outputs[0].connectTo(n1.inputs[0]); |
||||||
|
expect(() => p.outputs[0].connectTo(n2.inputs[0])).toThrow(); |
||||||
|
expect(p.outputs[0].connections).toHaveLength(1); |
||||||
|
}); |
||||||
|
|
||||||
|
test("combo output can not connect to non matching combos list input", async () => { |
||||||
|
const { ez } = await start({ |
||||||
|
mockNodeDefs: { |
||||||
|
...makeNodeDef("TestNode1", {}, [["A", "B"]]), |
||||||
|
...makeNodeDef("TestNode2", { example: [["A", "B"], { forceInput: true}] }), |
||||||
|
...makeNodeDef("TestNode3", { example: [["A", "B", "C"], { forceInput: true}] }), |
||||||
|
}, |
||||||
|
}); |
||||||
|
|
||||||
|
const n1 = ez.TestNode1(); |
||||||
|
const n2 = ez.TestNode2(); |
||||||
|
const n3 = ez.TestNode3(); |
||||||
|
|
||||||
|
n1.outputs[0].connectTo(n2.inputs[0]); |
||||||
|
expect(() => n1.outputs[0].connectTo(n3.inputs[0])).toThrow(); |
||||||
|
}); |
||||||
|
}); |
@ -0,0 +1,417 @@ |
|||||||
|
// @ts-check
|
||||||
|
/// <reference path="../../web/types/litegraph.d.ts" />
|
||||||
|
|
||||||
|
/** |
||||||
|
* @typedef { import("../../web/scripts/app")["app"] } app |
||||||
|
* @typedef { import("../../web/types/litegraph") } LG |
||||||
|
* @typedef { import("../../web/types/litegraph").IWidget } IWidget |
||||||
|
* @typedef { import("../../web/types/litegraph").ContextMenuItem } ContextMenuItem |
||||||
|
* @typedef { import("../../web/types/litegraph").INodeInputSlot } INodeInputSlot |
||||||
|
* @typedef { import("../../web/types/litegraph").INodeOutputSlot } INodeOutputSlot |
||||||
|
* @typedef { InstanceType<LG["LGraphNode"]> & { widgets?: Array<IWidget> } } LGNode |
||||||
|
* @typedef { (...args: EzOutput[] | [...EzOutput[], Record<string, unknown>]) => EzNode } EzNodeFactory |
||||||
|
*/ |
||||||
|
|
||||||
|
export class EzConnection { |
||||||
|
/** @type { app } */ |
||||||
|
app; |
||||||
|
/** @type { InstanceType<LG["LLink"]> } */ |
||||||
|
link; |
||||||
|
|
||||||
|
get originNode() { |
||||||
|
return new EzNode(this.app, this.app.graph.getNodeById(this.link.origin_id)); |
||||||
|
} |
||||||
|
|
||||||
|
get originOutput() { |
||||||
|
return this.originNode.outputs[this.link.origin_slot]; |
||||||
|
} |
||||||
|
|
||||||
|
get targetNode() { |
||||||
|
return new EzNode(this.app, this.app.graph.getNodeById(this.link.target_id)); |
||||||
|
} |
||||||
|
|
||||||
|
get targetInput() { |
||||||
|
return this.targetNode.inputs[this.link.target_slot]; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param { app } app |
||||||
|
* @param { InstanceType<LG["LLink"]> } link |
||||||
|
*/ |
||||||
|
constructor(app, link) { |
||||||
|
this.app = app; |
||||||
|
this.link = link; |
||||||
|
} |
||||||
|
|
||||||
|
disconnect() { |
||||||
|
this.targetInput.disconnect(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export class EzSlot { |
||||||
|
/** @type { EzNode } */ |
||||||
|
node; |
||||||
|
/** @type { number } */ |
||||||
|
index; |
||||||
|
|
||||||
|
/** |
||||||
|
* @param { EzNode } node |
||||||
|
* @param { number } index |
||||||
|
*/ |
||||||
|
constructor(node, index) { |
||||||
|
this.node = node; |
||||||
|
this.index = index; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export class EzInput extends EzSlot { |
||||||
|
/** @type { INodeInputSlot } */ |
||||||
|
input; |
||||||
|
|
||||||
|
/** |
||||||
|
* @param { EzNode } node |
||||||
|
* @param { number } index |
||||||
|
* @param { INodeInputSlot } input |
||||||
|
*/ |
||||||
|
constructor(node, index, input) { |
||||||
|
super(node, index); |
||||||
|
this.input = input; |
||||||
|
} |
||||||
|
|
||||||
|
disconnect() { |
||||||
|
this.node.node.disconnectInput(this.index); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export class EzOutput extends EzSlot { |
||||||
|
/** @type { INodeOutputSlot } */ |
||||||
|
output; |
||||||
|
|
||||||
|
/** |
||||||
|
* @param { EzNode } node |
||||||
|
* @param { number } index |
||||||
|
* @param { INodeOutputSlot } output |
||||||
|
*/ |
||||||
|
constructor(node, index, output) { |
||||||
|
super(node, index); |
||||||
|
this.output = output; |
||||||
|
} |
||||||
|
|
||||||
|
get connections() { |
||||||
|
return (this.node.node.outputs?.[this.index]?.links ?? []).map( |
||||||
|
(l) => new EzConnection(this.node.app, this.node.app.graph.links[l]) |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param { EzInput } input |
||||||
|
*/ |
||||||
|
connectTo(input) { |
||||||
|
if (!input) throw new Error("Invalid input"); |
||||||
|
|
||||||
|
/** |
||||||
|
* @type { LG["LLink"] | null } |
||||||
|
*/ |
||||||
|
const link = this.node.node.connect(this.index, input.node.node, input.index); |
||||||
|
if (!link) { |
||||||
|
const inp = input.input; |
||||||
|
const inName = inp.name || inp.label || inp.type; |
||||||
|
throw new Error( |
||||||
|
`Connecting from ${input.node.node.type}[${inName}#${input.index}] -> ${this.node.node.type}[${ |
||||||
|
this.output.name ?? this.output.type |
||||||
|
}#${this.index}] failed.` |
||||||
|
); |
||||||
|
} |
||||||
|
return link; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export class EzNodeMenuItem { |
||||||
|
/** @type { EzNode } */ |
||||||
|
node; |
||||||
|
/** @type { number } */ |
||||||
|
index; |
||||||
|
/** @type { ContextMenuItem } */ |
||||||
|
item; |
||||||
|
|
||||||
|
/** |
||||||
|
* @param { EzNode } node |
||||||
|
* @param { number } index |
||||||
|
* @param { ContextMenuItem } item |
||||||
|
*/ |
||||||
|
constructor(node, index, item) { |
||||||
|
this.node = node; |
||||||
|
this.index = index; |
||||||
|
this.item = item; |
||||||
|
} |
||||||
|
|
||||||
|
call(selectNode = true) { |
||||||
|
if (!this.item?.callback) throw new Error(`Menu Item ${this.item?.content ?? "[null]"} has no callback.`); |
||||||
|
if (selectNode) { |
||||||
|
this.node.select(); |
||||||
|
} |
||||||
|
this.item.callback.call(this.node.node, undefined, undefined, undefined, undefined, this.node.node); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export class EzWidget { |
||||||
|
/** @type { EzNode } */ |
||||||
|
node; |
||||||
|
/** @type { number } */ |
||||||
|
index; |
||||||
|
/** @type { IWidget } */ |
||||||
|
widget; |
||||||
|
|
||||||
|
/** |
||||||
|
* @param { EzNode } node |
||||||
|
* @param { number } index |
||||||
|
* @param { IWidget } widget |
||||||
|
*/ |
||||||
|
constructor(node, index, widget) { |
||||||
|
this.node = node; |
||||||
|
this.index = index; |
||||||
|
this.widget = widget; |
||||||
|
} |
||||||
|
|
||||||
|
get value() { |
||||||
|
return this.widget.value; |
||||||
|
} |
||||||
|
|
||||||
|
set value(v) { |
||||||
|
this.widget.value = v; |
||||||
|
} |
||||||
|
|
||||||
|
get isConvertedToInput() { |
||||||
|
// @ts-ignore : this type is valid for converted widgets
|
||||||
|
return this.widget.type === "converted-widget"; |
||||||
|
} |
||||||
|
|
||||||
|
getConvertedInput() { |
||||||
|
if (!this.isConvertedToInput) throw new Error(`Widget ${this.widget.name} is not converted to input.`); |
||||||
|
|
||||||
|
return this.node.inputs.find((inp) => inp.input["widget"]?.name === this.widget.name); |
||||||
|
} |
||||||
|
|
||||||
|
convertToWidget() { |
||||||
|
if (!this.isConvertedToInput) |
||||||
|
throw new Error(`Widget ${this.widget.name} cannot be converted as it is already a widget.`); |
||||||
|
this.node.menu[`Convert ${this.widget.name} to widget`].call(); |
||||||
|
} |
||||||
|
|
||||||
|
convertToInput() { |
||||||
|
if (this.isConvertedToInput) |
||||||
|
throw new Error(`Widget ${this.widget.name} cannot be converted as it is already an input.`); |
||||||
|
this.node.menu[`Convert ${this.widget.name} to input`].call(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export class EzNode { |
||||||
|
/** @type { app } */ |
||||||
|
app; |
||||||
|
/** @type { LGNode } */ |
||||||
|
node; |
||||||
|
|
||||||
|
/** |
||||||
|
* @param { app } app |
||||||
|
* @param { LGNode } node |
||||||
|
*/ |
||||||
|
constructor(app, node) { |
||||||
|
this.app = app; |
||||||
|
this.node = node; |
||||||
|
} |
||||||
|
|
||||||
|
get id() { |
||||||
|
return this.node.id; |
||||||
|
} |
||||||
|
|
||||||
|
get inputs() { |
||||||
|
return this.#makeLookupArray("inputs", "name", EzInput); |
||||||
|
} |
||||||
|
|
||||||
|
get outputs() { |
||||||
|
return this.#makeLookupArray("outputs", "name", EzOutput); |
||||||
|
} |
||||||
|
|
||||||
|
get widgets() { |
||||||
|
return this.#makeLookupArray("widgets", "name", EzWidget); |
||||||
|
} |
||||||
|
|
||||||
|
get menu() { |
||||||
|
return this.#makeLookupArray(() => this.app.canvas.getNodeMenuOptions(this.node), "content", EzNodeMenuItem); |
||||||
|
} |
||||||
|
|
||||||
|
select() { |
||||||
|
this.app.canvas.selectNode(this.node); |
||||||
|
} |
||||||
|
|
||||||
|
// /**
|
||||||
|
// * @template { "inputs" | "outputs" } T
|
||||||
|
// * @param { T } type
|
||||||
|
// * @returns { Record<string, type extends "inputs" ? EzInput : EzOutput> & (type extends "inputs" ? EzInput [] : EzOutput[]) }
|
||||||
|
// */
|
||||||
|
// #getSlotItems(type) {
|
||||||
|
// // @ts-ignore : these items are correct
|
||||||
|
// return (this.node[type] ?? []).reduce((p, s, i) => {
|
||||||
|
// if (s.name in p) {
|
||||||
|
// throw new Error(`Unable to store input ${s.name} on array as name conflicts.`);
|
||||||
|
// }
|
||||||
|
// // @ts-ignore
|
||||||
|
// p.push((p[s.name] = new (type === "inputs" ? EzInput : EzOutput)(this, i, s)));
|
||||||
|
// return p;
|
||||||
|
// }, Object.assign([], { $: this }));
|
||||||
|
// }
|
||||||
|
|
||||||
|
/** |
||||||
|
* @template { { new(node: EzNode, index: number, obj: any): any } } T |
||||||
|
* @param { "inputs" | "outputs" | "widgets" | (() => Array<unknown>) } nodeProperty |
||||||
|
* @param { string } nameProperty |
||||||
|
* @param { T } ctor |
||||||
|
* @returns { Record<string, InstanceType<T>> & Array<InstanceType<T>> } |
||||||
|
*/ |
||||||
|
#makeLookupArray(nodeProperty, nameProperty, ctor) { |
||||||
|
const items = typeof nodeProperty === "function" ? nodeProperty() : this.node[nodeProperty]; |
||||||
|
// @ts-ignore
|
||||||
|
return (items ?? []).reduce((p, s, i) => { |
||||||
|
if (!s) return p; |
||||||
|
|
||||||
|
const name = s[nameProperty]; |
||||||
|
// @ts-ignore
|
||||||
|
if (!name || name in p) { |
||||||
|
throw new Error(`Unable to store ${nodeProperty} ${name} on array as name conflicts.`); |
||||||
|
} |
||||||
|
// @ts-ignore
|
||||||
|
p.push((p[name] = new ctor(this, i, s))); |
||||||
|
return p; |
||||||
|
}, Object.assign([], { $: this })); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export class EzGraph { |
||||||
|
/** @type { app } */ |
||||||
|
app; |
||||||
|
|
||||||
|
/** |
||||||
|
* @param { app } app |
||||||
|
*/ |
||||||
|
constructor(app) { |
||||||
|
this.app = app; |
||||||
|
} |
||||||
|
|
||||||
|
get nodes() { |
||||||
|
return this.app.graph._nodes.map((n) => new EzNode(this.app, n)); |
||||||
|
} |
||||||
|
|
||||||
|
clear() { |
||||||
|
this.app.graph.clear(); |
||||||
|
} |
||||||
|
|
||||||
|
arrange() { |
||||||
|
this.app.graph.arrange(); |
||||||
|
} |
||||||
|
|
||||||
|
stringify() { |
||||||
|
return JSON.stringify(this.app.graph.serialize(), undefined, "\t"); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param { number | LGNode | EzNode } obj |
||||||
|
* @returns { EzNode } |
||||||
|
*/ |
||||||
|
find(obj) { |
||||||
|
let match; |
||||||
|
let id; |
||||||
|
if (typeof obj === "number") { |
||||||
|
id = obj; |
||||||
|
} else { |
||||||
|
id = obj.id; |
||||||
|
} |
||||||
|
|
||||||
|
match = this.app.graph.getNodeById(id); |
||||||
|
|
||||||
|
if (!match) { |
||||||
|
throw new Error(`Unable to find node with ID ${id}.`); |
||||||
|
} |
||||||
|
|
||||||
|
return new EzNode(this.app, match); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @returns { Promise<void> } |
||||||
|
*/ |
||||||
|
reload() { |
||||||
|
const graph = JSON.parse(JSON.stringify(this.app.graph.serialize())); |
||||||
|
return new Promise((r) => { |
||||||
|
this.app.graph.clear(); |
||||||
|
setTimeout(async () => { |
||||||
|
await this.app.loadGraphData(graph); |
||||||
|
r(); |
||||||
|
}, 10); |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export const Ez = { |
||||||
|
/** |
||||||
|
* Quickly build and interact with a ComfyUI graph |
||||||
|
* @example |
||||||
|
* const { ez, graph } = Ez.graph(app); |
||||||
|
* graph.clear(); |
||||||
|
* const [model, clip, vae] = ez.CheckpointLoaderSimple(); |
||||||
|
* const [pos] = ez.CLIPTextEncode(clip, { text: "positive" }); |
||||||
|
* const [neg] = ez.CLIPTextEncode(clip, { text: "negative" }); |
||||||
|
* const [latent] = ez.KSampler(model, pos, neg, ...ez.EmptyLatentImage()); |
||||||
|
* const [image] = ez.VAEDecode(latent, vae); |
||||||
|
* const saveNode = ez.SaveImage(image).node; |
||||||
|
* console.log(saveNode); |
||||||
|
* graph.arrange(); |
||||||
|
* @param { app } app |
||||||
|
* @param { LG["LiteGraph"] } LiteGraph |
||||||
|
* @param { LG["LGraphCanvas"] } LGraphCanvas |
||||||
|
* @param { boolean } clearGraph |
||||||
|
* @returns { { graph: EzGraph, ez: Record<string, EzNodeFactory> } } |
||||||
|
*/ |
||||||
|
graph(app, LiteGraph = window["LiteGraph"], LGraphCanvas = window["LGraphCanvas"], clearGraph = true) { |
||||||
|
// Always set the active canvas so things work
|
||||||
|
LGraphCanvas.active_canvas = app.canvas; |
||||||
|
|
||||||
|
if (clearGraph) { |
||||||
|
app.graph.clear(); |
||||||
|
} |
||||||
|
|
||||||
|
// @ts-ignore : this proxy handles utility methods & node creation
|
||||||
|
const factory = new Proxy( |
||||||
|
{}, |
||||||
|
{ |
||||||
|
get(_, p) { |
||||||
|
if (typeof p !== "string") throw new Error("Invalid node"); |
||||||
|
const node = LiteGraph.createNode(p); |
||||||
|
if (!node) throw new Error(`Unknown node "${p}"`); |
||||||
|
app.graph.add(node); |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {Parameters<EzNodeFactory>} args |
||||||
|
*/ |
||||||
|
return function (...args) { |
||||||
|
const ezNode = new EzNode(app, node); |
||||||
|
const inputs = ezNode.inputs; |
||||||
|
|
||||||
|
let slot = 0; |
||||||
|
for (const arg of args) { |
||||||
|
if (arg instanceof EzOutput) { |
||||||
|
arg.connectTo(inputs[slot++]); |
||||||
|
} else { |
||||||
|
for (const k in arg) { |
||||||
|
ezNode.widgets[k].value = arg[k]; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return ezNode; |
||||||
|
}; |
||||||
|
}, |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
return { graph: new EzGraph(app), ez: factory }; |
||||||
|
}, |
||||||
|
}; |
@ -0,0 +1,71 @@ |
|||||||
|
const { mockApi } = require("./setup"); |
||||||
|
const { Ez } = require("./ezgraph"); |
||||||
|
|
||||||
|
/** |
||||||
|
* |
||||||
|
* @param { Parameters<mockApi>[0] } config |
||||||
|
* @returns |
||||||
|
*/ |
||||||
|
export async function start(config = undefined) { |
||||||
|
mockApi(config); |
||||||
|
const { app } = require("../../web/scripts/app"); |
||||||
|
await app.setup(); |
||||||
|
return Ez.graph(app, global["LiteGraph"], global["LGraphCanvas"]); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param { ReturnType<Ez["graph"]>["graph"] } graph
|
||||||
|
* @param { (hasReloaded: boolean) => (Promise<void> | void) } cb
|
||||||
|
*/ |
||||||
|
export async function checkBeforeAndAfterReload(graph, cb) { |
||||||
|
await cb(false); |
||||||
|
await graph.reload(); |
||||||
|
await cb(true); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param { string } name
|
||||||
|
* @param { Record<string, string | [string | string[], any]> } input
|
||||||
|
* @param { (string | string[])[] | Record<string, string | string[]> } output |
||||||
|
* @returns { Record<string, import("../../web/types/comfy").ComfyObjectInfo> }
|
||||||
|
*/ |
||||||
|
export function makeNodeDef(name, input, output = {}) { |
||||||
|
const nodeDef = { |
||||||
|
name, |
||||||
|
category: "test", |
||||||
|
output: [], |
||||||
|
output_name: [], |
||||||
|
output_is_list: [], |
||||||
|
input: { |
||||||
|
required: {} |
||||||
|
}, |
||||||
|
}; |
||||||
|
for(const k in input) { |
||||||
|
nodeDef.input.required[k] = typeof input[k] === "string" ? [input[k], {}] : [...input[k]]; |
||||||
|
} |
||||||
|
if(output instanceof Array) { |
||||||
|
output = output.reduce((p, c) => { |
||||||
|
p[c] = c; |
||||||
|
return p; |
||||||
|
}, {}) |
||||||
|
} |
||||||
|
for(const k in output) { |
||||||
|
nodeDef.output.push(output[k]); |
||||||
|
nodeDef.output_name.push(k); |
||||||
|
nodeDef.output_is_list.push(false); |
||||||
|
} |
||||||
|
|
||||||
|
return { [name]: nodeDef }; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
/** |
||||||
|
* @template { any } T |
||||||
|
* @param { T } x |
||||||
|
* @returns { x is Exclude<T, null | undefined> } |
||||||
|
*/ |
||||||
|
export function assertNotNullOrUndefined(x) { |
||||||
|
expect(x).not.toEqual(null); |
||||||
|
expect(x).not.toEqual(undefined); |
||||||
|
return true; |
||||||
|
} |
@ -0,0 +1,36 @@ |
|||||||
|
const fs = require("fs"); |
||||||
|
const path = require("path"); |
||||||
|
const { nop } = require("../utils/nopProxy"); |
||||||
|
|
||||||
|
function forEachKey(cb) { |
||||||
|
for (const k of [ |
||||||
|
"LiteGraph", |
||||||
|
"LGraph", |
||||||
|
"LLink", |
||||||
|
"LGraphNode", |
||||||
|
"LGraphGroup", |
||||||
|
"DragAndScale", |
||||||
|
"LGraphCanvas", |
||||||
|
"ContextMenu", |
||||||
|
]) { |
||||||
|
cb(k); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function setup(ctx) { |
||||||
|
const lg = fs.readFileSync(path.resolve("../web/lib/litegraph.core.js"), "utf-8"); |
||||||
|
const globalTemp = {}; |
||||||
|
(function (console) { |
||||||
|
eval(lg); |
||||||
|
}).call(globalTemp, nop); |
||||||
|
|
||||||
|
forEachKey((k) => (ctx[k] = globalTemp[k])); |
||||||
|
require(path.resolve("../web/lib/litegraph.extensions.js")); |
||||||
|
} |
||||||
|
|
||||||
|
export function teardown(ctx) { |
||||||
|
forEachKey((k) => delete ctx[k]); |
||||||
|
|
||||||
|
// Clear document after each run
|
||||||
|
document.getElementsByTagName("html")[0].innerHTML = "";
|
||||||
|
} |
@ -0,0 +1,6 @@ |
|||||||
|
export const nop = new Proxy(function () {}, { |
||||||
|
get: () => nop, |
||||||
|
set: () => true, |
||||||
|
apply: () => nop, |
||||||
|
construct: () => nop, |
||||||
|
}); |
@ -0,0 +1,45 @@ |
|||||||
|
require("../../web/scripts/api"); |
||||||
|
|
||||||
|
const fs = require("fs"); |
||||||
|
const path = require("path"); |
||||||
|
function* walkSync(dir) { |
||||||
|
const files = fs.readdirSync(dir, { withFileTypes: true }); |
||||||
|
for (const file of files) { |
||||||
|
if (file.isDirectory()) { |
||||||
|
yield* walkSync(path.join(dir, file.name)); |
||||||
|
} else { |
||||||
|
yield path.join(dir, file.name); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @typedef { import("../../web/types/comfy").ComfyObjectInfo } ComfyObjectInfo |
||||||
|
*/ |
||||||
|
|
||||||
|
/** |
||||||
|
* @param { { mockExtensions?: string[], mockNodeDefs?: Record<string, ComfyObjectInfo> } } config |
||||||
|
*/ |
||||||
|
export function mockApi({ mockExtensions, mockNodeDefs } = {}) { |
||||||
|
if (!mockExtensions) { |
||||||
|
mockExtensions = Array.from(walkSync(path.resolve("../web/extensions/core"))) |
||||||
|
.filter((x) => x.endsWith(".js")) |
||||||
|
.map((x) => path.relative(path.resolve("../web"), x)); |
||||||
|
} |
||||||
|
if (!mockNodeDefs) { |
||||||
|
mockNodeDefs = JSON.parse(fs.readFileSync(path.resolve("./data/object_info.json"))); |
||||||
|
} |
||||||
|
|
||||||
|
jest.mock("../../web/scripts/api", () => ({ |
||||||
|
get api() { |
||||||
|
return { |
||||||
|
addEventListener: jest.fn(), |
||||||
|
getSystemStats: jest.fn(), |
||||||
|
getExtensions: jest.fn(() => mockExtensions), |
||||||
|
getNodeDefs: jest.fn(() => mockNodeDefs), |
||||||
|
init: jest.fn(), |
||||||
|
apiURL: jest.fn((x) => "../../web/" + x), |
||||||
|
}; |
||||||
|
}, |
||||||
|
})); |
||||||
|
} |
Loading…
Reference in new issue