surena26 commited on
Commit
650bd72
·
verified ·
1 Parent(s): 6f7e8eb

Upload folder using huggingface_hub

Browse files
ComfyUI/tests-ui/.gitignore ADDED
@@ -0,0 +1 @@
 
 
1
+ node_modules
ComfyUI/tests-ui/afterSetup.js ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ const { start } = require("./utils");
2
+ const lg = require("./utils/litegraph");
3
+
4
+ // Load things once per test file before to ensure its all warmed up for the tests
5
+ beforeAll(async () => {
6
+ lg.setup(global);
7
+ await start({ resetEnv: true });
8
+ lg.teardown(global);
9
+ });
ComfyUI/tests-ui/babel.config.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "presets": ["@babel/preset-env"],
3
+ "plugins": ["babel-plugin-transform-import-meta"]
4
+ }
ComfyUI/tests-ui/globalSetup.js ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ module.exports = async function () {
2
+ global.ResizeObserver = class ResizeObserver {
3
+ observe() {}
4
+ unobserve() {}
5
+ disconnect() {}
6
+ };
7
+
8
+ const { nop } = require("./utils/nopProxy");
9
+ global.enableWebGLCanvas = nop;
10
+
11
+ HTMLCanvasElement.prototype.getContext = nop;
12
+
13
+ localStorage["Comfy.Settings.Comfy.Logging.Enabled"] = "false";
14
+ };
ComfyUI/tests-ui/jest.config.js ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('jest').Config} */
2
+ const config = {
3
+ testEnvironment: "jsdom",
4
+ setupFiles: ["./globalSetup.js"],
5
+ setupFilesAfterEnv: ["./afterSetup.js"],
6
+ clearMocks: true,
7
+ resetModules: true,
8
+ testTimeout: 10000
9
+ };
10
+
11
+ module.exports = config;
ComfyUI/tests-ui/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
ComfyUI/tests-ui/package.json ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "comfui-tests",
3
+ "version": "1.0.0",
4
+ "description": "UI tests",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "jest",
8
+ "test:generate": "node setup.js"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/comfyanonymous/ComfyUI.git"
13
+ },
14
+ "keywords": [
15
+ "comfyui",
16
+ "test"
17
+ ],
18
+ "author": "comfyanonymous",
19
+ "license": "GPL-3.0",
20
+ "bugs": {
21
+ "url": "https://github.com/comfyanonymous/ComfyUI/issues"
22
+ },
23
+ "homepage": "https://github.com/comfyanonymous/ComfyUI#readme",
24
+ "devDependencies": {
25
+ "@babel/preset-env": "^7.22.20",
26
+ "@types/jest": "^29.5.5",
27
+ "babel-plugin-transform-import-meta": "^2.2.1",
28
+ "jest": "^29.7.0",
29
+ "jest-environment-jsdom": "^29.7.0"
30
+ }
31
+ }
ComfyUI/tests-ui/setup.js ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { spawn } = require("child_process");
2
+ const { resolve } = require("path");
3
+ const { existsSync, mkdirSync, writeFileSync } = require("fs");
4
+ const http = require("http");
5
+
6
+ async function setup() {
7
+ // Wait up to 30s for it to start
8
+ let success = false;
9
+ let child;
10
+ for (let i = 0; i < 30; i++) {
11
+ try {
12
+ await new Promise((res, rej) => {
13
+ http
14
+ .get("http://127.0.0.1:8188/object_info", (resp) => {
15
+ let data = "";
16
+ resp.on("data", (chunk) => {
17
+ data += chunk;
18
+ });
19
+ resp.on("end", () => {
20
+ // Modify the response data to add some checkpoints
21
+ const objectInfo = JSON.parse(data);
22
+ objectInfo.CheckpointLoaderSimple.input.required.ckpt_name[0] = ["model1.safetensors", "model2.ckpt"];
23
+ objectInfo.VAELoader.input.required.vae_name[0] = ["vae1.safetensors", "vae2.ckpt"];
24
+
25
+ data = JSON.stringify(objectInfo, undefined, "\t");
26
+
27
+ const outDir = resolve("./data");
28
+ if (!existsSync(outDir)) {
29
+ mkdirSync(outDir);
30
+ }
31
+
32
+ const outPath = resolve(outDir, "object_info.json");
33
+ console.log(`Writing ${Object.keys(objectInfo).length} nodes to ${outPath}`);
34
+ writeFileSync(outPath, data, {
35
+ encoding: "utf8",
36
+ });
37
+ res();
38
+ });
39
+ })
40
+ .on("error", rej);
41
+ });
42
+ success = true;
43
+ break;
44
+ } catch (error) {
45
+ console.log(i + "/30", error);
46
+ if (i === 0) {
47
+ // Start the server on first iteration if it fails to connect
48
+ console.log("Starting ComfyUI server...");
49
+
50
+ let python = resolve("../../python_embeded/python.exe");
51
+ let args;
52
+ let cwd;
53
+ if (existsSync(python)) {
54
+ args = ["-s", "ComfyUI/main.py"];
55
+ cwd = "../..";
56
+ } else {
57
+ python = "python";
58
+ args = ["main.py"];
59
+ cwd = "..";
60
+ }
61
+ args.push("--cpu");
62
+ console.log(python, ...args);
63
+ child = spawn(python, args, { cwd });
64
+ child.on("error", (err) => {
65
+ console.log(`Server error (${err})`);
66
+ i = 30;
67
+ });
68
+ child.on("exit", (code) => {
69
+ if (!success) {
70
+ console.log(`Server exited (${code})`);
71
+ i = 30;
72
+ }
73
+ });
74
+ }
75
+ await new Promise((r) => {
76
+ setTimeout(r, 1000);
77
+ });
78
+ }
79
+ }
80
+
81
+ child?.kill();
82
+
83
+ if (!success) {
84
+ throw new Error("Waiting for server failed...");
85
+ }
86
+ }
87
+
88
+ setup();
ComfyUI/tests-ui/tests/extensions.test.js ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+ /// <reference path="../node_modules/@types/jest/index.d.ts" />
3
+ const { start } = require("../utils");
4
+ const lg = require("../utils/litegraph");
5
+
6
+ describe("extensions", () => {
7
+ beforeEach(() => {
8
+ lg.setup(global);
9
+ });
10
+
11
+ afterEach(() => {
12
+ lg.teardown(global);
13
+ });
14
+
15
+ it("calls each extension hook", async () => {
16
+ const mockExtension = {
17
+ name: "TestExtension",
18
+ init: jest.fn(),
19
+ setup: jest.fn(),
20
+ addCustomNodeDefs: jest.fn(),
21
+ getCustomWidgets: jest.fn(),
22
+ beforeRegisterNodeDef: jest.fn(),
23
+ registerCustomNodes: jest.fn(),
24
+ loadedGraphNode: jest.fn(),
25
+ nodeCreated: jest.fn(),
26
+ beforeConfigureGraph: jest.fn(),
27
+ afterConfigureGraph: jest.fn(),
28
+ };
29
+
30
+ const { app, ez, graph } = await start({
31
+ async preSetup(app) {
32
+ app.registerExtension(mockExtension);
33
+ },
34
+ });
35
+
36
+ // Basic initialisation hooks should be called once, with app
37
+ expect(mockExtension.init).toHaveBeenCalledTimes(1);
38
+ expect(mockExtension.init).toHaveBeenCalledWith(app);
39
+
40
+ // Adding custom node defs should be passed the full list of nodes
41
+ expect(mockExtension.addCustomNodeDefs).toHaveBeenCalledTimes(1);
42
+ expect(mockExtension.addCustomNodeDefs.mock.calls[0][1]).toStrictEqual(app);
43
+ const defs = mockExtension.addCustomNodeDefs.mock.calls[0][0];
44
+ expect(defs).toHaveProperty("KSampler");
45
+ expect(defs).toHaveProperty("LoadImage");
46
+
47
+ // Get custom widgets is called once and should return new widget types
48
+ expect(mockExtension.getCustomWidgets).toHaveBeenCalledTimes(1);
49
+ expect(mockExtension.getCustomWidgets).toHaveBeenCalledWith(app);
50
+
51
+ // Before register node def will be called once per node type
52
+ const nodeNames = Object.keys(defs);
53
+ const nodeCount = nodeNames.length;
54
+ expect(mockExtension.beforeRegisterNodeDef).toHaveBeenCalledTimes(nodeCount);
55
+ for (let i = 0; i < 10; i++) {
56
+ // It should be send the JS class and the original JSON definition
57
+ const nodeClass = mockExtension.beforeRegisterNodeDef.mock.calls[i][0];
58
+ const nodeDef = mockExtension.beforeRegisterNodeDef.mock.calls[i][1];
59
+
60
+ expect(nodeClass.name).toBe("ComfyNode");
61
+ expect(nodeClass.comfyClass).toBe(nodeNames[i]);
62
+ expect(nodeDef.name).toBe(nodeNames[i]);
63
+ expect(nodeDef).toHaveProperty("input");
64
+ expect(nodeDef).toHaveProperty("output");
65
+ }
66
+
67
+ // Register custom nodes is called once after registerNode defs to allow adding other frontend nodes
68
+ expect(mockExtension.registerCustomNodes).toHaveBeenCalledTimes(1);
69
+
70
+ // Before configure graph will be called here as the default graph is being loaded
71
+ expect(mockExtension.beforeConfigureGraph).toHaveBeenCalledTimes(1);
72
+ // it gets sent the graph data that is going to be loaded
73
+ const graphData = mockExtension.beforeConfigureGraph.mock.calls[0][0];
74
+
75
+ // A node created is fired for each node constructor that is called
76
+ expect(mockExtension.nodeCreated).toHaveBeenCalledTimes(graphData.nodes.length);
77
+ for (let i = 0; i < graphData.nodes.length; i++) {
78
+ expect(mockExtension.nodeCreated.mock.calls[i][0].type).toBe(graphData.nodes[i].type);
79
+ }
80
+
81
+ // Each node then calls loadedGraphNode to allow them to be updated
82
+ expect(mockExtension.loadedGraphNode).toHaveBeenCalledTimes(graphData.nodes.length);
83
+ for (let i = 0; i < graphData.nodes.length; i++) {
84
+ expect(mockExtension.loadedGraphNode.mock.calls[i][0].type).toBe(graphData.nodes[i].type);
85
+ }
86
+
87
+ // After configure is then called once all the setup is done
88
+ expect(mockExtension.afterConfigureGraph).toHaveBeenCalledTimes(1);
89
+
90
+ expect(mockExtension.setup).toHaveBeenCalledTimes(1);
91
+ expect(mockExtension.setup).toHaveBeenCalledWith(app);
92
+
93
+ // Ensure hooks are called in the correct order
94
+ const callOrder = [
95
+ "init",
96
+ "addCustomNodeDefs",
97
+ "getCustomWidgets",
98
+ "beforeRegisterNodeDef",
99
+ "registerCustomNodes",
100
+ "beforeConfigureGraph",
101
+ "nodeCreated",
102
+ "loadedGraphNode",
103
+ "afterConfigureGraph",
104
+ "setup",
105
+ ];
106
+ for (let i = 1; i < callOrder.length; i++) {
107
+ const fn1 = mockExtension[callOrder[i - 1]];
108
+ const fn2 = mockExtension[callOrder[i]];
109
+ expect(fn1.mock.invocationCallOrder[0]).toBeLessThan(fn2.mock.invocationCallOrder[0]);
110
+ }
111
+
112
+ graph.clear();
113
+
114
+ // Ensure adding a new node calls the correct callback
115
+ ez.LoadImage();
116
+ expect(mockExtension.loadedGraphNode).toHaveBeenCalledTimes(graphData.nodes.length);
117
+ expect(mockExtension.nodeCreated).toHaveBeenCalledTimes(graphData.nodes.length + 1);
118
+ expect(mockExtension.nodeCreated.mock.lastCall[0].type).toBe("LoadImage");
119
+
120
+ // Reload the graph to ensure correct hooks are fired
121
+ await graph.reload();
122
+
123
+ // These hooks should not be fired again
124
+ expect(mockExtension.init).toHaveBeenCalledTimes(1);
125
+ expect(mockExtension.addCustomNodeDefs).toHaveBeenCalledTimes(1);
126
+ expect(mockExtension.getCustomWidgets).toHaveBeenCalledTimes(1);
127
+ expect(mockExtension.registerCustomNodes).toHaveBeenCalledTimes(1);
128
+ expect(mockExtension.beforeRegisterNodeDef).toHaveBeenCalledTimes(nodeCount);
129
+ expect(mockExtension.setup).toHaveBeenCalledTimes(1);
130
+
131
+ // These should be called again
132
+ expect(mockExtension.beforeConfigureGraph).toHaveBeenCalledTimes(2);
133
+ expect(mockExtension.nodeCreated).toHaveBeenCalledTimes(graphData.nodes.length + 2);
134
+ expect(mockExtension.loadedGraphNode).toHaveBeenCalledTimes(graphData.nodes.length + 1);
135
+ expect(mockExtension.afterConfigureGraph).toHaveBeenCalledTimes(2);
136
+ }, 15000);
137
+
138
+ it("allows custom nodeDefs and widgets to be registered", async () => {
139
+ const widgetMock = jest.fn((node, inputName, inputData, app) => {
140
+ expect(node.constructor.comfyClass).toBe("TestNode");
141
+ expect(inputName).toBe("test_input");
142
+ expect(inputData[0]).toBe("CUSTOMWIDGET");
143
+ expect(inputData[1]?.hello).toBe("world");
144
+ expect(app).toStrictEqual(app);
145
+
146
+ return {
147
+ widget: node.addWidget("button", inputName, "hello", () => {}),
148
+ };
149
+ });
150
+
151
+ // Register our extension that adds a custom node + widget type
152
+ const mockExtension = {
153
+ name: "TestExtension",
154
+ addCustomNodeDefs: (nodeDefs) => {
155
+ nodeDefs["TestNode"] = {
156
+ output: [],
157
+ output_name: [],
158
+ output_is_list: [],
159
+ name: "TestNode",
160
+ display_name: "TestNode",
161
+ category: "Test",
162
+ input: {
163
+ required: {
164
+ test_input: ["CUSTOMWIDGET", { hello: "world" }],
165
+ },
166
+ },
167
+ };
168
+ },
169
+ getCustomWidgets: jest.fn(() => {
170
+ return {
171
+ CUSTOMWIDGET: widgetMock,
172
+ };
173
+ }),
174
+ };
175
+
176
+ const { graph, ez } = await start({
177
+ async preSetup(app) {
178
+ app.registerExtension(mockExtension);
179
+ },
180
+ });
181
+
182
+ expect(mockExtension.getCustomWidgets).toBeCalledTimes(1);
183
+
184
+ graph.clear();
185
+ expect(widgetMock).toBeCalledTimes(0);
186
+ const node = ez.TestNode();
187
+ expect(widgetMock).toBeCalledTimes(1);
188
+
189
+ // Ensure our custom widget is created
190
+ expect(node.inputs.length).toBe(0);
191
+ expect(node.widgets.length).toBe(1);
192
+ const w = node.widgets[0].widget;
193
+ expect(w.name).toBe("test_input");
194
+ expect(w.type).toBe("button");
195
+ });
196
+ });
ComfyUI/tests-ui/tests/groupNode.test.js ADDED
@@ -0,0 +1,1005 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+ /// <reference path="../node_modules/@types/jest/index.d.ts" />
3
+
4
+ const { start, createDefaultWorkflow, getNodeDef, checkBeforeAndAfterReload } = require("../utils");
5
+ const lg = require("../utils/litegraph");
6
+
7
+ describe("group node", () => {
8
+ beforeEach(() => {
9
+ lg.setup(global);
10
+ });
11
+
12
+ afterEach(() => {
13
+ lg.teardown(global);
14
+ });
15
+
16
+ /**
17
+ *
18
+ * @param {*} app
19
+ * @param {*} graph
20
+ * @param {*} name
21
+ * @param {*} nodes
22
+ * @returns { Promise<InstanceType<import("../utils/ezgraph")["EzNode"]>> }
23
+ */
24
+ async function convertToGroup(app, graph, name, nodes) {
25
+ // Select the nodes we are converting
26
+ for (const n of nodes) {
27
+ n.select(true);
28
+ }
29
+
30
+ expect(Object.keys(app.canvas.selected_nodes).sort((a, b) => +a - +b)).toEqual(
31
+ nodes.map((n) => n.id + "").sort((a, b) => +a - +b)
32
+ );
33
+
34
+ global.prompt = jest.fn().mockImplementation(() => name);
35
+ const groupNode = await nodes[0].menu["Convert to Group Node"].call(false);
36
+
37
+ // Check group name was requested
38
+ expect(window.prompt).toHaveBeenCalled();
39
+
40
+ // Ensure old nodes are removed
41
+ for (const n of nodes) {
42
+ expect(n.isRemoved).toBeTruthy();
43
+ }
44
+
45
+ expect(groupNode.type).toEqual("workflow/" + name);
46
+
47
+ return graph.find(groupNode);
48
+ }
49
+
50
+ /**
51
+ * @param { Record<string, string | number> | number[] } idMap
52
+ * @param { Record<string, Record<string, unknown>> } valueMap
53
+ */
54
+ function getOutput(idMap = {}, valueMap = {}) {
55
+ if (idMap instanceof Array) {
56
+ idMap = idMap.reduce((p, n) => {
57
+ p[n] = n + "";
58
+ return p;
59
+ }, {});
60
+ }
61
+ const expected = {
62
+ 1: { inputs: { ckpt_name: "model1.safetensors", ...valueMap?.[1] }, class_type: "CheckpointLoaderSimple" },
63
+ 2: { inputs: { text: "positive", clip: ["1", 1], ...valueMap?.[2] }, class_type: "CLIPTextEncode" },
64
+ 3: { inputs: { text: "negative", clip: ["1", 1], ...valueMap?.[3] }, class_type: "CLIPTextEncode" },
65
+ 4: { inputs: { width: 512, height: 512, batch_size: 1, ...valueMap?.[4] }, class_type: "EmptyLatentImage" },
66
+ 5: {
67
+ inputs: {
68
+ seed: 0,
69
+ steps: 20,
70
+ cfg: 8,
71
+ sampler_name: "euler",
72
+ scheduler: "normal",
73
+ denoise: 1,
74
+ model: ["1", 0],
75
+ positive: ["2", 0],
76
+ negative: ["3", 0],
77
+ latent_image: ["4", 0],
78
+ ...valueMap?.[5],
79
+ },
80
+ class_type: "KSampler",
81
+ },
82
+ 6: { inputs: { samples: ["5", 0], vae: ["1", 2], ...valueMap?.[6] }, class_type: "VAEDecode" },
83
+ 7: { inputs: { filename_prefix: "ComfyUI", images: ["6", 0], ...valueMap?.[7] }, class_type: "SaveImage" },
84
+ };
85
+
86
+ // Map old IDs to new at the top level
87
+ const mapped = {};
88
+ for (const oldId in idMap) {
89
+ mapped[idMap[oldId]] = expected[oldId];
90
+ delete expected[oldId];
91
+ }
92
+ Object.assign(mapped, expected);
93
+
94
+ // Map old IDs to new inside links
95
+ for (const k in mapped) {
96
+ for (const input in mapped[k].inputs) {
97
+ const v = mapped[k].inputs[input];
98
+ if (v instanceof Array) {
99
+ if (v[0] in idMap) {
100
+ v[0] = idMap[v[0]] + "";
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ return mapped;
107
+ }
108
+
109
+ test("can be created from selected nodes", async () => {
110
+ const { ez, graph, app } = await start();
111
+ const nodes = createDefaultWorkflow(ez, graph);
112
+ const group = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg, nodes.empty]);
113
+
114
+ // Ensure links are now to the group node
115
+ expect(group.inputs).toHaveLength(2);
116
+ expect(group.outputs).toHaveLength(3);
117
+
118
+ expect(group.inputs.map((i) => i.input.name)).toEqual(["clip", "CLIPTextEncode clip"]);
119
+ expect(group.outputs.map((i) => i.output.name)).toEqual(["LATENT", "CONDITIONING", "CLIPTextEncode CONDITIONING"]);
120
+
121
+ // ckpt clip to both clip inputs on the group
122
+ expect(nodes.ckpt.outputs.CLIP.connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([
123
+ [group.id, 0],
124
+ [group.id, 1],
125
+ ]);
126
+
127
+ // group conditioning to sampler
128
+ expect(group.outputs["CONDITIONING"].connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([
129
+ [nodes.sampler.id, 1],
130
+ ]);
131
+ // group conditioning 2 to sampler
132
+ expect(
133
+ group.outputs["CLIPTextEncode CONDITIONING"].connections.map((t) => [t.targetNode.id, t.targetInput.index])
134
+ ).toEqual([[nodes.sampler.id, 2]]);
135
+ // group latent to sampler
136
+ expect(group.outputs["LATENT"].connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([
137
+ [nodes.sampler.id, 3],
138
+ ]);
139
+ });
140
+
141
+ test("maintains all output links on conversion", async () => {
142
+ const { ez, graph, app } = await start();
143
+ const nodes = createDefaultWorkflow(ez, graph);
144
+ const save2 = ez.SaveImage(...nodes.decode.outputs);
145
+ const save3 = ez.SaveImage(...nodes.decode.outputs);
146
+ // Ensure an output with multiple links maintains them on convert to group
147
+ const group = await convertToGroup(app, graph, "test", [nodes.sampler, nodes.decode]);
148
+ expect(group.outputs[0].connections.length).toBe(3);
149
+ expect(group.outputs[0].connections[0].targetNode.id).toBe(nodes.save.id);
150
+ expect(group.outputs[0].connections[1].targetNode.id).toBe(save2.id);
151
+ expect(group.outputs[0].connections[2].targetNode.id).toBe(save3.id);
152
+
153
+ // and they're still linked when converting back to nodes
154
+ const newNodes = group.menu["Convert to nodes"].call();
155
+ const decode = graph.find(newNodes.find((n) => n.type === "VAEDecode"));
156
+ expect(decode.outputs[0].connections.length).toBe(3);
157
+ expect(decode.outputs[0].connections[0].targetNode.id).toBe(nodes.save.id);
158
+ expect(decode.outputs[0].connections[1].targetNode.id).toBe(save2.id);
159
+ expect(decode.outputs[0].connections[2].targetNode.id).toBe(save3.id);
160
+ });
161
+ test("can be be converted back to nodes", async () => {
162
+ const { ez, graph, app } = await start();
163
+ const nodes = createDefaultWorkflow(ez, graph);
164
+ const toConvert = [nodes.pos, nodes.neg, nodes.empty, nodes.sampler];
165
+ const group = await convertToGroup(app, graph, "test", toConvert);
166
+
167
+ // Edit some values to ensure they are set back onto the converted nodes
168
+ expect(group.widgets["text"].value).toBe("positive");
169
+ group.widgets["text"].value = "pos";
170
+ expect(group.widgets["CLIPTextEncode text"].value).toBe("negative");
171
+ group.widgets["CLIPTextEncode text"].value = "neg";
172
+ expect(group.widgets["width"].value).toBe(512);
173
+ group.widgets["width"].value = 1024;
174
+ expect(group.widgets["sampler_name"].value).toBe("euler");
175
+ group.widgets["sampler_name"].value = "ddim";
176
+ expect(group.widgets["control_after_generate"].value).toBe("randomize");
177
+ group.widgets["control_after_generate"].value = "fixed";
178
+
179
+ /** @type { Array<any> } */
180
+ group.menu["Convert to nodes"].call();
181
+
182
+ // ensure widget values are set
183
+ const pos = graph.find(nodes.pos.id);
184
+ expect(pos.node.type).toBe("CLIPTextEncode");
185
+ expect(pos.widgets["text"].value).toBe("pos");
186
+ const neg = graph.find(nodes.neg.id);
187
+ expect(neg.node.type).toBe("CLIPTextEncode");
188
+ expect(neg.widgets["text"].value).toBe("neg");
189
+ const empty = graph.find(nodes.empty.id);
190
+ expect(empty.node.type).toBe("EmptyLatentImage");
191
+ expect(empty.widgets["width"].value).toBe(1024);
192
+ const sampler = graph.find(nodes.sampler.id);
193
+ expect(sampler.node.type).toBe("KSampler");
194
+ expect(sampler.widgets["sampler_name"].value).toBe("ddim");
195
+ expect(sampler.widgets["control_after_generate"].value).toBe("fixed");
196
+
197
+ // validate links
198
+ expect(nodes.ckpt.outputs.CLIP.connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([
199
+ [pos.id, 0],
200
+ [neg.id, 0],
201
+ ]);
202
+
203
+ expect(pos.outputs["CONDITIONING"].connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([
204
+ [nodes.sampler.id, 1],
205
+ ]);
206
+
207
+ expect(neg.outputs["CONDITIONING"].connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([
208
+ [nodes.sampler.id, 2],
209
+ ]);
210
+
211
+ expect(empty.outputs["LATENT"].connections.map((t) => [t.targetNode.id, t.targetInput.index])).toEqual([
212
+ [nodes.sampler.id, 3],
213
+ ]);
214
+ });
215
+ test("it can embed reroutes as inputs", async () => {
216
+ const { ez, graph, app } = await start();
217
+ const nodes = createDefaultWorkflow(ez, graph);
218
+
219
+ // Add and connect a reroute to the clip text encodes
220
+ const reroute = ez.Reroute();
221
+ nodes.ckpt.outputs.CLIP.connectTo(reroute.inputs[0]);
222
+ reroute.outputs[0].connectTo(nodes.pos.inputs[0]);
223
+ reroute.outputs[0].connectTo(nodes.neg.inputs[0]);
224
+
225
+ // Convert to group and ensure we only have 1 input of the correct type
226
+ const group = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg, nodes.empty, reroute]);
227
+ expect(group.inputs).toHaveLength(1);
228
+ expect(group.inputs[0].input.type).toEqual("CLIP");
229
+
230
+ expect((await graph.toPrompt()).output).toEqual(getOutput());
231
+ });
232
+ test("it can embed reroutes as outputs", async () => {
233
+ const { ez, graph, app } = await start();
234
+ const nodes = createDefaultWorkflow(ez, graph);
235
+
236
+ // Add a reroute with no output so we output IMAGE even though its used internally
237
+ const reroute = ez.Reroute();
238
+ nodes.decode.outputs.IMAGE.connectTo(reroute.inputs[0]);
239
+
240
+ // Convert to group and ensure there is an IMAGE output
241
+ const group = await convertToGroup(app, graph, "test", [nodes.decode, nodes.save, reroute]);
242
+ expect(group.outputs).toHaveLength(1);
243
+ expect(group.outputs[0].output.type).toEqual("IMAGE");
244
+ expect((await graph.toPrompt()).output).toEqual(getOutput([nodes.decode.id, nodes.save.id]));
245
+ });
246
+ test("it can embed reroutes as pipes", async () => {
247
+ const { ez, graph, app } = await start();
248
+ const nodes = createDefaultWorkflow(ez, graph);
249
+
250
+ // Use reroutes as a pipe
251
+ const rerouteModel = ez.Reroute();
252
+ const rerouteClip = ez.Reroute();
253
+ const rerouteVae = ez.Reroute();
254
+ nodes.ckpt.outputs.MODEL.connectTo(rerouteModel.inputs[0]);
255
+ nodes.ckpt.outputs.CLIP.connectTo(rerouteClip.inputs[0]);
256
+ nodes.ckpt.outputs.VAE.connectTo(rerouteVae.inputs[0]);
257
+
258
+ const group = await convertToGroup(app, graph, "test", [rerouteModel, rerouteClip, rerouteVae]);
259
+
260
+ expect(group.outputs).toHaveLength(3);
261
+ expect(group.outputs.map((o) => o.output.type)).toEqual(["MODEL", "CLIP", "VAE"]);
262
+
263
+ expect(group.outputs).toHaveLength(3);
264
+ expect(group.outputs.map((o) => o.output.type)).toEqual(["MODEL", "CLIP", "VAE"]);
265
+
266
+ group.outputs[0].connectTo(nodes.sampler.inputs.model);
267
+ group.outputs[1].connectTo(nodes.pos.inputs.clip);
268
+ group.outputs[1].connectTo(nodes.neg.inputs.clip);
269
+ });
270
+ test("can handle reroutes used internally", async () => {
271
+ const { ez, graph, app } = await start();
272
+ const nodes = createDefaultWorkflow(ez, graph);
273
+
274
+ let reroutes = [];
275
+ let prevNode = nodes.ckpt;
276
+ for (let i = 0; i < 5; i++) {
277
+ const reroute = ez.Reroute();
278
+ prevNode.outputs[0].connectTo(reroute.inputs[0]);
279
+ prevNode = reroute;
280
+ reroutes.push(reroute);
281
+ }
282
+ prevNode.outputs[0].connectTo(nodes.sampler.inputs.model);
283
+
284
+ const group = await convertToGroup(app, graph, "test", [...reroutes, ...Object.values(nodes)]);
285
+ expect((await graph.toPrompt()).output).toEqual(getOutput());
286
+
287
+ group.menu["Convert to nodes"].call();
288
+ expect((await graph.toPrompt()).output).toEqual(getOutput());
289
+ });
290
+ test("creates with widget values from inner nodes", async () => {
291
+ const { ez, graph, app } = await start();
292
+ const nodes = createDefaultWorkflow(ez, graph);
293
+
294
+ nodes.ckpt.widgets.ckpt_name.value = "model2.ckpt";
295
+ nodes.pos.widgets.text.value = "hello";
296
+ nodes.neg.widgets.text.value = "world";
297
+ nodes.empty.widgets.width.value = 256;
298
+ nodes.empty.widgets.height.value = 1024;
299
+ nodes.sampler.widgets.seed.value = 1;
300
+ nodes.sampler.widgets.control_after_generate.value = "increment";
301
+ nodes.sampler.widgets.steps.value = 8;
302
+ nodes.sampler.widgets.cfg.value = 4.5;
303
+ nodes.sampler.widgets.sampler_name.value = "uni_pc";
304
+ nodes.sampler.widgets.scheduler.value = "karras";
305
+ nodes.sampler.widgets.denoise.value = 0.9;
306
+
307
+ const group = await convertToGroup(app, graph, "test", [
308
+ nodes.ckpt,
309
+ nodes.pos,
310
+ nodes.neg,
311
+ nodes.empty,
312
+ nodes.sampler,
313
+ ]);
314
+
315
+ expect(group.widgets["ckpt_name"].value).toEqual("model2.ckpt");
316
+ expect(group.widgets["text"].value).toEqual("hello");
317
+ expect(group.widgets["CLIPTextEncode text"].value).toEqual("world");
318
+ expect(group.widgets["width"].value).toEqual(256);
319
+ expect(group.widgets["height"].value).toEqual(1024);
320
+ expect(group.widgets["seed"].value).toEqual(1);
321
+ expect(group.widgets["control_after_generate"].value).toEqual("increment");
322
+ expect(group.widgets["steps"].value).toEqual(8);
323
+ expect(group.widgets["cfg"].value).toEqual(4.5);
324
+ expect(group.widgets["sampler_name"].value).toEqual("uni_pc");
325
+ expect(group.widgets["scheduler"].value).toEqual("karras");
326
+ expect(group.widgets["denoise"].value).toEqual(0.9);
327
+
328
+ expect((await graph.toPrompt()).output).toEqual(
329
+ getOutput([nodes.ckpt.id, nodes.pos.id, nodes.neg.id, nodes.empty.id, nodes.sampler.id], {
330
+ [nodes.ckpt.id]: { ckpt_name: "model2.ckpt" },
331
+ [nodes.pos.id]: { text: "hello" },
332
+ [nodes.neg.id]: { text: "world" },
333
+ [nodes.empty.id]: { width: 256, height: 1024 },
334
+ [nodes.sampler.id]: {
335
+ seed: 1,
336
+ steps: 8,
337
+ cfg: 4.5,
338
+ sampler_name: "uni_pc",
339
+ scheduler: "karras",
340
+ denoise: 0.9,
341
+ },
342
+ })
343
+ );
344
+ });
345
+ test("group inputs can be reroutes", async () => {
346
+ const { ez, graph, app } = await start();
347
+ const nodes = createDefaultWorkflow(ez, graph);
348
+ const group = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg]);
349
+
350
+ const reroute = ez.Reroute();
351
+ nodes.ckpt.outputs.CLIP.connectTo(reroute.inputs[0]);
352
+
353
+ reroute.outputs[0].connectTo(group.inputs[0]);
354
+ reroute.outputs[0].connectTo(group.inputs[1]);
355
+
356
+ expect((await graph.toPrompt()).output).toEqual(getOutput([nodes.pos.id, nodes.neg.id]));
357
+ });
358
+ test("group outputs can be reroutes", async () => {
359
+ const { ez, graph, app } = await start();
360
+ const nodes = createDefaultWorkflow(ez, graph);
361
+ const group = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg]);
362
+
363
+ const reroute1 = ez.Reroute();
364
+ const reroute2 = ez.Reroute();
365
+ group.outputs[0].connectTo(reroute1.inputs[0]);
366
+ group.outputs[1].connectTo(reroute2.inputs[0]);
367
+
368
+ reroute1.outputs[0].connectTo(nodes.sampler.inputs.positive);
369
+ reroute2.outputs[0].connectTo(nodes.sampler.inputs.negative);
370
+
371
+ expect((await graph.toPrompt()).output).toEqual(getOutput([nodes.pos.id, nodes.neg.id]));
372
+ });
373
+ test("groups can connect to each other", async () => {
374
+ const { ez, graph, app } = await start();
375
+ const nodes = createDefaultWorkflow(ez, graph);
376
+ const group1 = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg]);
377
+ const group2 = await convertToGroup(app, graph, "test2", [nodes.empty, nodes.sampler]);
378
+
379
+ group1.outputs[0].connectTo(group2.inputs["positive"]);
380
+ group1.outputs[1].connectTo(group2.inputs["negative"]);
381
+
382
+ expect((await graph.toPrompt()).output).toEqual(
383
+ getOutput([nodes.pos.id, nodes.neg.id, nodes.empty.id, nodes.sampler.id])
384
+ );
385
+ });
386
+ test("groups can connect to each other via internal reroutes", async () => {
387
+ const { ez, graph, app } = await start();
388
+
389
+ const latent = ez.EmptyLatentImage();
390
+ const vae = ez.VAELoader();
391
+ const latentReroute = ez.Reroute();
392
+ const vaeReroute = ez.Reroute();
393
+
394
+ latent.outputs[0].connectTo(latentReroute.inputs[0]);
395
+ vae.outputs[0].connectTo(vaeReroute.inputs[0]);
396
+
397
+ const group1 = await convertToGroup(app, graph, "test", [latentReroute, vaeReroute]);
398
+ group1.menu.Clone.call();
399
+ expect(app.graph._nodes).toHaveLength(4);
400
+ const group2 = graph.find(app.graph._nodes[3]);
401
+ expect(group2.node.type).toEqual("workflow/test");
402
+ expect(group2.id).not.toEqual(group1.id);
403
+
404
+ group1.outputs.VAE.connectTo(group2.inputs.VAE);
405
+ group1.outputs.LATENT.connectTo(group2.inputs.LATENT);
406
+
407
+ const decode = ez.VAEDecode(group2.outputs.LATENT, group2.outputs.VAE);
408
+ const preview = ez.PreviewImage(decode.outputs[0]);
409
+
410
+ const output = {
411
+ [latent.id]: { inputs: { width: 512, height: 512, batch_size: 1 }, class_type: "EmptyLatentImage" },
412
+ [vae.id]: { inputs: { vae_name: "vae1.safetensors" }, class_type: "VAELoader" },
413
+ [decode.id]: { inputs: { samples: [latent.id + "", 0], vae: [vae.id + "", 0] }, class_type: "VAEDecode" },
414
+ [preview.id]: { inputs: { images: [decode.id + "", 0] }, class_type: "PreviewImage" },
415
+ };
416
+ expect((await graph.toPrompt()).output).toEqual(output);
417
+
418
+ // Ensure missing connections dont cause errors
419
+ group2.inputs.VAE.disconnect();
420
+ delete output[decode.id].inputs.vae;
421
+ expect((await graph.toPrompt()).output).toEqual(output);
422
+ });
423
+ test("displays generated image on group node", async () => {
424
+ const { ez, graph, app } = await start();
425
+ const nodes = createDefaultWorkflow(ez, graph);
426
+ let group = await convertToGroup(app, graph, "test", [
427
+ nodes.pos,
428
+ nodes.neg,
429
+ nodes.empty,
430
+ nodes.sampler,
431
+ nodes.decode,
432
+ nodes.save,
433
+ ]);
434
+
435
+ const { api } = require("../../web/scripts/api");
436
+
437
+ api.dispatchEvent(new CustomEvent("execution_start", {}));
438
+ api.dispatchEvent(new CustomEvent("executing", { detail: `${nodes.save.id}` }));
439
+ // Event should be forwarded to group node id
440
+ expect(+app.runningNodeId).toEqual(group.id);
441
+ expect(group.node["imgs"]).toBeFalsy();
442
+ api.dispatchEvent(
443
+ new CustomEvent("executed", {
444
+ detail: {
445
+ node: `${nodes.save.id}`,
446
+ output: {
447
+ images: [
448
+ {
449
+ filename: "test.png",
450
+ type: "output",
451
+ },
452
+ ],
453
+ },
454
+ },
455
+ })
456
+ );
457
+
458
+ // Trigger paint
459
+ group.node.onDrawBackground?.(app.canvas.ctx, app.canvas.canvas);
460
+
461
+ expect(group.node["images"]).toEqual([
462
+ {
463
+ filename: "test.png",
464
+ type: "output",
465
+ },
466
+ ]);
467
+
468
+ // Reload
469
+ const workflow = JSON.stringify((await graph.toPrompt()).workflow);
470
+ await app.loadGraphData(JSON.parse(workflow));
471
+ group = graph.find(group);
472
+
473
+ // Trigger inner nodes to get created
474
+ group.node["getInnerNodes"]();
475
+
476
+ // Check it works for internal node ids
477
+ api.dispatchEvent(new CustomEvent("execution_start", {}));
478
+ api.dispatchEvent(new CustomEvent("executing", { detail: `${group.id}:5` }));
479
+ // Event should be forwarded to group node id
480
+ expect(+app.runningNodeId).toEqual(group.id);
481
+ expect(group.node["imgs"]).toBeFalsy();
482
+ api.dispatchEvent(
483
+ new CustomEvent("executed", {
484
+ detail: {
485
+ node: `${group.id}:5`,
486
+ output: {
487
+ images: [
488
+ {
489
+ filename: "test2.png",
490
+ type: "output",
491
+ },
492
+ ],
493
+ },
494
+ },
495
+ })
496
+ );
497
+
498
+ // Trigger paint
499
+ group.node.onDrawBackground?.(app.canvas.ctx, app.canvas.canvas);
500
+
501
+ expect(group.node["images"]).toEqual([
502
+ {
503
+ filename: "test2.png",
504
+ type: "output",
505
+ },
506
+ ]);
507
+ });
508
+ test("allows widgets to be converted to inputs", async () => {
509
+ const { ez, graph, app } = await start();
510
+ const nodes = createDefaultWorkflow(ez, graph);
511
+ const group = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg]);
512
+ group.widgets[0].convertToInput();
513
+
514
+ const primitive = ez.PrimitiveNode();
515
+ primitive.outputs[0].connectTo(group.inputs["text"]);
516
+ primitive.widgets[0].value = "hello";
517
+
518
+ expect((await graph.toPrompt()).output).toEqual(
519
+ getOutput([nodes.pos.id, nodes.neg.id], {
520
+ [nodes.pos.id]: { text: "hello" },
521
+ })
522
+ );
523
+ });
524
+ test("can be copied", async () => {
525
+ const { ez, graph, app } = await start();
526
+ const nodes = createDefaultWorkflow(ez, graph);
527
+
528
+ const group1 = await convertToGroup(app, graph, "test", [
529
+ nodes.pos,
530
+ nodes.neg,
531
+ nodes.empty,
532
+ nodes.sampler,
533
+ nodes.decode,
534
+ nodes.save,
535
+ ]);
536
+
537
+ group1.widgets["text"].value = "hello";
538
+ group1.widgets["width"].value = 256;
539
+ group1.widgets["seed"].value = 1;
540
+
541
+ // Clone the node
542
+ group1.menu.Clone.call();
543
+ expect(app.graph._nodes).toHaveLength(3);
544
+ const group2 = graph.find(app.graph._nodes[2]);
545
+ expect(group2.node.type).toEqual("workflow/test");
546
+ expect(group2.id).not.toEqual(group1.id);
547
+
548
+ // Reconnect ckpt
549
+ nodes.ckpt.outputs.MODEL.connectTo(group2.inputs["model"]);
550
+ nodes.ckpt.outputs.CLIP.connectTo(group2.inputs["clip"]);
551
+ nodes.ckpt.outputs.CLIP.connectTo(group2.inputs["CLIPTextEncode clip"]);
552
+ nodes.ckpt.outputs.VAE.connectTo(group2.inputs["vae"]);
553
+
554
+ group2.widgets["text"].value = "world";
555
+ group2.widgets["width"].value = 1024;
556
+ group2.widgets["seed"].value = 100;
557
+
558
+ let i = 0;
559
+ expect((await graph.toPrompt()).output).toEqual({
560
+ ...getOutput([nodes.empty.id, nodes.pos.id, nodes.neg.id, nodes.sampler.id, nodes.decode.id, nodes.save.id], {
561
+ [nodes.empty.id]: { width: 256 },
562
+ [nodes.pos.id]: { text: "hello" },
563
+ [nodes.sampler.id]: { seed: 1 },
564
+ }),
565
+ ...getOutput(
566
+ {
567
+ [nodes.empty.id]: `${group2.id}:${i++}`,
568
+ [nodes.pos.id]: `${group2.id}:${i++}`,
569
+ [nodes.neg.id]: `${group2.id}:${i++}`,
570
+ [nodes.sampler.id]: `${group2.id}:${i++}`,
571
+ [nodes.decode.id]: `${group2.id}:${i++}`,
572
+ [nodes.save.id]: `${group2.id}:${i++}`,
573
+ },
574
+ {
575
+ [nodes.empty.id]: { width: 1024 },
576
+ [nodes.pos.id]: { text: "world" },
577
+ [nodes.sampler.id]: { seed: 100 },
578
+ }
579
+ ),
580
+ });
581
+
582
+ graph.arrange();
583
+ });
584
+ test("is embedded in workflow", async () => {
585
+ let { ez, graph, app } = await start();
586
+ const nodes = createDefaultWorkflow(ez, graph);
587
+ let group = await convertToGroup(app, graph, "test", [nodes.pos, nodes.neg]);
588
+ const workflow = JSON.stringify((await graph.toPrompt()).workflow);
589
+
590
+ // Clear the environment
591
+ ({ ez, graph, app } = await start({
592
+ resetEnv: true,
593
+ }));
594
+ // Ensure the node isnt registered
595
+ expect(() => ez["workflow/test"]).toThrow();
596
+
597
+ // Reload the workflow
598
+ await app.loadGraphData(JSON.parse(workflow));
599
+
600
+ // Ensure the node is found
601
+ group = graph.find(group);
602
+
603
+ // Generate prompt and ensure it is as expected
604
+ expect((await graph.toPrompt()).output).toEqual(
605
+ getOutput({
606
+ [nodes.pos.id]: `${group.id}:0`,
607
+ [nodes.neg.id]: `${group.id}:1`,
608
+ })
609
+ );
610
+ });
611
+ test("shows missing node error on missing internal node when loading graph data", async () => {
612
+ const { graph } = await start();
613
+
614
+ const dialogShow = jest.spyOn(graph.app.ui.dialog, "show");
615
+ await graph.app.loadGraphData({
616
+ last_node_id: 3,
617
+ last_link_id: 1,
618
+ nodes: [
619
+ {
620
+ id: 3,
621
+ type: "workflow/testerror",
622
+ },
623
+ ],
624
+ links: [],
625
+ groups: [],
626
+ config: {},
627
+ extra: {
628
+ groupNodes: {
629
+ testerror: {
630
+ nodes: [
631
+ {
632
+ type: "NotKSampler",
633
+ },
634
+ {
635
+ type: "NotVAEDecode",
636
+ },
637
+ ],
638
+ },
639
+ },
640
+ },
641
+ });
642
+
643
+ expect(dialogShow).toBeCalledTimes(1);
644
+ const call = dialogShow.mock.calls[0][0].innerHTML;
645
+ expect(call).toContain("the following node types were not found");
646
+ expect(call).toContain("NotKSampler");
647
+ expect(call).toContain("NotVAEDecode");
648
+ expect(call).toContain("workflow/testerror");
649
+ });
650
+ test("maintains widget inputs on conversion back to nodes", async () => {
651
+ const { ez, graph, app } = await start();
652
+ let pos = ez.CLIPTextEncode({ text: "positive" });
653
+ pos.node.title = "Positive";
654
+ let neg = ez.CLIPTextEncode({ text: "negative" });
655
+ neg.node.title = "Negative";
656
+ pos.widgets.text.convertToInput();
657
+ neg.widgets.text.convertToInput();
658
+
659
+ let primitive = ez.PrimitiveNode();
660
+ primitive.outputs[0].connectTo(pos.inputs.text);
661
+ primitive.outputs[0].connectTo(neg.inputs.text);
662
+
663
+ const group = await convertToGroup(app, graph, "test", [pos, neg, primitive]);
664
+ // This will use a primitive widget named 'value'
665
+ expect(group.widgets.length).toBe(1);
666
+ expect(group.widgets["value"].value).toBe("positive");
667
+
668
+ const newNodes = group.menu["Convert to nodes"].call();
669
+ pos = graph.find(newNodes.find((n) => n.title === "Positive"));
670
+ neg = graph.find(newNodes.find((n) => n.title === "Negative"));
671
+ primitive = graph.find(newNodes.find((n) => n.type === "PrimitiveNode"));
672
+
673
+ expect(pos.inputs).toHaveLength(2);
674
+ expect(neg.inputs).toHaveLength(2);
675
+ expect(primitive.outputs[0].connections).toHaveLength(2);
676
+
677
+ expect((await graph.toPrompt()).output).toEqual({
678
+ 1: { inputs: { text: "positive" }, class_type: "CLIPTextEncode" },
679
+ 2: { inputs: { text: "positive" }, class_type: "CLIPTextEncode" },
680
+ });
681
+ });
682
+ test("correctly handles widget inputs", async () => {
683
+ const { ez, graph, app } = await start();
684
+ const upscaleMethods = (await getNodeDef("ImageScaleBy")).input.required["upscale_method"][0];
685
+
686
+ const image = ez.LoadImage();
687
+ const scale1 = ez.ImageScaleBy(image.outputs[0]);
688
+ const scale2 = ez.ImageScaleBy(image.outputs[0]);
689
+ const preview1 = ez.PreviewImage(scale1.outputs[0]);
690
+ const preview2 = ez.PreviewImage(scale2.outputs[0]);
691
+ scale1.widgets.upscale_method.value = upscaleMethods[1];
692
+ scale1.widgets.upscale_method.convertToInput();
693
+
694
+ const group = await convertToGroup(app, graph, "test", [scale1, scale2]);
695
+ expect(group.inputs.length).toBe(3);
696
+ expect(group.inputs[0].input.type).toBe("IMAGE");
697
+ expect(group.inputs[1].input.type).toBe("IMAGE");
698
+ expect(group.inputs[2].input.type).toBe("COMBO");
699
+
700
+ // Ensure links are maintained
701
+ expect(group.inputs[0].connection?.originNode?.id).toBe(image.id);
702
+ expect(group.inputs[1].connection?.originNode?.id).toBe(image.id);
703
+ expect(group.inputs[2].connection).toBeFalsy();
704
+
705
+ // Ensure primitive gets correct type
706
+ const primitive = ez.PrimitiveNode();
707
+ primitive.outputs[0].connectTo(group.inputs[2]);
708
+ expect(primitive.widgets.value.widget.options.values).toBe(upscaleMethods);
709
+ expect(primitive.widgets.value.value).toBe(upscaleMethods[1]); // Ensure value is copied
710
+ primitive.widgets.value.value = upscaleMethods[1];
711
+
712
+ await checkBeforeAndAfterReload(graph, async (r) => {
713
+ const scale1id = r ? `${group.id}:0` : scale1.id;
714
+ const scale2id = r ? `${group.id}:1` : scale2.id;
715
+ // Ensure widget value is applied to prompt
716
+ expect((await graph.toPrompt()).output).toStrictEqual({
717
+ [image.id]: { inputs: { image: "example.png", upload: "image" }, class_type: "LoadImage" },
718
+ [scale1id]: {
719
+ inputs: { upscale_method: upscaleMethods[1], scale_by: 1, image: [`${image.id}`, 0] },
720
+ class_type: "ImageScaleBy",
721
+ },
722
+ [scale2id]: {
723
+ inputs: { upscale_method: "nearest-exact", scale_by: 1, image: [`${image.id}`, 0] },
724
+ class_type: "ImageScaleBy",
725
+ },
726
+ [preview1.id]: { inputs: { images: [`${scale1id}`, 0] }, class_type: "PreviewImage" },
727
+ [preview2.id]: { inputs: { images: [`${scale2id}`, 0] }, class_type: "PreviewImage" },
728
+ });
729
+ });
730
+ });
731
+ test("adds widgets in node execution order", async () => {
732
+ const { ez, graph, app } = await start();
733
+ const scale = ez.LatentUpscale();
734
+ const save = ez.SaveImage();
735
+ const empty = ez.EmptyLatentImage();
736
+ const decode = ez.VAEDecode();
737
+
738
+ scale.outputs.LATENT.connectTo(decode.inputs.samples);
739
+ decode.outputs.IMAGE.connectTo(save.inputs.images);
740
+ empty.outputs.LATENT.connectTo(scale.inputs.samples);
741
+
742
+ const group = await convertToGroup(app, graph, "test", [scale, save, empty, decode]);
743
+ const widgets = group.widgets.map((w) => w.widget.name);
744
+ expect(widgets).toStrictEqual([
745
+ "width",
746
+ "height",
747
+ "batch_size",
748
+ "upscale_method",
749
+ "LatentUpscale width",
750
+ "LatentUpscale height",
751
+ "crop",
752
+ "filename_prefix",
753
+ ]);
754
+ });
755
+ test("adds output for external links when converting to group", async () => {
756
+ const { ez, graph, app } = await start();
757
+ const img = ez.EmptyLatentImage();
758
+ let decode = ez.VAEDecode(...img.outputs);
759
+ const preview1 = ez.PreviewImage(...decode.outputs);
760
+ const preview2 = ez.PreviewImage(...decode.outputs);
761
+
762
+ const group = await convertToGroup(app, graph, "test", [img, decode, preview1]);
763
+
764
+ // Ensure we have an output connected to the 2nd preview node
765
+ expect(group.outputs.length).toBe(1);
766
+ expect(group.outputs[0].connections.length).toBe(1);
767
+ expect(group.outputs[0].connections[0].targetNode.id).toBe(preview2.id);
768
+
769
+ // Convert back and ensure bothe previews are still connected
770
+ group.menu["Convert to nodes"].call();
771
+ decode = graph.find(decode);
772
+ expect(decode.outputs[0].connections.length).toBe(2);
773
+ expect(decode.outputs[0].connections[0].targetNode.id).toBe(preview1.id);
774
+ expect(decode.outputs[0].connections[1].targetNode.id).toBe(preview2.id);
775
+ });
776
+ test("adds output for external links when converting to group when nodes are not in execution order", async () => {
777
+ const { ez, graph, app } = await start();
778
+ const sampler = ez.KSampler();
779
+ const ckpt = ez.CheckpointLoaderSimple();
780
+ const empty = ez.EmptyLatentImage();
781
+ const pos = ez.CLIPTextEncode(ckpt.outputs.CLIP, { text: "positive" });
782
+ const neg = ez.CLIPTextEncode(ckpt.outputs.CLIP, { text: "negative" });
783
+ const decode1 = ez.VAEDecode(sampler.outputs.LATENT, ckpt.outputs.VAE);
784
+ const save = ez.SaveImage(decode1.outputs.IMAGE);
785
+ ckpt.outputs.MODEL.connectTo(sampler.inputs.model);
786
+ pos.outputs.CONDITIONING.connectTo(sampler.inputs.positive);
787
+ neg.outputs.CONDITIONING.connectTo(sampler.inputs.negative);
788
+ empty.outputs.LATENT.connectTo(sampler.inputs.latent_image);
789
+
790
+ const encode = ez.VAEEncode(decode1.outputs.IMAGE);
791
+ const vae = ez.VAELoader();
792
+ const decode2 = ez.VAEDecode(encode.outputs.LATENT, vae.outputs.VAE);
793
+ const preview = ez.PreviewImage(decode2.outputs.IMAGE);
794
+ vae.outputs.VAE.connectTo(encode.inputs.vae);
795
+
796
+ const group = await convertToGroup(app, graph, "test", [vae, decode1, encode, sampler]);
797
+
798
+ expect(group.outputs.length).toBe(3);
799
+ expect(group.outputs[0].output.name).toBe("VAE");
800
+ expect(group.outputs[0].output.type).toBe("VAE");
801
+ expect(group.outputs[1].output.name).toBe("IMAGE");
802
+ expect(group.outputs[1].output.type).toBe("IMAGE");
803
+ expect(group.outputs[2].output.name).toBe("LATENT");
804
+ expect(group.outputs[2].output.type).toBe("LATENT");
805
+
806
+ expect(group.outputs[0].connections.length).toBe(1);
807
+ expect(group.outputs[0].connections[0].targetNode.id).toBe(decode2.id);
808
+ expect(group.outputs[0].connections[0].targetInput.index).toBe(1);
809
+
810
+ expect(group.outputs[1].connections.length).toBe(1);
811
+ expect(group.outputs[1].connections[0].targetNode.id).toBe(save.id);
812
+ expect(group.outputs[1].connections[0].targetInput.index).toBe(0);
813
+
814
+ expect(group.outputs[2].connections.length).toBe(1);
815
+ expect(group.outputs[2].connections[0].targetNode.id).toBe(decode2.id);
816
+ expect(group.outputs[2].connections[0].targetInput.index).toBe(0);
817
+
818
+ expect((await graph.toPrompt()).output).toEqual({
819
+ ...getOutput({ 1: ckpt.id, 2: pos.id, 3: neg.id, 4: empty.id, 5: sampler.id, 6: decode1.id, 7: save.id }),
820
+ [vae.id]: { inputs: { vae_name: "vae1.safetensors" }, class_type: vae.node.type },
821
+ [encode.id]: { inputs: { pixels: ["6", 0], vae: [vae.id + "", 0] }, class_type: encode.node.type },
822
+ [decode2.id]: { inputs: { samples: [encode.id + "", 0], vae: [vae.id + "", 0] }, class_type: decode2.node.type },
823
+ [preview.id]: { inputs: { images: [decode2.id + "", 0] }, class_type: preview.node.type },
824
+ });
825
+ });
826
+ test("works with IMAGEUPLOAD widget", async () => {
827
+ const { ez, graph, app } = await start();
828
+ const img = ez.LoadImage();
829
+ const preview1 = ez.PreviewImage(img.outputs[0]);
830
+
831
+ const group = await convertToGroup(app, graph, "test", [img, preview1]);
832
+ const widget = group.widgets["upload"];
833
+ expect(widget).toBeTruthy();
834
+ expect(widget.widget.type).toBe("button");
835
+ });
836
+ test("internal primitive populates widgets for all linked inputs", async () => {
837
+ const { ez, graph, app } = await start();
838
+ const img = ez.LoadImage();
839
+ const scale1 = ez.ImageScale(img.outputs[0]);
840
+ const scale2 = ez.ImageScale(img.outputs[0]);
841
+ ez.PreviewImage(scale1.outputs[0]);
842
+ ez.PreviewImage(scale2.outputs[0]);
843
+
844
+ scale1.widgets.width.convertToInput();
845
+ scale2.widgets.height.convertToInput();
846
+
847
+ const primitive = ez.PrimitiveNode();
848
+ primitive.outputs[0].connectTo(scale1.inputs.width);
849
+ primitive.outputs[0].connectTo(scale2.inputs.height);
850
+
851
+ const group = await convertToGroup(app, graph, "test", [img, primitive, scale1, scale2]);
852
+ group.widgets.value.value = 100;
853
+ expect((await graph.toPrompt()).output).toEqual({
854
+ 1: {
855
+ inputs: { image: img.widgets.image.value, upload: "image" },
856
+ class_type: "LoadImage",
857
+ },
858
+ 2: {
859
+ inputs: { upscale_method: "nearest-exact", width: 100, height: 512, crop: "disabled", image: ["1", 0] },
860
+ class_type: "ImageScale",
861
+ },
862
+ 3: {
863
+ inputs: { upscale_method: "nearest-exact", width: 512, height: 100, crop: "disabled", image: ["1", 0] },
864
+ class_type: "ImageScale",
865
+ },
866
+ 4: { inputs: { images: ["2", 0] }, class_type: "PreviewImage" },
867
+ 5: { inputs: { images: ["3", 0] }, class_type: "PreviewImage" },
868
+ });
869
+ });
870
+ test("primitive control widgets values are copied on convert", async () => {
871
+ const { ez, graph, app } = await start();
872
+ const sampler = ez.KSampler();
873
+ sampler.widgets.seed.convertToInput();
874
+ sampler.widgets.sampler_name.convertToInput();
875
+
876
+ let p1 = ez.PrimitiveNode();
877
+ let p2 = ez.PrimitiveNode();
878
+ p1.outputs[0].connectTo(sampler.inputs.seed);
879
+ p2.outputs[0].connectTo(sampler.inputs.sampler_name);
880
+
881
+ p1.widgets.control_after_generate.value = "increment";
882
+ p2.widgets.control_after_generate.value = "decrement";
883
+ p2.widgets.control_filter_list.value = "/.*/";
884
+
885
+ p2.node.title = "p2";
886
+
887
+ const group = await convertToGroup(app, graph, "test", [sampler, p1, p2]);
888
+ expect(group.widgets.control_after_generate.value).toBe("increment");
889
+ expect(group.widgets["p2 control_after_generate"].value).toBe("decrement");
890
+ expect(group.widgets["p2 control_filter_list"].value).toBe("/.*/");
891
+
892
+ group.widgets.control_after_generate.value = "fixed";
893
+ group.widgets["p2 control_after_generate"].value = "randomize";
894
+ group.widgets["p2 control_filter_list"].value = "/.+/";
895
+
896
+ group.menu["Convert to nodes"].call();
897
+ p1 = graph.find(p1);
898
+ p2 = graph.find(p2);
899
+
900
+ expect(p1.widgets.control_after_generate.value).toBe("fixed");
901
+ expect(p2.widgets.control_after_generate.value).toBe("randomize");
902
+ expect(p2.widgets.control_filter_list.value).toBe("/.+/");
903
+ });
904
+ test("internal reroutes work with converted inputs and merge options", async () => {
905
+ const { ez, graph, app } = await start();
906
+ const vae = ez.VAELoader();
907
+ const latent = ez.EmptyLatentImage();
908
+ const decode = ez.VAEDecode(latent.outputs.LATENT, vae.outputs.VAE);
909
+ const scale = ez.ImageScale(decode.outputs.IMAGE);
910
+ ez.PreviewImage(scale.outputs.IMAGE);
911
+
912
+ const r1 = ez.Reroute();
913
+ const r2 = ez.Reroute();
914
+
915
+ latent.widgets.width.value = 64;
916
+ latent.widgets.height.value = 128;
917
+
918
+ latent.widgets.width.convertToInput();
919
+ latent.widgets.height.convertToInput();
920
+ latent.widgets.batch_size.convertToInput();
921
+
922
+ scale.widgets.width.convertToInput();
923
+ scale.widgets.height.convertToInput();
924
+
925
+ r1.inputs[0].input.label = "hbw";
926
+ r1.outputs[0].connectTo(latent.inputs.height);
927
+ r1.outputs[0].connectTo(latent.inputs.batch_size);
928
+ r1.outputs[0].connectTo(scale.inputs.width);
929
+
930
+ r2.inputs[0].input.label = "wh";
931
+ r2.outputs[0].connectTo(latent.inputs.width);
932
+ r2.outputs[0].connectTo(scale.inputs.height);
933
+
934
+ const group = await convertToGroup(app, graph, "test", [r1, r2, latent, decode, scale]);
935
+
936
+ expect(group.inputs[0].input.type).toBe("VAE");
937
+ expect(group.inputs[1].input.type).toBe("INT");
938
+ expect(group.inputs[2].input.type).toBe("INT");
939
+
940
+ const p1 = ez.PrimitiveNode();
941
+ const p2 = ez.PrimitiveNode();
942
+ p1.outputs[0].connectTo(group.inputs[1]);
943
+ p2.outputs[0].connectTo(group.inputs[2]);
944
+
945
+ expect(p1.widgets.value.widget.options?.min).toBe(16); // width/height min
946
+ expect(p1.widgets.value.widget.options?.max).toBe(4096); // batch max
947
+ expect(p1.widgets.value.widget.options?.step).toBe(80); // width/height step * 10
948
+
949
+ expect(p2.widgets.value.widget.options?.min).toBe(16); // width/height min
950
+ expect(p2.widgets.value.widget.options?.max).toBe(16384); // width/height max
951
+ expect(p2.widgets.value.widget.options?.step).toBe(80); // width/height step * 10
952
+
953
+ expect(p1.widgets.value.value).toBe(128);
954
+ expect(p2.widgets.value.value).toBe(64);
955
+
956
+ p1.widgets.value.value = 16;
957
+ p2.widgets.value.value = 32;
958
+
959
+ await checkBeforeAndAfterReload(graph, async (r) => {
960
+ const id = (v) => (r ? `${group.id}:` : "") + v;
961
+ expect((await graph.toPrompt()).output).toStrictEqual({
962
+ 1: { inputs: { vae_name: "vae1.safetensors" }, class_type: "VAELoader" },
963
+ [id(2)]: { inputs: { width: 32, height: 16, batch_size: 16 }, class_type: "EmptyLatentImage" },
964
+ [id(3)]: { inputs: { samples: [id(2), 0], vae: ["1", 0] }, class_type: "VAEDecode" },
965
+ [id(4)]: {
966
+ inputs: { upscale_method: "nearest-exact", width: 16, height: 32, crop: "disabled", image: [id(3), 0] },
967
+ class_type: "ImageScale",
968
+ },
969
+ 5: { inputs: { images: [id(4), 0] }, class_type: "PreviewImage" },
970
+ });
971
+ });
972
+ });
973
+ test("converted inputs with linked widgets map values correctly on creation", async () => {
974
+ const { ez, graph, app } = await start();
975
+ const k1 = ez.KSampler();
976
+ const k2 = ez.KSampler();
977
+ k1.widgets.seed.convertToInput();
978
+ k2.widgets.seed.convertToInput();
979
+
980
+ const rr = ez.Reroute();
981
+ rr.outputs[0].connectTo(k1.inputs.seed);
982
+ rr.outputs[0].connectTo(k2.inputs.seed);
983
+
984
+ const group = await convertToGroup(app, graph, "test", [k1, k2, rr]);
985
+ expect(group.widgets.steps.value).toBe(20);
986
+ expect(group.widgets.cfg.value).toBe(8);
987
+ expect(group.widgets.scheduler.value).toBe("normal");
988
+ expect(group.widgets["KSampler steps"].value).toBe(20);
989
+ expect(group.widgets["KSampler cfg"].value).toBe(8);
990
+ expect(group.widgets["KSampler scheduler"].value).toBe("normal");
991
+ });
992
+ test("allow multiple of the same node type to be added", async () => {
993
+ const { ez, graph, app } = await start();
994
+ const nodes = [...Array(10)].map(() => ez.ImageScaleBy());
995
+ const group = await convertToGroup(app, graph, "test", nodes);
996
+ expect(group.inputs.length).toBe(10);
997
+ expect(group.outputs.length).toBe(10);
998
+ expect(group.widgets.length).toBe(20);
999
+ expect(group.widgets.map((w) => w.widget.name)).toStrictEqual(
1000
+ [...Array(10)]
1001
+ .map((_, i) => `${i > 0 ? "ImageScaleBy " : ""}${i > 1 ? i + " " : ""}`)
1002
+ .flatMap((p) => [`${p}upscale_method`, `${p}scale_by`])
1003
+ );
1004
+ });
1005
+ });
ComfyUI/tests-ui/tests/users.test.js ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+ /// <reference path="../node_modules/@types/jest/index.d.ts" />
3
+ const { start } = require("../utils");
4
+ const lg = require("../utils/litegraph");
5
+
6
+ describe("users", () => {
7
+ beforeEach(() => {
8
+ lg.setup(global);
9
+ });
10
+
11
+ afterEach(() => {
12
+ lg.teardown(global);
13
+ });
14
+
15
+ function expectNoUserScreen() {
16
+ // Ensure login isnt visible
17
+ const selection = document.querySelectorAll("#comfy-user-selection")?.[0];
18
+ expect(selection["style"].display).toBe("none");
19
+ const menu = document.querySelectorAll(".comfy-menu")?.[0];
20
+ expect(window.getComputedStyle(menu)?.display).not.toBe("none");
21
+ }
22
+
23
+ describe("multi-user", () => {
24
+ function mockAddStylesheet() {
25
+ const utils = require("../../web/scripts/utils");
26
+ utils.addStylesheet = jest.fn().mockReturnValue(Promise.resolve());
27
+ }
28
+
29
+ async function waitForUserScreenShow() {
30
+ mockAddStylesheet();
31
+
32
+ // Wait for "show" to be called
33
+ const { UserSelectionScreen } = require("../../web/scripts/ui/userSelection");
34
+ let resolve, reject;
35
+ const fn = UserSelectionScreen.prototype.show;
36
+ const p = new Promise((res, rej) => {
37
+ resolve = res;
38
+ reject = rej;
39
+ });
40
+ jest.spyOn(UserSelectionScreen.prototype, "show").mockImplementation(async (...args) => {
41
+ const res = fn(...args);
42
+ await new Promise(process.nextTick); // wait for promises to resolve
43
+ resolve();
44
+ return res;
45
+ });
46
+ // @ts-ignore
47
+ setTimeout(() => reject("timeout waiting for UserSelectionScreen to be shown."), 500);
48
+ await p;
49
+ await new Promise(process.nextTick); // wait for promises to resolve
50
+ }
51
+
52
+ async function testUserScreen(onShown, users) {
53
+ if (!users) {
54
+ users = {};
55
+ }
56
+ const starting = start({
57
+ resetEnv: true,
58
+ userConfig: { storage: "server", users },
59
+ });
60
+
61
+ // Ensure no current user
62
+ expect(localStorage["Comfy.userId"]).toBeFalsy();
63
+ expect(localStorage["Comfy.userName"]).toBeFalsy();
64
+
65
+ await waitForUserScreenShow();
66
+
67
+ const selection = document.querySelectorAll("#comfy-user-selection")?.[0];
68
+ expect(selection).toBeTruthy();
69
+
70
+ // Ensure login is visible
71
+ expect(window.getComputedStyle(selection)?.display).not.toBe("none");
72
+ // Ensure menu is hidden
73
+ const menu = document.querySelectorAll(".comfy-menu")?.[0];
74
+ expect(window.getComputedStyle(menu)?.display).toBe("none");
75
+
76
+ const isCreate = await onShown(selection);
77
+
78
+ // Submit form
79
+ selection.querySelectorAll("form")[0].submit();
80
+ await new Promise(process.nextTick); // wait for promises to resolve
81
+
82
+ // Wait for start
83
+ const s = await starting;
84
+
85
+ // Ensure login is removed
86
+ expect(document.querySelectorAll("#comfy-user-selection")).toHaveLength(0);
87
+ expect(window.getComputedStyle(menu)?.display).not.toBe("none");
88
+
89
+ // Ensure settings + templates are saved
90
+ const { api } = require("../../web/scripts/api");
91
+ expect(api.createUser).toHaveBeenCalledTimes(+isCreate);
92
+ expect(api.storeSettings).toHaveBeenCalledTimes(+isCreate);
93
+ expect(api.storeUserData).toHaveBeenCalledTimes(+isCreate);
94
+ if (isCreate) {
95
+ expect(api.storeUserData).toHaveBeenCalledWith("comfy.templates.json", null, { stringify: false });
96
+ expect(s.app.isNewUserSession).toBeTruthy();
97
+ } else {
98
+ expect(s.app.isNewUserSession).toBeFalsy();
99
+ }
100
+
101
+ return { users, selection, ...s };
102
+ }
103
+
104
+ it("allows user creation if no users", async () => {
105
+ const { users } = await testUserScreen((selection) => {
106
+ // Ensure we have no users flag added
107
+ expect(selection.classList.contains("no-users")).toBeTruthy();
108
+
109
+ // Enter a username
110
+ const input = selection.getElementsByTagName("input")[0];
111
+ input.focus();
112
+ input.value = "Test User";
113
+
114
+ return true;
115
+ });
116
+
117
+ expect(users).toStrictEqual({
118
+ "Test User!": "Test User",
119
+ });
120
+
121
+ expect(localStorage["Comfy.userId"]).toBe("Test User!");
122
+ expect(localStorage["Comfy.userName"]).toBe("Test User");
123
+ });
124
+ it("allows user creation if no current user but other users", async () => {
125
+ const users = {
126
+ "Test User 2!": "Test User 2",
127
+ };
128
+
129
+ await testUserScreen((selection) => {
130
+ expect(selection.classList.contains("no-users")).toBeFalsy();
131
+
132
+ // Enter a username
133
+ const input = selection.getElementsByTagName("input")[0];
134
+ input.focus();
135
+ input.value = "Test User 3";
136
+ return true;
137
+ }, users);
138
+
139
+ expect(users).toStrictEqual({
140
+ "Test User 2!": "Test User 2",
141
+ "Test User 3!": "Test User 3",
142
+ });
143
+
144
+ expect(localStorage["Comfy.userId"]).toBe("Test User 3!");
145
+ expect(localStorage["Comfy.userName"]).toBe("Test User 3");
146
+ });
147
+ it("allows user selection if no current user but other users", async () => {
148
+ const users = {
149
+ "A!": "A",
150
+ "B!": "B",
151
+ "C!": "C",
152
+ };
153
+
154
+ await testUserScreen((selection) => {
155
+ expect(selection.classList.contains("no-users")).toBeFalsy();
156
+
157
+ // Check user list
158
+ const select = selection.getElementsByTagName("select")[0];
159
+ const options = select.getElementsByTagName("option");
160
+ expect(
161
+ [...options]
162
+ .filter((o) => !o.disabled)
163
+ .reduce((p, n) => {
164
+ p[n.getAttribute("value")] = n.textContent;
165
+ return p;
166
+ }, {})
167
+ ).toStrictEqual(users);
168
+
169
+ // Select an option
170
+ select.focus();
171
+ select.value = options[2].value;
172
+
173
+ return false;
174
+ }, users);
175
+
176
+ expect(users).toStrictEqual(users);
177
+
178
+ expect(localStorage["Comfy.userId"]).toBe("B!");
179
+ expect(localStorage["Comfy.userName"]).toBe("B");
180
+ });
181
+ it("doesnt show user screen if current user", async () => {
182
+ const starting = start({
183
+ resetEnv: true,
184
+ userConfig: {
185
+ storage: "server",
186
+ users: {
187
+ "User!": "User",
188
+ },
189
+ },
190
+ localStorage: {
191
+ "Comfy.userId": "User!",
192
+ "Comfy.userName": "User",
193
+ },
194
+ });
195
+ await new Promise(process.nextTick); // wait for promises to resolve
196
+
197
+ expectNoUserScreen();
198
+
199
+ await starting;
200
+ });
201
+ it("allows user switching", async () => {
202
+ const { app } = await start({
203
+ resetEnv: true,
204
+ userConfig: {
205
+ storage: "server",
206
+ users: {
207
+ "User!": "User",
208
+ },
209
+ },
210
+ localStorage: {
211
+ "Comfy.userId": "User!",
212
+ "Comfy.userName": "User",
213
+ },
214
+ });
215
+
216
+ // cant actually test switching user easily but can check the setting is present
217
+ expect(app.ui.settings.settingsLookup["Comfy.SwitchUser"]).toBeTruthy();
218
+ });
219
+ });
220
+ describe("single-user", () => {
221
+ it("doesnt show user creation if no default user", async () => {
222
+ const { app } = await start({
223
+ resetEnv: true,
224
+ userConfig: { migrated: false, storage: "server" },
225
+ });
226
+ expectNoUserScreen();
227
+
228
+ // It should store the settings
229
+ const { api } = require("../../web/scripts/api");
230
+ expect(api.storeSettings).toHaveBeenCalledTimes(1);
231
+ expect(api.storeUserData).toHaveBeenCalledTimes(1);
232
+ expect(api.storeUserData).toHaveBeenCalledWith("comfy.templates.json", null, { stringify: false });
233
+ expect(app.isNewUserSession).toBeTruthy();
234
+ });
235
+ it("doesnt show user creation if default user", async () => {
236
+ const { app } = await start({
237
+ resetEnv: true,
238
+ userConfig: { migrated: true, storage: "server" },
239
+ });
240
+ expectNoUserScreen();
241
+
242
+ // It should store the settings
243
+ const { api } = require("../../web/scripts/api");
244
+ expect(api.storeSettings).toHaveBeenCalledTimes(0);
245
+ expect(api.storeUserData).toHaveBeenCalledTimes(0);
246
+ expect(app.isNewUserSession).toBeFalsy();
247
+ });
248
+ it("doesnt allow user switching", async () => {
249
+ const { app } = await start({
250
+ resetEnv: true,
251
+ userConfig: { migrated: true, storage: "server" },
252
+ });
253
+ expectNoUserScreen();
254
+
255
+ expect(app.ui.settings.settingsLookup["Comfy.SwitchUser"]).toBeFalsy();
256
+ });
257
+ });
258
+ describe("browser-user", () => {
259
+ it("doesnt show user creation if no default user", async () => {
260
+ const { app } = await start({
261
+ resetEnv: true,
262
+ userConfig: { migrated: false, storage: "browser" },
263
+ });
264
+ expectNoUserScreen();
265
+
266
+ // It should store the settings
267
+ const { api } = require("../../web/scripts/api");
268
+ expect(api.storeSettings).toHaveBeenCalledTimes(0);
269
+ expect(api.storeUserData).toHaveBeenCalledTimes(0);
270
+ expect(app.isNewUserSession).toBeFalsy();
271
+ });
272
+ it("doesnt show user creation if default user", async () => {
273
+ const { app } = await start({
274
+ resetEnv: true,
275
+ userConfig: { migrated: true, storage: "server" },
276
+ });
277
+ expectNoUserScreen();
278
+
279
+ // It should store the settings
280
+ const { api } = require("../../web/scripts/api");
281
+ expect(api.storeSettings).toHaveBeenCalledTimes(0);
282
+ expect(api.storeUserData).toHaveBeenCalledTimes(0);
283
+ expect(app.isNewUserSession).toBeFalsy();
284
+ });
285
+ it("doesnt allow user switching", async () => {
286
+ const { app } = await start({
287
+ resetEnv: true,
288
+ userConfig: { migrated: true, storage: "browser" },
289
+ });
290
+ expectNoUserScreen();
291
+
292
+ expect(app.ui.settings.settingsLookup["Comfy.SwitchUser"]).toBeFalsy();
293
+ });
294
+ });
295
+ });
ComfyUI/tests-ui/tests/widgetInputs.test.js ADDED
@@ -0,0 +1,557 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+ /// <reference path="../node_modules/@types/jest/index.d.ts" />
3
+
4
+ const {
5
+ start,
6
+ makeNodeDef,
7
+ checkBeforeAndAfterReload,
8
+ assertNotNullOrUndefined,
9
+ createDefaultWorkflow,
10
+ } = require("../utils");
11
+ const lg = require("../utils/litegraph");
12
+
13
+ /**
14
+ * @typedef { import("../utils/ezgraph") } Ez
15
+ * @typedef { ReturnType<Ez["Ez"]["graph"]>["ez"] } EzNodeFactory
16
+ */
17
+
18
+ /**
19
+ * @param { EzNodeFactory } ez
20
+ * @param { InstanceType<Ez["EzGraph"]> } graph
21
+ * @param { InstanceType<Ez["EzInput"]> } input
22
+ * @param { string } widgetType
23
+ * @param { number } controlWidgetCount
24
+ * @returns
25
+ */
26
+ async function connectPrimitiveAndReload(ez, graph, input, widgetType, controlWidgetCount = 0) {
27
+ // Connect to primitive and ensure its still connected after
28
+ let primitive = ez.PrimitiveNode();
29
+ primitive.outputs[0].connectTo(input);
30
+
31
+ await checkBeforeAndAfterReload(graph, async () => {
32
+ primitive = graph.find(primitive);
33
+ let { connections } = primitive.outputs[0];
34
+ expect(connections).toHaveLength(1);
35
+ expect(connections[0].targetNode.id).toBe(input.node.node.id);
36
+
37
+ // Ensure widget is correct type
38
+ const valueWidget = primitive.widgets.value;
39
+ expect(valueWidget.widget.type).toBe(widgetType);
40
+
41
+ // Check if control_after_generate should be added
42
+ if (controlWidgetCount) {
43
+ const controlWidget = primitive.widgets.control_after_generate;
44
+ expect(controlWidget.widget.type).toBe("combo");
45
+ if (widgetType === "combo") {
46
+ const filterWidget = primitive.widgets.control_filter_list;
47
+ expect(filterWidget.widget.type).toBe("string");
48
+ }
49
+ }
50
+
51
+ // Ensure we dont have other widgets
52
+ expect(primitive.node.widgets).toHaveLength(1 + controlWidgetCount);
53
+ });
54
+
55
+ return primitive;
56
+ }
57
+
58
+ describe("widget inputs", () => {
59
+ beforeEach(() => {
60
+ lg.setup(global);
61
+ });
62
+
63
+ afterEach(() => {
64
+ lg.teardown(global);
65
+ });
66
+
67
+ [
68
+ { name: "int", type: "INT", widget: "number", control: 1 },
69
+ { name: "float", type: "FLOAT", widget: "number", control: 1 },
70
+ { name: "text", type: "STRING" },
71
+ {
72
+ name: "customtext",
73
+ type: "STRING",
74
+ opt: { multiline: true },
75
+ },
76
+ { name: "toggle", type: "BOOLEAN" },
77
+ { name: "combo", type: ["a", "b", "c"], control: 2 },
78
+ ].forEach((c) => {
79
+ test(`widget conversion + primitive works on ${c.name}`, async () => {
80
+ const { ez, graph } = await start({
81
+ mockNodeDefs: makeNodeDef("TestNode", { [c.name]: [c.type, c.opt ?? {}] }),
82
+ });
83
+
84
+ // Create test node and convert to input
85
+ const n = ez.TestNode();
86
+ const w = n.widgets[c.name];
87
+ w.convertToInput();
88
+ expect(w.isConvertedToInput).toBeTruthy();
89
+ const input = w.getConvertedInput();
90
+ expect(input).toBeTruthy();
91
+
92
+ // @ts-ignore : input is valid here
93
+ await connectPrimitiveAndReload(ez, graph, input, c.widget ?? c.name, c.control);
94
+ });
95
+ });
96
+
97
+ test("converted widget works after reload", async () => {
98
+ const { ez, graph } = await start();
99
+ let n = ez.CheckpointLoaderSimple();
100
+
101
+ const inputCount = n.inputs.length;
102
+
103
+ // Convert ckpt name to an input
104
+ n.widgets.ckpt_name.convertToInput();
105
+ expect(n.widgets.ckpt_name.isConvertedToInput).toBeTruthy();
106
+ expect(n.inputs.ckpt_name).toBeTruthy();
107
+ expect(n.inputs.length).toEqual(inputCount + 1);
108
+
109
+ // Convert back to widget and ensure input is removed
110
+ n.widgets.ckpt_name.convertToWidget();
111
+ expect(n.widgets.ckpt_name.isConvertedToInput).toBeFalsy();
112
+ expect(n.inputs.ckpt_name).toBeFalsy();
113
+ expect(n.inputs.length).toEqual(inputCount);
114
+
115
+ // Convert again and reload the graph to ensure it maintains state
116
+ n.widgets.ckpt_name.convertToInput();
117
+ expect(n.inputs.length).toEqual(inputCount + 1);
118
+
119
+ const primitive = await connectPrimitiveAndReload(ez, graph, n.inputs.ckpt_name, "combo", 2);
120
+
121
+ // Disconnect & reconnect
122
+ primitive.outputs[0].connections[0].disconnect();
123
+ let { connections } = primitive.outputs[0];
124
+ expect(connections).toHaveLength(0);
125
+
126
+ primitive.outputs[0].connectTo(n.inputs.ckpt_name);
127
+ ({ connections } = primitive.outputs[0]);
128
+ expect(connections).toHaveLength(1);
129
+ expect(connections[0].targetNode.id).toBe(n.node.id);
130
+
131
+ // Convert back to widget and ensure input is removed
132
+ n.widgets.ckpt_name.convertToWidget();
133
+ expect(n.widgets.ckpt_name.isConvertedToInput).toBeFalsy();
134
+ expect(n.inputs.ckpt_name).toBeFalsy();
135
+ expect(n.inputs.length).toEqual(inputCount);
136
+ });
137
+
138
+ test("converted widget works on clone", async () => {
139
+ const { graph, ez } = await start();
140
+ let n = ez.CheckpointLoaderSimple();
141
+
142
+ // Convert the widget to an input
143
+ n.widgets.ckpt_name.convertToInput();
144
+ expect(n.widgets.ckpt_name.isConvertedToInput).toBeTruthy();
145
+
146
+ // Clone the node
147
+ n.menu["Clone"].call();
148
+ expect(graph.nodes).toHaveLength(2);
149
+ const clone = graph.nodes[1];
150
+ expect(clone.id).not.toEqual(n.id);
151
+
152
+ // Ensure the clone has an input
153
+ expect(clone.widgets.ckpt_name.isConvertedToInput).toBeTruthy();
154
+ expect(clone.inputs.ckpt_name).toBeTruthy();
155
+
156
+ // Ensure primitive connects to both nodes
157
+ let primitive = ez.PrimitiveNode();
158
+ primitive.outputs[0].connectTo(n.inputs.ckpt_name);
159
+ primitive.outputs[0].connectTo(clone.inputs.ckpt_name);
160
+ expect(primitive.outputs[0].connections).toHaveLength(2);
161
+
162
+ // Convert back to widget and ensure input is removed
163
+ clone.widgets.ckpt_name.convertToWidget();
164
+ expect(clone.widgets.ckpt_name.isConvertedToInput).toBeFalsy();
165
+ expect(clone.inputs.ckpt_name).toBeFalsy();
166
+ });
167
+
168
+ test("shows missing node error on custom node with converted input", async () => {
169
+ const { graph } = await start();
170
+
171
+ const dialogShow = jest.spyOn(graph.app.ui.dialog, "show");
172
+
173
+ await graph.app.loadGraphData({
174
+ last_node_id: 3,
175
+ last_link_id: 4,
176
+ nodes: [
177
+ {
178
+ id: 1,
179
+ type: "TestNode",
180
+ pos: [41.87329101561909, 389.7381480823742],
181
+ size: { 0: 220, 1: 374 },
182
+ flags: {},
183
+ order: 1,
184
+ mode: 0,
185
+ inputs: [{ name: "test", type: "FLOAT", link: 4, widget: { name: "test" }, slot_index: 0 }],
186
+ outputs: [],
187
+ properties: { "Node name for S&R": "TestNode" },
188
+ widgets_values: [1],
189
+ },
190
+ {
191
+ id: 3,
192
+ type: "PrimitiveNode",
193
+ pos: [-312, 433],
194
+ size: { 0: 210, 1: 82 },
195
+ flags: {},
196
+ order: 0,
197
+ mode: 0,
198
+ outputs: [{ links: [4], widget: { name: "test" } }],
199
+ title: "test",
200
+ properties: {},
201
+ },
202
+ ],
203
+ links: [[4, 3, 0, 1, 6, "FLOAT"]],
204
+ groups: [],
205
+ config: {},
206
+ extra: {},
207
+ version: 0.4,
208
+ });
209
+
210
+ expect(dialogShow).toBeCalledTimes(1);
211
+ expect(dialogShow.mock.calls[0][0].innerHTML).toContain("the following node types were not found");
212
+ expect(dialogShow.mock.calls[0][0].innerHTML).toContain("TestNode");
213
+ });
214
+
215
+ test("defaultInput widgets can be converted back to inputs", async () => {
216
+ const { graph, ez } = await start({
217
+ mockNodeDefs: makeNodeDef("TestNode", { example: ["INT", { defaultInput: true }] }),
218
+ });
219
+
220
+ // Create test node and ensure it starts as an input
221
+ let n = ez.TestNode();
222
+ let w = n.widgets.example;
223
+ expect(w.isConvertedToInput).toBeTruthy();
224
+ let input = w.getConvertedInput();
225
+ expect(input).toBeTruthy();
226
+
227
+ // Ensure it can be converted to
228
+ w.convertToWidget();
229
+ expect(w.isConvertedToInput).toBeFalsy();
230
+ expect(n.inputs.length).toEqual(0);
231
+ // and from
232
+ w.convertToInput();
233
+ expect(w.isConvertedToInput).toBeTruthy();
234
+ input = w.getConvertedInput();
235
+
236
+ // Reload and ensure it still only has 1 converted widget
237
+ if (!assertNotNullOrUndefined(input)) return;
238
+
239
+ await connectPrimitiveAndReload(ez, graph, input, "number", 1);
240
+ n = graph.find(n);
241
+ expect(n.widgets).toHaveLength(1);
242
+ w = n.widgets.example;
243
+ expect(w.isConvertedToInput).toBeTruthy();
244
+
245
+ // Convert back to widget and ensure it is still a widget after reload
246
+ w.convertToWidget();
247
+ await graph.reload();
248
+ n = graph.find(n);
249
+ expect(n.widgets).toHaveLength(1);
250
+ expect(n.widgets[0].isConvertedToInput).toBeFalsy();
251
+ expect(n.inputs.length).toEqual(0);
252
+ });
253
+
254
+ test("forceInput widgets can not be converted back to inputs", async () => {
255
+ const { graph, ez } = await start({
256
+ mockNodeDefs: makeNodeDef("TestNode", { example: ["INT", { forceInput: true }] }),
257
+ });
258
+
259
+ // Create test node and ensure it starts as an input
260
+ let n = ez.TestNode();
261
+ let w = n.widgets.example;
262
+ expect(w.isConvertedToInput).toBeTruthy();
263
+ const input = w.getConvertedInput();
264
+ expect(input).toBeTruthy();
265
+
266
+ // Convert to widget should error
267
+ expect(() => w.convertToWidget()).toThrow();
268
+
269
+ // Reload and ensure it still only has 1 converted widget
270
+ if (assertNotNullOrUndefined(input)) {
271
+ await connectPrimitiveAndReload(ez, graph, input, "number", 1);
272
+ n = graph.find(n);
273
+ expect(n.widgets).toHaveLength(1);
274
+ expect(n.widgets.example.isConvertedToInput).toBeTruthy();
275
+ }
276
+ });
277
+
278
+ test("primitive can connect to matching combos on converted widgets", async () => {
279
+ const { ez } = await start({
280
+ mockNodeDefs: {
281
+ ...makeNodeDef("TestNode1", { example: [["A", "B", "C"], { forceInput: true }] }),
282
+ ...makeNodeDef("TestNode2", { example: [["A", "B", "C"], { forceInput: true }] }),
283
+ },
284
+ });
285
+
286
+ const n1 = ez.TestNode1();
287
+ const n2 = ez.TestNode2();
288
+ const p = ez.PrimitiveNode();
289
+ p.outputs[0].connectTo(n1.inputs[0]);
290
+ p.outputs[0].connectTo(n2.inputs[0]);
291
+ expect(p.outputs[0].connections).toHaveLength(2);
292
+ const valueWidget = p.widgets.value;
293
+ expect(valueWidget.widget.type).toBe("combo");
294
+ expect(valueWidget.widget.options.values).toEqual(["A", "B", "C"]);
295
+ });
296
+
297
+ test("primitive can not connect to non matching combos on converted widgets", async () => {
298
+ const { ez } = await start({
299
+ mockNodeDefs: {
300
+ ...makeNodeDef("TestNode1", { example: [["A", "B", "C"], { forceInput: true }] }),
301
+ ...makeNodeDef("TestNode2", { example: [["A", "B"], { forceInput: true }] }),
302
+ },
303
+ });
304
+
305
+ const n1 = ez.TestNode1();
306
+ const n2 = ez.TestNode2();
307
+ const p = ez.PrimitiveNode();
308
+ p.outputs[0].connectTo(n1.inputs[0]);
309
+ expect(() => p.outputs[0].connectTo(n2.inputs[0])).toThrow();
310
+ expect(p.outputs[0].connections).toHaveLength(1);
311
+ });
312
+
313
+ test("combo output can not connect to non matching combos list input", async () => {
314
+ const { ez } = await start({
315
+ mockNodeDefs: {
316
+ ...makeNodeDef("TestNode1", {}, [["A", "B"]]),
317
+ ...makeNodeDef("TestNode2", { example: [["A", "B"], { forceInput: true }] }),
318
+ ...makeNodeDef("TestNode3", { example: [["A", "B", "C"], { forceInput: true }] }),
319
+ },
320
+ });
321
+
322
+ const n1 = ez.TestNode1();
323
+ const n2 = ez.TestNode2();
324
+ const n3 = ez.TestNode3();
325
+
326
+ n1.outputs[0].connectTo(n2.inputs[0]);
327
+ expect(() => n1.outputs[0].connectTo(n3.inputs[0])).toThrow();
328
+ });
329
+
330
+ test("combo primitive can filter list when control_after_generate called", async () => {
331
+ const { ez } = await start({
332
+ mockNodeDefs: {
333
+ ...makeNodeDef("TestNode1", { example: [["A", "B", "C", "D", "AA", "BB", "CC", "DD", "AAA", "BBB"], {}] }),
334
+ },
335
+ });
336
+
337
+ const n1 = ez.TestNode1();
338
+ n1.widgets.example.convertToInput();
339
+ const p = ez.PrimitiveNode();
340
+ p.outputs[0].connectTo(n1.inputs[0]);
341
+
342
+ const value = p.widgets.value;
343
+ const control = p.widgets.control_after_generate.widget;
344
+ const filter = p.widgets.control_filter_list;
345
+
346
+ expect(p.widgets.length).toBe(3);
347
+ control.value = "increment";
348
+ expect(value.value).toBe("A");
349
+
350
+ // Manually trigger after queue when set to increment
351
+ control["afterQueued"]();
352
+ expect(value.value).toBe("B");
353
+
354
+ // Filter to items containing D
355
+ filter.value = "D";
356
+ control["afterQueued"]();
357
+ expect(value.value).toBe("D");
358
+ control["afterQueued"]();
359
+ expect(value.value).toBe("DD");
360
+
361
+ // Check decrement
362
+ value.value = "BBB";
363
+ control.value = "decrement";
364
+ filter.value = "B";
365
+ control["afterQueued"]();
366
+ expect(value.value).toBe("BB");
367
+ control["afterQueued"]();
368
+ expect(value.value).toBe("B");
369
+
370
+ // Check regex works
371
+ value.value = "BBB";
372
+ filter.value = "/[AB]|^C$/";
373
+ control["afterQueued"]();
374
+ expect(value.value).toBe("AAA");
375
+ control["afterQueued"]();
376
+ expect(value.value).toBe("BB");
377
+ control["afterQueued"]();
378
+ expect(value.value).toBe("AA");
379
+ control["afterQueued"]();
380
+ expect(value.value).toBe("C");
381
+ control["afterQueued"]();
382
+ expect(value.value).toBe("B");
383
+ control["afterQueued"]();
384
+ expect(value.value).toBe("A");
385
+
386
+ // Check random
387
+ control.value = "randomize";
388
+ filter.value = "/D/";
389
+ for (let i = 0; i < 100; i++) {
390
+ control["afterQueued"]();
391
+ expect(value.value === "D" || value.value === "DD").toBeTruthy();
392
+ }
393
+
394
+ // Ensure it doesnt apply when fixed
395
+ control.value = "fixed";
396
+ value.value = "B";
397
+ filter.value = "C";
398
+ control["afterQueued"]();
399
+ expect(value.value).toBe("B");
400
+ });
401
+
402
+ describe("reroutes", () => {
403
+ async function checkOutput(graph, values) {
404
+ expect((await graph.toPrompt()).output).toStrictEqual({
405
+ 1: { inputs: { ckpt_name: "model1.safetensors" }, class_type: "CheckpointLoaderSimple" },
406
+ 2: { inputs: { text: "positive", clip: ["1", 1] }, class_type: "CLIPTextEncode" },
407
+ 3: { inputs: { text: "negative", clip: ["1", 1] }, class_type: "CLIPTextEncode" },
408
+ 4: {
409
+ inputs: { width: values.width ?? 512, height: values.height ?? 512, batch_size: values?.batch_size ?? 1 },
410
+ class_type: "EmptyLatentImage",
411
+ },
412
+ 5: {
413
+ inputs: {
414
+ seed: 0,
415
+ steps: 20,
416
+ cfg: 8,
417
+ sampler_name: "euler",
418
+ scheduler: values?.scheduler ?? "normal",
419
+ denoise: 1,
420
+ model: ["1", 0],
421
+ positive: ["2", 0],
422
+ negative: ["3", 0],
423
+ latent_image: ["4", 0],
424
+ },
425
+ class_type: "KSampler",
426
+ },
427
+ 6: { inputs: { samples: ["5", 0], vae: ["1", 2] }, class_type: "VAEDecode" },
428
+ 7: {
429
+ inputs: { filename_prefix: values.filename_prefix ?? "ComfyUI", images: ["6", 0] },
430
+ class_type: "SaveImage",
431
+ },
432
+ });
433
+ }
434
+
435
+ async function waitForWidget(node) {
436
+ // widgets are created slightly after the graph is ready
437
+ // hard to find an exact hook to get these so just wait for them to be ready
438
+ for (let i = 0; i < 10; i++) {
439
+ await new Promise((r) => setTimeout(r, 10));
440
+ if (node.widgets?.value) {
441
+ return;
442
+ }
443
+ }
444
+ }
445
+
446
+ it("can connect primitive via a reroute path to a widget input", async () => {
447
+ const { ez, graph } = await start();
448
+ const nodes = createDefaultWorkflow(ez, graph);
449
+
450
+ nodes.empty.widgets.width.convertToInput();
451
+ nodes.sampler.widgets.scheduler.convertToInput();
452
+ nodes.save.widgets.filename_prefix.convertToInput();
453
+
454
+ let widthReroute = ez.Reroute();
455
+ let schedulerReroute = ez.Reroute();
456
+ let fileReroute = ez.Reroute();
457
+
458
+ let widthNext = widthReroute;
459
+ let schedulerNext = schedulerReroute;
460
+ let fileNext = fileReroute;
461
+
462
+ for (let i = 0; i < 5; i++) {
463
+ let next = ez.Reroute();
464
+ widthNext.outputs[0].connectTo(next.inputs[0]);
465
+ widthNext = next;
466
+
467
+ next = ez.Reroute();
468
+ schedulerNext.outputs[0].connectTo(next.inputs[0]);
469
+ schedulerNext = next;
470
+
471
+ next = ez.Reroute();
472
+ fileNext.outputs[0].connectTo(next.inputs[0]);
473
+ fileNext = next;
474
+ }
475
+
476
+ widthNext.outputs[0].connectTo(nodes.empty.inputs.width);
477
+ schedulerNext.outputs[0].connectTo(nodes.sampler.inputs.scheduler);
478
+ fileNext.outputs[0].connectTo(nodes.save.inputs.filename_prefix);
479
+
480
+ let widthPrimitive = ez.PrimitiveNode();
481
+ let schedulerPrimitive = ez.PrimitiveNode();
482
+ let filePrimitive = ez.PrimitiveNode();
483
+
484
+ widthPrimitive.outputs[0].connectTo(widthReroute.inputs[0]);
485
+ schedulerPrimitive.outputs[0].connectTo(schedulerReroute.inputs[0]);
486
+ filePrimitive.outputs[0].connectTo(fileReroute.inputs[0]);
487
+ expect(widthPrimitive.widgets.value.value).toBe(512);
488
+ widthPrimitive.widgets.value.value = 1024;
489
+ expect(schedulerPrimitive.widgets.value.value).toBe("normal");
490
+ schedulerPrimitive.widgets.value.value = "simple";
491
+ expect(filePrimitive.widgets.value.value).toBe("ComfyUI");
492
+ filePrimitive.widgets.value.value = "ComfyTest";
493
+
494
+ await checkBeforeAndAfterReload(graph, async () => {
495
+ widthPrimitive = graph.find(widthPrimitive);
496
+ schedulerPrimitive = graph.find(schedulerPrimitive);
497
+ filePrimitive = graph.find(filePrimitive);
498
+ await waitForWidget(filePrimitive);
499
+ expect(widthPrimitive.widgets.length).toBe(2);
500
+ expect(schedulerPrimitive.widgets.length).toBe(3);
501
+ expect(filePrimitive.widgets.length).toBe(1);
502
+
503
+ await checkOutput(graph, {
504
+ width: 1024,
505
+ scheduler: "simple",
506
+ filename_prefix: "ComfyTest",
507
+ });
508
+ });
509
+ });
510
+ it("can connect primitive via a reroute path to multiple widget inputs", async () => {
511
+ const { ez, graph } = await start();
512
+ const nodes = createDefaultWorkflow(ez, graph);
513
+
514
+ nodes.empty.widgets.width.convertToInput();
515
+ nodes.empty.widgets.height.convertToInput();
516
+ nodes.empty.widgets.batch_size.convertToInput();
517
+
518
+ let reroute = ez.Reroute();
519
+ let prevReroute = reroute;
520
+ for (let i = 0; i < 5; i++) {
521
+ const next = ez.Reroute();
522
+ prevReroute.outputs[0].connectTo(next.inputs[0]);
523
+ prevReroute = next;
524
+ }
525
+
526
+ const r1 = ez.Reroute(prevReroute.outputs[0]);
527
+ const r2 = ez.Reroute(prevReroute.outputs[0]);
528
+ const r3 = ez.Reroute(r2.outputs[0]);
529
+ const r4 = ez.Reroute(r2.outputs[0]);
530
+
531
+ r1.outputs[0].connectTo(nodes.empty.inputs.width);
532
+ r3.outputs[0].connectTo(nodes.empty.inputs.height);
533
+ r4.outputs[0].connectTo(nodes.empty.inputs.batch_size);
534
+
535
+ let primitive = ez.PrimitiveNode();
536
+ primitive.outputs[0].connectTo(reroute.inputs[0]);
537
+ expect(primitive.widgets.value.value).toBe(1);
538
+ primitive.widgets.value.value = 64;
539
+
540
+ await checkBeforeAndAfterReload(graph, async (r) => {
541
+ primitive = graph.find(primitive);
542
+ await waitForWidget(primitive);
543
+
544
+ // Ensure widget configs are merged
545
+ expect(primitive.widgets.value.widget.options?.min).toBe(16); // width/height min
546
+ expect(primitive.widgets.value.widget.options?.max).toBe(4096); // batch max
547
+ expect(primitive.widgets.value.widget.options?.step).toBe(80); // width/height step * 10
548
+
549
+ await checkOutput(graph, {
550
+ width: 64,
551
+ height: 64,
552
+ batch_size: 64,
553
+ });
554
+ });
555
+ });
556
+ });
557
+ });
ComfyUI/tests-ui/utils/ezgraph.js ADDED
@@ -0,0 +1,452 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+ /// <reference path="../../web/types/litegraph.d.ts" />
3
+
4
+ /**
5
+ * @typedef { import("../../web/scripts/app")["app"] } app
6
+ * @typedef { import("../../web/types/litegraph") } LG
7
+ * @typedef { import("../../web/types/litegraph").IWidget } IWidget
8
+ * @typedef { import("../../web/types/litegraph").ContextMenuItem } ContextMenuItem
9
+ * @typedef { import("../../web/types/litegraph").INodeInputSlot } INodeInputSlot
10
+ * @typedef { import("../../web/types/litegraph").INodeOutputSlot } INodeOutputSlot
11
+ * @typedef { InstanceType<LG["LGraphNode"]> & { widgets?: Array<IWidget> } } LGNode
12
+ * @typedef { (...args: EzOutput[] | [...EzOutput[], Record<string, unknown>]) => EzNode } EzNodeFactory
13
+ */
14
+
15
+ export class EzConnection {
16
+ /** @type { app } */
17
+ app;
18
+ /** @type { InstanceType<LG["LLink"]> } */
19
+ link;
20
+
21
+ get originNode() {
22
+ return new EzNode(this.app, this.app.graph.getNodeById(this.link.origin_id));
23
+ }
24
+
25
+ get originOutput() {
26
+ return this.originNode.outputs[this.link.origin_slot];
27
+ }
28
+
29
+ get targetNode() {
30
+ return new EzNode(this.app, this.app.graph.getNodeById(this.link.target_id));
31
+ }
32
+
33
+ get targetInput() {
34
+ return this.targetNode.inputs[this.link.target_slot];
35
+ }
36
+
37
+ /**
38
+ * @param { app } app
39
+ * @param { InstanceType<LG["LLink"]> } link
40
+ */
41
+ constructor(app, link) {
42
+ this.app = app;
43
+ this.link = link;
44
+ }
45
+
46
+ disconnect() {
47
+ this.targetInput.disconnect();
48
+ }
49
+ }
50
+
51
+ export class EzSlot {
52
+ /** @type { EzNode } */
53
+ node;
54
+ /** @type { number } */
55
+ index;
56
+
57
+ /**
58
+ * @param { EzNode } node
59
+ * @param { number } index
60
+ */
61
+ constructor(node, index) {
62
+ this.node = node;
63
+ this.index = index;
64
+ }
65
+ }
66
+
67
+ export class EzInput extends EzSlot {
68
+ /** @type { INodeInputSlot } */
69
+ input;
70
+
71
+ /**
72
+ * @param { EzNode } node
73
+ * @param { number } index
74
+ * @param { INodeInputSlot } input
75
+ */
76
+ constructor(node, index, input) {
77
+ super(node, index);
78
+ this.input = input;
79
+ }
80
+
81
+ get connection() {
82
+ const link = this.node.node.inputs?.[this.index]?.link;
83
+ if (link == null) {
84
+ return null;
85
+ }
86
+ return new EzConnection(this.node.app, this.node.app.graph.links[link]);
87
+ }
88
+
89
+ disconnect() {
90
+ this.node.node.disconnectInput(this.index);
91
+ }
92
+ }
93
+
94
+ export class EzOutput extends EzSlot {
95
+ /** @type { INodeOutputSlot } */
96
+ output;
97
+
98
+ /**
99
+ * @param { EzNode } node
100
+ * @param { number } index
101
+ * @param { INodeOutputSlot } output
102
+ */
103
+ constructor(node, index, output) {
104
+ super(node, index);
105
+ this.output = output;
106
+ }
107
+
108
+ get connections() {
109
+ return (this.node.node.outputs?.[this.index]?.links ?? []).map(
110
+ (l) => new EzConnection(this.node.app, this.node.app.graph.links[l])
111
+ );
112
+ }
113
+
114
+ /**
115
+ * @param { EzInput } input
116
+ */
117
+ connectTo(input) {
118
+ if (!input) throw new Error("Invalid input");
119
+
120
+ /**
121
+ * @type { LG["LLink"] | null }
122
+ */
123
+ const link = this.node.node.connect(this.index, input.node.node, input.index);
124
+ if (!link) {
125
+ const inp = input.input;
126
+ const inName = inp.name || inp.label || inp.type;
127
+ throw new Error(
128
+ `Connecting from ${input.node.node.type}#${input.node.id}[${inName}#${input.index}] -> ${this.node.node.type}#${this.node.id}[${
129
+ this.output.name ?? this.output.type
130
+ }#${this.index}] failed.`
131
+ );
132
+ }
133
+ return link;
134
+ }
135
+ }
136
+
137
+ export class EzNodeMenuItem {
138
+ /** @type { EzNode } */
139
+ node;
140
+ /** @type { number } */
141
+ index;
142
+ /** @type { ContextMenuItem } */
143
+ item;
144
+
145
+ /**
146
+ * @param { EzNode } node
147
+ * @param { number } index
148
+ * @param { ContextMenuItem } item
149
+ */
150
+ constructor(node, index, item) {
151
+ this.node = node;
152
+ this.index = index;
153
+ this.item = item;
154
+ }
155
+
156
+ call(selectNode = true) {
157
+ if (!this.item?.callback) throw new Error(`Menu Item ${this.item?.content ?? "[null]"} has no callback.`);
158
+ if (selectNode) {
159
+ this.node.select();
160
+ }
161
+ return this.item.callback.call(this.node.node, undefined, undefined, undefined, undefined, this.node.node);
162
+ }
163
+ }
164
+
165
+ export class EzWidget {
166
+ /** @type { EzNode } */
167
+ node;
168
+ /** @type { number } */
169
+ index;
170
+ /** @type { IWidget } */
171
+ widget;
172
+
173
+ /**
174
+ * @param { EzNode } node
175
+ * @param { number } index
176
+ * @param { IWidget } widget
177
+ */
178
+ constructor(node, index, widget) {
179
+ this.node = node;
180
+ this.index = index;
181
+ this.widget = widget;
182
+ }
183
+
184
+ get value() {
185
+ return this.widget.value;
186
+ }
187
+
188
+ set value(v) {
189
+ this.widget.value = v;
190
+ this.widget.callback?.call?.(this.widget, v)
191
+ }
192
+
193
+ get isConvertedToInput() {
194
+ // @ts-ignore : this type is valid for converted widgets
195
+ return this.widget.type === "converted-widget";
196
+ }
197
+
198
+ getConvertedInput() {
199
+ if (!this.isConvertedToInput) throw new Error(`Widget ${this.widget.name} is not converted to input.`);
200
+
201
+ return this.node.inputs.find((inp) => inp.input["widget"]?.name === this.widget.name);
202
+ }
203
+
204
+ convertToWidget() {
205
+ if (!this.isConvertedToInput)
206
+ throw new Error(`Widget ${this.widget.name} cannot be converted as it is already a widget.`);
207
+ var menu = this.node.menu["Convert Input to Widget"].item.submenu.options;
208
+ var index = menu.findIndex(a => a.content == `Convert ${this.widget.name} to widget`);
209
+ menu[index].callback.call();
210
+ }
211
+
212
+ convertToInput() {
213
+ if (this.isConvertedToInput)
214
+ throw new Error(`Widget ${this.widget.name} cannot be converted as it is already an input.`);
215
+ var menu = this.node.menu["Convert Widget to Input"].item.submenu.options;
216
+ var index = menu.findIndex(a => a.content == `Convert ${this.widget.name} to input`);
217
+ menu[index].callback.call();
218
+ }
219
+ }
220
+
221
+ export class EzNode {
222
+ /** @type { app } */
223
+ app;
224
+ /** @type { LGNode } */
225
+ node;
226
+
227
+ /**
228
+ * @param { app } app
229
+ * @param { LGNode } node
230
+ */
231
+ constructor(app, node) {
232
+ this.app = app;
233
+ this.node = node;
234
+ }
235
+
236
+ get id() {
237
+ return this.node.id;
238
+ }
239
+
240
+ get inputs() {
241
+ return this.#makeLookupArray("inputs", "name", EzInput);
242
+ }
243
+
244
+ get outputs() {
245
+ return this.#makeLookupArray("outputs", "name", EzOutput);
246
+ }
247
+
248
+ get widgets() {
249
+ return this.#makeLookupArray("widgets", "name", EzWidget);
250
+ }
251
+
252
+ get menu() {
253
+ return this.#makeLookupArray(() => this.app.canvas.getNodeMenuOptions(this.node), "content", EzNodeMenuItem);
254
+ }
255
+
256
+ get isRemoved() {
257
+ return !this.app.graph.getNodeById(this.id);
258
+ }
259
+
260
+ select(addToSelection = false) {
261
+ this.app.canvas.selectNode(this.node, addToSelection);
262
+ }
263
+
264
+ // /**
265
+ // * @template { "inputs" | "outputs" } T
266
+ // * @param { T } type
267
+ // * @returns { Record<string, type extends "inputs" ? EzInput : EzOutput> & (type extends "inputs" ? EzInput [] : EzOutput[]) }
268
+ // */
269
+ // #getSlotItems(type) {
270
+ // // @ts-ignore : these items are correct
271
+ // return (this.node[type] ?? []).reduce((p, s, i) => {
272
+ // if (s.name in p) {
273
+ // throw new Error(`Unable to store input ${s.name} on array as name conflicts.`);
274
+ // }
275
+ // // @ts-ignore
276
+ // p.push((p[s.name] = new (type === "inputs" ? EzInput : EzOutput)(this, i, s)));
277
+ // return p;
278
+ // }, Object.assign([], { $: this }));
279
+ // }
280
+
281
+ /**
282
+ * @template { { new(node: EzNode, index: number, obj: any): any } } T
283
+ * @param { "inputs" | "outputs" | "widgets" | (() => Array<unknown>) } nodeProperty
284
+ * @param { string } nameProperty
285
+ * @param { T } ctor
286
+ * @returns { Record<string, InstanceType<T>> & Array<InstanceType<T>> }
287
+ */
288
+ #makeLookupArray(nodeProperty, nameProperty, ctor) {
289
+ const items = typeof nodeProperty === "function" ? nodeProperty() : this.node[nodeProperty];
290
+ // @ts-ignore
291
+ return (items ?? []).reduce((p, s, i) => {
292
+ if (!s) return p;
293
+
294
+ const name = s[nameProperty];
295
+ const item = new ctor(this, i, s);
296
+ // @ts-ignore
297
+ p.push(item);
298
+ if (name) {
299
+ // @ts-ignore
300
+ if (name in p) {
301
+ throw new Error(`Unable to store ${nodeProperty} ${name} on array as name conflicts.`);
302
+ }
303
+ }
304
+ // @ts-ignore
305
+ p[name] = item;
306
+ return p;
307
+ }, Object.assign([], { $: this }));
308
+ }
309
+ }
310
+
311
+ export class EzGraph {
312
+ /** @type { app } */
313
+ app;
314
+
315
+ /**
316
+ * @param { app } app
317
+ */
318
+ constructor(app) {
319
+ this.app = app;
320
+ }
321
+
322
+ get nodes() {
323
+ return this.app.graph._nodes.map((n) => new EzNode(this.app, n));
324
+ }
325
+
326
+ clear() {
327
+ this.app.graph.clear();
328
+ }
329
+
330
+ arrange() {
331
+ this.app.graph.arrange();
332
+ }
333
+
334
+ stringify() {
335
+ return JSON.stringify(this.app.graph.serialize(), undefined);
336
+ }
337
+
338
+ /**
339
+ * @param { number | LGNode | EzNode } obj
340
+ * @returns { EzNode }
341
+ */
342
+ find(obj) {
343
+ let match;
344
+ let id;
345
+ if (typeof obj === "number") {
346
+ id = obj;
347
+ } else {
348
+ id = obj.id;
349
+ }
350
+
351
+ match = this.app.graph.getNodeById(id);
352
+
353
+ if (!match) {
354
+ throw new Error(`Unable to find node with ID ${id}.`);
355
+ }
356
+
357
+ return new EzNode(this.app, match);
358
+ }
359
+
360
+ /**
361
+ * @returns { Promise<void> }
362
+ */
363
+ reload() {
364
+ const graph = JSON.parse(JSON.stringify(this.app.graph.serialize()));
365
+ return new Promise((r) => {
366
+ this.app.graph.clear();
367
+ setTimeout(async () => {
368
+ await this.app.loadGraphData(graph);
369
+ r();
370
+ }, 10);
371
+ });
372
+ }
373
+
374
+ /**
375
+ * @returns { Promise<{
376
+ * workflow: {},
377
+ * output: Record<string, {
378
+ * class_name: string,
379
+ * inputs: Record<string, [string, number] | unknown>
380
+ * }>}> }
381
+ */
382
+ toPrompt() {
383
+ // @ts-ignore
384
+ return this.app.graphToPrompt();
385
+ }
386
+ }
387
+
388
+ export const Ez = {
389
+ /**
390
+ * Quickly build and interact with a ComfyUI graph
391
+ * @example
392
+ * const { ez, graph } = Ez.graph(app);
393
+ * graph.clear();
394
+ * const [model, clip, vae] = ez.CheckpointLoaderSimple().outputs;
395
+ * const [pos] = ez.CLIPTextEncode(clip, { text: "positive" }).outputs;
396
+ * const [neg] = ez.CLIPTextEncode(clip, { text: "negative" }).outputs;
397
+ * const [latent] = ez.KSampler(model, pos, neg, ...ez.EmptyLatentImage().outputs).outputs;
398
+ * const [image] = ez.VAEDecode(latent, vae).outputs;
399
+ * const saveNode = ez.SaveImage(image);
400
+ * console.log(saveNode);
401
+ * graph.arrange();
402
+ * @param { app } app
403
+ * @param { LG["LiteGraph"] } LiteGraph
404
+ * @param { LG["LGraphCanvas"] } LGraphCanvas
405
+ * @param { boolean } clearGraph
406
+ * @returns { { graph: EzGraph, ez: Record<string, EzNodeFactory> } }
407
+ */
408
+ graph(app, LiteGraph = window["LiteGraph"], LGraphCanvas = window["LGraphCanvas"], clearGraph = true) {
409
+ // Always set the active canvas so things work
410
+ LGraphCanvas.active_canvas = app.canvas;
411
+
412
+ if (clearGraph) {
413
+ app.graph.clear();
414
+ }
415
+
416
+ // @ts-ignore : this proxy handles utility methods & node creation
417
+ const factory = new Proxy(
418
+ {},
419
+ {
420
+ get(_, p) {
421
+ if (typeof p !== "string") throw new Error("Invalid node");
422
+ const node = LiteGraph.createNode(p);
423
+ if (!node) throw new Error(`Unknown node "${p}"`);
424
+ app.graph.add(node);
425
+
426
+ /**
427
+ * @param {Parameters<EzNodeFactory>} args
428
+ */
429
+ return function (...args) {
430
+ const ezNode = new EzNode(app, node);
431
+ const inputs = ezNode.inputs;
432
+
433
+ let slot = 0;
434
+ for (const arg of args) {
435
+ if (arg instanceof EzOutput) {
436
+ arg.connectTo(inputs[slot++]);
437
+ } else {
438
+ for (const k in arg) {
439
+ ezNode.widgets[k].value = arg[k];
440
+ }
441
+ }
442
+ }
443
+
444
+ return ezNode;
445
+ };
446
+ },
447
+ }
448
+ );
449
+
450
+ return { graph: new EzGraph(app), ez: factory };
451
+ },
452
+ };
ComfyUI/tests-ui/utils/index.js ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { mockApi } = require("./setup");
2
+ const { Ez } = require("./ezgraph");
3
+ const lg = require("./litegraph");
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+
7
+ const html = fs.readFileSync(path.resolve(__dirname, "../../web/index.html"))
8
+
9
+ /**
10
+ *
11
+ * @param { Parameters<typeof mockApi>[0] & {
12
+ * resetEnv?: boolean,
13
+ * preSetup?(app): Promise<void>,
14
+ * localStorage?: Record<string, string>
15
+ * } } config
16
+ * @returns
17
+ */
18
+ export async function start(config = {}) {
19
+ if(config.resetEnv) {
20
+ jest.resetModules();
21
+ jest.resetAllMocks();
22
+ lg.setup(global);
23
+ localStorage.clear();
24
+ sessionStorage.clear();
25
+ }
26
+
27
+ Object.assign(localStorage, config.localStorage ?? {});
28
+ document.body.innerHTML = html;
29
+
30
+ mockApi(config);
31
+ const { app } = require("../../web/scripts/app");
32
+ config.preSetup?.(app);
33
+ await app.setup();
34
+
35
+ return { ...Ez.graph(app, global["LiteGraph"], global["LGraphCanvas"]), app };
36
+ }
37
+
38
+ /**
39
+ * @param { ReturnType<Ez["graph"]>["graph"] } graph
40
+ * @param { (hasReloaded: boolean) => (Promise<void> | void) } cb
41
+ */
42
+ export async function checkBeforeAndAfterReload(graph, cb) {
43
+ await cb(false);
44
+ await graph.reload();
45
+ await cb(true);
46
+ }
47
+
48
+ /**
49
+ * @param { string } name
50
+ * @param { Record<string, string | [string | string[], any]> } input
51
+ * @param { (string | string[])[] | Record<string, string | string[]> } output
52
+ * @returns { Record<string, import("../../web/types/comfy").ComfyObjectInfo> }
53
+ */
54
+ export function makeNodeDef(name, input, output = {}) {
55
+ const nodeDef = {
56
+ name,
57
+ category: "test",
58
+ output: [],
59
+ output_name: [],
60
+ output_is_list: [],
61
+ input: {
62
+ required: {},
63
+ },
64
+ };
65
+ for (const k in input) {
66
+ nodeDef.input.required[k] = typeof input[k] === "string" ? [input[k], {}] : [...input[k]];
67
+ }
68
+ if (output instanceof Array) {
69
+ output = output.reduce((p, c) => {
70
+ p[c] = c;
71
+ return p;
72
+ }, {});
73
+ }
74
+ for (const k in output) {
75
+ nodeDef.output.push(output[k]);
76
+ nodeDef.output_name.push(k);
77
+ nodeDef.output_is_list.push(false);
78
+ }
79
+
80
+ return { [name]: nodeDef };
81
+ }
82
+
83
+ /**
84
+ /**
85
+ * @template { any } T
86
+ * @param { T } x
87
+ * @returns { x is Exclude<T, null | undefined> }
88
+ */
89
+ export function assertNotNullOrUndefined(x) {
90
+ expect(x).not.toEqual(null);
91
+ expect(x).not.toEqual(undefined);
92
+ return true;
93
+ }
94
+
95
+ /**
96
+ *
97
+ * @param { ReturnType<Ez["graph"]>["ez"] } ez
98
+ * @param { ReturnType<Ez["graph"]>["graph"] } graph
99
+ */
100
+ export function createDefaultWorkflow(ez, graph) {
101
+ graph.clear();
102
+ const ckpt = ez.CheckpointLoaderSimple();
103
+
104
+ const pos = ez.CLIPTextEncode(ckpt.outputs.CLIP, { text: "positive" });
105
+ const neg = ez.CLIPTextEncode(ckpt.outputs.CLIP, { text: "negative" });
106
+
107
+ const empty = ez.EmptyLatentImage();
108
+ const sampler = ez.KSampler(
109
+ ckpt.outputs.MODEL,
110
+ pos.outputs.CONDITIONING,
111
+ neg.outputs.CONDITIONING,
112
+ empty.outputs.LATENT
113
+ );
114
+
115
+ const decode = ez.VAEDecode(sampler.outputs.LATENT, ckpt.outputs.VAE);
116
+ const save = ez.SaveImage(decode.outputs.IMAGE);
117
+ graph.arrange();
118
+
119
+ return { ckpt, pos, neg, empty, sampler, decode, save };
120
+ }
121
+
122
+ export async function getNodeDefs() {
123
+ const { api } = require("../../web/scripts/api");
124
+ return api.getNodeDefs();
125
+ }
126
+
127
+ export async function getNodeDef(nodeId) {
128
+ return (await getNodeDefs())[nodeId];
129
+ }
ComfyUI/tests-ui/utils/litegraph.js ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { nop } = require("../utils/nopProxy");
4
+
5
+ function forEachKey(cb) {
6
+ for (const k of [
7
+ "LiteGraph",
8
+ "LGraph",
9
+ "LLink",
10
+ "LGraphNode",
11
+ "LGraphGroup",
12
+ "DragAndScale",
13
+ "LGraphCanvas",
14
+ "ContextMenu",
15
+ ]) {
16
+ cb(k);
17
+ }
18
+ }
19
+
20
+ export function setup(ctx) {
21
+ const lg = fs.readFileSync(path.resolve("../web/lib/litegraph.core.js"), "utf-8");
22
+ const globalTemp = {};
23
+ (function (console) {
24
+ eval(lg);
25
+ }).call(globalTemp, nop);
26
+
27
+ forEachKey((k) => (ctx[k] = globalTemp[k]));
28
+ require(path.resolve("../web/lib/litegraph.extensions.js"));
29
+ }
30
+
31
+ export function teardown(ctx) {
32
+ forEachKey((k) => delete ctx[k]);
33
+
34
+ // Clear document after each run
35
+ document.getElementsByTagName("html")[0].innerHTML = "";
36
+ }
ComfyUI/tests-ui/utils/nopProxy.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export const nop = new Proxy(function () {}, {
2
+ get: () => nop,
3
+ set: () => true,
4
+ apply: () => nop,
5
+ construct: () => nop,
6
+ });
ComfyUI/tests-ui/utils/setup.js ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ require("../../web/scripts/api");
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ function* walkSync(dir) {
6
+ const files = fs.readdirSync(dir, { withFileTypes: true });
7
+ for (const file of files) {
8
+ if (file.isDirectory()) {
9
+ yield* walkSync(path.join(dir, file.name));
10
+ } else {
11
+ yield path.join(dir, file.name);
12
+ }
13
+ }
14
+ }
15
+
16
+ /**
17
+ * @typedef { import("../../web/types/comfy").ComfyObjectInfo } ComfyObjectInfo
18
+ */
19
+
20
+ /**
21
+ * @param {{
22
+ * mockExtensions?: string[],
23
+ * mockNodeDefs?: Record<string, ComfyObjectInfo>,
24
+ * settings?: Record<string, string>
25
+ * userConfig?: {storage: "server" | "browser", users?: Record<string, any>, migrated?: boolean },
26
+ * userData?: Record<string, any>
27
+ * }} config
28
+ */
29
+ export function mockApi(config = {}) {
30
+ let { mockExtensions, mockNodeDefs, userConfig, settings, userData } = {
31
+ userConfig,
32
+ settings: {},
33
+ userData: {},
34
+ ...config,
35
+ };
36
+ if (!mockExtensions) {
37
+ mockExtensions = Array.from(walkSync(path.resolve("../web/extensions/core")))
38
+ .filter((x) => x.endsWith(".js"))
39
+ .map((x) => path.relative(path.resolve("../web"), x));
40
+ }
41
+ if (!mockNodeDefs) {
42
+ mockNodeDefs = JSON.parse(fs.readFileSync(path.resolve("./data/object_info.json")));
43
+ }
44
+
45
+ const events = new EventTarget();
46
+ const mockApi = {
47
+ addEventListener: events.addEventListener.bind(events),
48
+ removeEventListener: events.removeEventListener.bind(events),
49
+ dispatchEvent: events.dispatchEvent.bind(events),
50
+ getSystemStats: jest.fn(),
51
+ getExtensions: jest.fn(() => mockExtensions),
52
+ getNodeDefs: jest.fn(() => mockNodeDefs),
53
+ init: jest.fn(),
54
+ apiURL: jest.fn((x) => "../../web/" + x),
55
+ createUser: jest.fn((username) => {
56
+ if(username in userConfig.users) {
57
+ return { status: 400, json: () => "Duplicate" }
58
+ }
59
+ userConfig.users[username + "!"] = username;
60
+ return { status: 200, json: () => username + "!" }
61
+ }),
62
+ getUserConfig: jest.fn(() => userConfig ?? { storage: "browser", migrated: false }),
63
+ getSettings: jest.fn(() => settings),
64
+ storeSettings: jest.fn((v) => Object.assign(settings, v)),
65
+ getUserData: jest.fn((f) => {
66
+ if (f in userData) {
67
+ return { status: 200, json: () => userData[f] };
68
+ } else {
69
+ return { status: 404 };
70
+ }
71
+ }),
72
+ storeUserData: jest.fn((file, data) => {
73
+ userData[file] = data;
74
+ }),
75
+ };
76
+ jest.mock("../../web/scripts/api", () => ({
77
+ get api() {
78
+ return mockApi;
79
+ },
80
+ }));
81
+ }