Compare commits
960 Commits
v1.2.54
...
task-runne
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
628b44051b | ||
|
|
a7a5e3cf67 | ||
|
|
64e218a9f3 | ||
|
|
0b69d3cbfe | ||
|
|
8257e848c6 | ||
|
|
a07b7693b6 | ||
|
|
26ddf69451 | ||
|
|
ed6ece2099 | ||
|
|
b42516d39c | ||
|
|
ef24efe5a3 | ||
|
|
34c267c755 | ||
|
|
8b9f0ddd1d | ||
|
|
af658b7792 | ||
|
|
9c53bbd53d | ||
|
|
f9be20fa78 | ||
|
|
87fc7a2c5d | ||
|
|
1f266e826e | ||
|
|
911adfe9f8 | ||
|
|
654d72b4cc | ||
|
|
a1ed67fc74 | ||
|
|
78bc635518 | ||
|
|
f49ec175e9 | ||
|
|
e4c60e7e18 | ||
|
|
37cb2cb0a5 | ||
|
|
141825e988 | ||
|
|
78f43b1e06 | ||
|
|
a6105eb8c7 | ||
|
|
79ed598d5d | ||
|
|
816574e0ab | ||
|
|
1a4e77a3ab | ||
|
|
de570712df | ||
|
|
44612e8f97 | ||
|
|
3df911c1bf | ||
|
|
af26b9ad6d | ||
|
|
d503873980 | ||
|
|
842a9f74fc | ||
|
|
29551a36b3 | ||
|
|
d6e5c8950c | ||
|
|
ad1c1ce9c2 | ||
|
|
cb9d2c6bae | ||
|
|
7fd41eeaba | ||
|
|
79fee6ac72 | ||
|
|
edd58cd153 | ||
|
|
e153508955 | ||
|
|
237fca0bf1 | ||
|
|
65542b885a | ||
|
|
f739f704af | ||
|
|
37abdbe35d | ||
|
|
ff445f5c95 | ||
|
|
84b652a281 | ||
|
|
184291d21b | ||
|
|
d7fb25a36a | ||
|
|
c039a60fcc | ||
|
|
3b6108c26e | ||
|
|
49bb247526 | ||
|
|
dd005f5fa5 | ||
|
|
bf90b458d3 | ||
|
|
7e78c5b1dc | ||
|
|
c13190cd07 | ||
|
|
00f031e382 | ||
|
|
04153caaf5 | ||
|
|
210bfdeb7d | ||
|
|
ce0726d85e | ||
|
|
dd69f9dc30 | ||
|
|
3f261f0e53 | ||
|
|
3b2cc23f65 | ||
|
|
c50a86b258 | ||
|
|
1a8c2bba42 | ||
|
|
fc09951b3e | ||
|
|
76d5f39607 | ||
|
|
9d3bc0f173 | ||
|
|
d9b350e159 | ||
|
|
44610674ee | ||
|
|
9bfce5b8d0 | ||
|
|
8986fa356a | ||
|
|
0c4fd4af1c | ||
|
|
30cd46ce1f | ||
|
|
3122c33310 | ||
|
|
91d8d04dc6 | ||
|
|
8f5aa1ff08 | ||
|
|
e076783b89 | ||
|
|
04c23001fc | ||
|
|
cb265fb0bf | ||
|
|
e9211fe377 | ||
|
|
ffc7febeac | ||
|
|
b15e626607 | ||
|
|
906b5e35a3 | ||
|
|
e8cd9c7642 | ||
|
|
93e184e379 | ||
|
|
1d02cd3c47 | ||
|
|
b86e3f71cb | ||
|
|
1ece2462bd | ||
|
|
73ecacfa2d | ||
|
|
67e6df7c72 | ||
|
|
7e8510028d | ||
|
|
dd4dd8b68a | ||
|
|
7111022617 | ||
|
|
daee073045 | ||
|
|
a1a834a76d | ||
|
|
c437d32691 | ||
|
|
0130d41be5 | ||
|
|
527561d148 | ||
|
|
1c4481c342 | ||
|
|
07000a23d4 | ||
|
|
ea6c9e7ca5 | ||
|
|
90698fced6 | ||
|
|
9716aea10d | ||
|
|
077ded2cce | ||
|
|
ffb20b8789 | ||
|
|
549a2fdc92 | ||
|
|
e123295423 | ||
|
|
613b44610a | ||
|
|
e82d795ff9 | ||
|
|
ba31f8fa68 | ||
|
|
f2eb4e1519 | ||
|
|
975c2248c5 | ||
|
|
477f4b275d | ||
|
|
179b8c22a9 | ||
|
|
6525389273 | ||
|
|
59fc5ac77e | ||
|
|
ed844e04b8 | ||
|
|
972af1977d | ||
|
|
5ac0b7b181 | ||
|
|
31ea39e44c | ||
|
|
e65653c107 | ||
|
|
e46706777c | ||
|
|
1a817a48cb | ||
|
|
871967349f | ||
|
|
11258f4a95 | ||
|
|
c3e05c2a10 | ||
|
|
ea1e776dcc | ||
|
|
f0c273f845 | ||
|
|
ea489851ed | ||
|
|
5717c33a0b | ||
|
|
c31919c418 | ||
|
|
2e7de4701e | ||
|
|
f53723da0f | ||
|
|
5c7cbe968e | ||
|
|
cb607ee070 | ||
|
|
f4b5677901 | ||
|
|
9c1eacf0af | ||
|
|
bb988edf9f | ||
|
|
39e9f421f4 | ||
|
|
3d2b9a8d9d | ||
|
|
c77a5cab5b | ||
|
|
e525a3ad98 | ||
|
|
f507142e6a | ||
|
|
3189e310a8 | ||
|
|
174a9a114a | ||
|
|
0b3c0cc0c9 | ||
|
|
74b02237db | ||
|
|
6dc5c6958f | ||
|
|
70bece2d48 | ||
|
|
7eea2e5aa0 | ||
|
|
eb94337c43 | ||
|
|
98b96d2b1a | ||
|
|
1b10e16b2d | ||
|
|
d8f074fea0 | ||
|
|
e88817ea36 | ||
|
|
4ae77e22d4 | ||
|
|
9f23f81879 | ||
|
|
3bf0dfd218 | ||
|
|
be7e2c6505 | ||
|
|
6dce285210 | ||
|
|
5218024395 | ||
|
|
92a5a8057f | ||
|
|
b19f7ca228 | ||
|
|
94f964cf17 | ||
|
|
a7fd9bf0d9 | ||
|
|
642094ead1 | ||
|
|
95a4623c37 | ||
|
|
773059da93 | ||
|
|
a48ad1cb41 | ||
|
|
5152985656 | ||
|
|
ab33eb6262 | ||
|
|
af1a5d9dbc | ||
|
|
feabd3f11d | ||
|
|
74361eebe0 | ||
|
|
20412f2c74 | ||
|
|
861d559f19 | ||
|
|
7cb0c230df | ||
|
|
9d94802e26 | ||
|
|
34492aed17 | ||
|
|
a3be889872 | ||
|
|
9acb5abf58 | ||
|
|
b9edd05623 | ||
|
|
56a243f958 | ||
|
|
c311806249 | ||
|
|
7990491c58 | ||
|
|
66ada54587 | ||
|
|
a5f9fa006c | ||
|
|
f69e88bf41 | ||
|
|
4e554503c0 | ||
|
|
c00d665aa5 | ||
|
|
cce11bb639 | ||
|
|
7771802038 | ||
|
|
9d7a7dae62 | ||
|
|
e7528f13a2 | ||
|
|
f851e356f2 | ||
|
|
db572a4085 | ||
|
|
f1eee96ebc | ||
|
|
d11fad8a06 | ||
|
|
e961e64497 | ||
|
|
006d544ac5 | ||
|
|
467b35f98d | ||
|
|
eb43234a3a | ||
|
|
5da26e917a | ||
|
|
96b2987590 | ||
|
|
653ed6f096 | ||
|
|
3cb303ff05 | ||
|
|
af6cb31214 | ||
|
|
54ca111c7c | ||
|
|
e2bbfe7d80 | ||
|
|
3b9a5a61b2 | ||
|
|
27ec6139a8 | ||
|
|
fa06d6ea99 | ||
|
|
47435cdfed | ||
|
|
cfb884b118 | ||
|
|
1af0b4f992 | ||
|
|
c36e55f0b7 | ||
|
|
9f6c828cfe | ||
|
|
cf51254d24 | ||
|
|
b0cc5eac95 | ||
|
|
feeb1d19c8 | ||
|
|
920266e1ff | ||
|
|
b81ccc0507 | ||
|
|
f4c523f188 | ||
|
|
cce5ade578 | ||
|
|
3767749398 | ||
|
|
c3ceef0461 | ||
|
|
ac952fbee3 | ||
|
|
72f7c3101d | ||
|
|
ca9627cada | ||
|
|
c8f62cd963 | ||
|
|
9900e46b95 | ||
|
|
c433b38ba8 | ||
|
|
0b501d7a1c | ||
|
|
0e197261b0 | ||
|
|
89bf5c182d | ||
|
|
e80c75482e | ||
|
|
569a131624 | ||
|
|
b1d02c6a7b | ||
|
|
abeafd5625 | ||
|
|
0906d7873a | ||
|
|
0ce3f1ecb0 | ||
|
|
ec6919c5ab | ||
|
|
10de690ccf | ||
|
|
cb3e4b5ed7 | ||
|
|
2b4ac582d4 | ||
|
|
7f60811ebf | ||
|
|
90e94df1e4 | ||
|
|
5b72fc7fdc | ||
|
|
601b739f75 | ||
|
|
a441908db3 | ||
|
|
a376503a7b | ||
|
|
69a4a17848 | ||
|
|
22f9ad31dc | ||
|
|
2a02159355 | ||
|
|
9258098e0c | ||
|
|
fa48fe587d | ||
|
|
7b64bfa173 | ||
|
|
59b62eadf1 | ||
|
|
98912efeb7 | ||
|
|
c7303ea465 | ||
|
|
618b451f1e | ||
|
|
a99f63784d | ||
|
|
6f7c7f3c61 | ||
|
|
4f049b66a2 | ||
|
|
458ff350c8 | ||
|
|
fc8c10bca4 | ||
|
|
28ab86cd4a | ||
|
|
efb6f8afe3 | ||
|
|
a2549f23c7 | ||
|
|
7877c96ffe | ||
|
|
4997d13219 | ||
|
|
c5135afd29 | ||
|
|
8663340067 | ||
|
|
a1ffc521a4 | ||
|
|
f7dce3116a | ||
|
|
f23530afcf | ||
|
|
c5f77de1bf | ||
|
|
c60645d9f4 | ||
|
|
ebbff358a0 | ||
|
|
a39c04d56e | ||
|
|
9e61d1509f | ||
|
|
0c511d08e6 | ||
|
|
929e3a5733 | ||
|
|
75c98d00fc | ||
|
|
7bce5ac4d0 | ||
|
|
e7733d94f9 | ||
|
|
20a3a95df2 | ||
|
|
d2bbf41fb5 | ||
|
|
97ccc6a854 | ||
|
|
b48619878f | ||
|
|
ac6130a556 | ||
|
|
86797d2492 | ||
|
|
112a693f3d | ||
|
|
57701f6145 | ||
|
|
c2145b6f45 | ||
|
|
0b91d53c9f | ||
|
|
b4ccaf4fec | ||
|
|
2d1b2cb1cc | ||
|
|
aa04ab78c1 | ||
|
|
1671437fb3 | ||
|
|
9d49cb0e4b | ||
|
|
43548785b5 | ||
|
|
b6038128cb | ||
|
|
a76159e9a0 | ||
|
|
a0ff78dbdb | ||
|
|
059dfcbebb | ||
|
|
c17d44638e | ||
|
|
0e385c4262 | ||
|
|
da98b1c4cc | ||
|
|
26d2e5de31 | ||
|
|
b0e37036d5 | ||
|
|
b7ddb47d2a | ||
|
|
6eb5ee99d8 | ||
|
|
6409e17d4a | ||
|
|
a6549fb41e | ||
|
|
06d5064b7c | ||
|
|
fd621f485e | ||
|
|
517ae56763 | ||
|
|
ae26390776 | ||
|
|
aa68422e0f | ||
|
|
dac2a2ec86 | ||
|
|
5cee4d828f | ||
|
|
d6247d69ce | ||
|
|
e2fa1e65d1 | ||
|
|
55c04b5533 | ||
|
|
540e785424 | ||
|
|
30a92d40eb | ||
|
|
679405e0a2 | ||
|
|
6cdc524174 | ||
|
|
80a42ebf10 | ||
|
|
c45f03f5e7 | ||
|
|
36c2604639 | ||
|
|
73f50e7e0b | ||
|
|
418fddd669 | ||
|
|
e3cda0e749 | ||
|
|
30465f17e0 | ||
|
|
5206939c78 | ||
|
|
73396784a8 | ||
|
|
3745d8d791 | ||
|
|
735153886f | ||
|
|
d04cc4e272 | ||
|
|
473aa120eb | ||
|
|
7986aebf27 | ||
|
|
2caa87d35d | ||
|
|
dabcd4741b | ||
|
|
e893f3ed03 | ||
|
|
1856479de9 | ||
|
|
fc69129a2f | ||
|
|
d953b8fa46 | ||
|
|
1847db7a47 | ||
|
|
9cbcda20a7 | ||
|
|
0fe0aea242 | ||
|
|
08ae36818a | ||
|
|
da6b4d2872 | ||
|
|
8e293b41f5 | ||
|
|
d2771a7a1d | ||
|
|
13869da300 | ||
|
|
6ba53f3af1 | ||
|
|
b23cebcba4 | ||
|
|
30dfe76577 | ||
|
|
c1f0cfe366 | ||
|
|
d1279fa474 | ||
|
|
a56462fc7c | ||
|
|
646bcf595b | ||
|
|
4796677a0a | ||
|
|
01ac9d9336 | ||
|
|
89a9288154 | ||
|
|
17b7ef18d6 | ||
|
|
1afc584393 | ||
|
|
9d71cdf8ef | ||
|
|
1c7f3e865a | ||
|
|
9ef40189f9 | ||
|
|
5c6eecd660 | ||
|
|
9b07993e1a | ||
|
|
c35d29f31c | ||
|
|
e50d7c5eef | ||
|
|
7c2cce40de | ||
|
|
2017b9016b | ||
|
|
0bf30e7621 | ||
|
|
7f5b685c9f | ||
|
|
ec824579d6 | ||
|
|
9e565154a9 | ||
|
|
541335bb31 | ||
|
|
814c4b8ef0 | ||
|
|
df3fff5dbb | ||
|
|
b0085114d7 | ||
|
|
7f9c70386f | ||
|
|
578870d345 | ||
|
|
05fab91bda | ||
|
|
5191e11650 | ||
|
|
92079a653e | ||
|
|
a7d14eb815 | ||
|
|
9aea6eae70 | ||
|
|
88a42172c5 | ||
|
|
e79013dcfe | ||
|
|
08f3370828 | ||
|
|
c4d3c672ad | ||
|
|
39eaa2e850 | ||
|
|
2d022e4e49 | ||
|
|
1ac6d6529f | ||
|
|
86fec820ac | ||
|
|
030d5845db | ||
|
|
dd1c878fdf | ||
|
|
3942603a38 | ||
|
|
244578db96 | ||
|
|
6b6edfde9f | ||
|
|
c54b675a48 | ||
|
|
b7008dfc5c | ||
|
|
d0ad4af51c | ||
|
|
4a182014e1 | ||
|
|
46cd522384 | ||
|
|
c977667a15 | ||
|
|
d531bc34c4 | ||
|
|
adfbec2744 | ||
|
|
23521559bf | ||
|
|
51f57aba17 | ||
|
|
97bab053df | ||
|
|
c1c5573e7f | ||
|
|
16d2a95760 | ||
|
|
f97b673481 | ||
|
|
c8d5a6f154 | ||
|
|
3708afaf21 | ||
|
|
43c23e526c | ||
|
|
a80eb84df1 | ||
|
|
f89898b3d0 | ||
|
|
af21142602 | ||
|
|
4b91860227 | ||
|
|
e53bafbca6 | ||
|
|
e01c8f06c7 | ||
|
|
c61ed4da37 | ||
|
|
4a4d6d070a | ||
|
|
4bedd873a1 | ||
|
|
f8bd910e63 | ||
|
|
1160231b62 | ||
|
|
a51e27bedf | ||
|
|
abed0656af | ||
|
|
5febda16c7 | ||
|
|
069dc67c30 | ||
|
|
7623810166 | ||
|
|
21fa88461f | ||
|
|
27b0493306 | ||
|
|
ad2c1a0d3e | ||
|
|
f51866d988 | ||
|
|
46627bb44b | ||
|
|
68cadbda9f | ||
|
|
0f2260065a | ||
|
|
4007cc13c2 | ||
|
|
3920210c5c | ||
|
|
4e22bffae2 | ||
|
|
462a131557 | ||
|
|
ec01a04786 | ||
|
|
4c48241e19 | ||
|
|
886c40a69a | ||
|
|
479d1b28c7 | ||
|
|
c41b57128a | ||
|
|
5d178a407d | ||
|
|
73b7606f6e | ||
|
|
94f5031f0d | ||
|
|
c857e7d98c | ||
|
|
d5b8a555d9 | ||
|
|
f34d50da3d | ||
|
|
4f3693e322 | ||
|
|
431ad7d27f | ||
|
|
0c97b09a5a | ||
|
|
bdb9f0d845 | ||
|
|
77b85acdd5 | ||
|
|
8906f5c26e | ||
|
|
81194cc7fe | ||
|
|
f4b972fab5 | ||
|
|
3aa1c03566 | ||
|
|
600b7f93e5 | ||
|
|
2a7df57404 | ||
|
|
6352cd86ee | ||
|
|
0058691579 | ||
|
|
1531bb6d9f | ||
|
|
40245aacf9 | ||
|
|
6e49685f58 | ||
|
|
946823ce6c | ||
|
|
c05f1465db | ||
|
|
88164bdac5 | ||
|
|
fc9e347055 | ||
|
|
6fbf1248f4 | ||
|
|
56848724cd | ||
|
|
26c3eeb942 | ||
|
|
a8f869337e | ||
|
|
7e245ba1cf | ||
|
|
2a93f873b4 | ||
|
|
f8e7058e19 | ||
|
|
8d4e740baa | ||
|
|
3273ee938b | ||
|
|
94f1bc3b38 | ||
|
|
d5ce140eb6 | ||
|
|
b5f0c4bf73 | ||
|
|
545a990365 | ||
|
|
71e4a42cfe | ||
|
|
16b0ebf75a | ||
|
|
eaeb17bdc7 | ||
|
|
239b464957 | ||
|
|
00b6d989ec | ||
|
|
c5f05b1855 | ||
|
|
6fefcaad7b | ||
|
|
22fdfd7f0b | ||
|
|
6842eb05de | ||
|
|
37e7994d55 | ||
|
|
399893bbb2 | ||
|
|
227db065f3 | ||
|
|
b4352bcd8d | ||
|
|
39bab9d9e2 | ||
|
|
c71644f02f | ||
|
|
6aad7ee8b6 | ||
|
|
2b96d831fc | ||
|
|
dde0291add | ||
|
|
8af016ffc1 | ||
|
|
82b4547d7d | ||
|
|
791a25637f | ||
|
|
b922aa5c7c | ||
|
|
cbaebbc9c2 | ||
|
|
86b2e1aa6c | ||
|
|
61c5f05126 | ||
|
|
dde9c3dad5 | ||
|
|
ee5c127146 | ||
|
|
acba6097e0 | ||
|
|
82d00a1bcf | ||
|
|
b9224464c0 | ||
|
|
fba9a03df3 | ||
|
|
2fd624cd3d | ||
|
|
095fe2a175 | ||
|
|
d838777e04 | ||
|
|
7e0d1d441d | ||
|
|
ddab149f16 | ||
|
|
a73fdcd3bd | ||
|
|
d6e0c197bd | ||
|
|
3117d0fdc1 | ||
|
|
96fda64b70 | ||
|
|
e3d2c3a814 | ||
|
|
1a8900de1f | ||
|
|
05ba526388 | ||
|
|
4bc79181ae | ||
|
|
feafbf9cbf | ||
|
|
40f9b881f3 | ||
|
|
8236163fea | ||
|
|
59b555b448 | ||
|
|
71eeee6744 | ||
|
|
1ff6e27d9c | ||
|
|
64ef0f18b1 | ||
|
|
73bdbddf90 | ||
|
|
a55833b3a6 | ||
|
|
43012eb1d1 | ||
|
|
d1e019589d | ||
|
|
7bc79edf3d | ||
|
|
58ad01adfe | ||
|
|
5f1a9659e9 | ||
|
|
6c6c356c78 | ||
|
|
893fd498df | ||
|
|
1ca388457d | ||
|
|
69f0da06f8 | ||
|
|
d9a34872c3 | ||
|
|
31fac3873c | ||
|
|
8dc057517f | ||
|
|
4617e0fb1a | ||
|
|
f8ec87ddea | ||
|
|
c12f059940 | ||
|
|
cc320e0f84 | ||
|
|
acbc38ced4 | ||
|
|
777a6d9ce3 | ||
|
|
7e0b87dd32 | ||
|
|
0161a670cf | ||
|
|
0eba49c536 | ||
|
|
1d9c3f00b7 | ||
|
|
904408de01 | ||
|
|
700336fcc7 | ||
|
|
dd192777b7 | ||
|
|
6b6992591b | ||
|
|
45380f7ca0 | ||
|
|
f0b735f3dd | ||
|
|
9568d63820 | ||
|
|
073638672d | ||
|
|
8ae9210298 | ||
|
|
daf94d74d5 | ||
|
|
14b3d4c766 | ||
|
|
40880dbb59 | ||
|
|
aa4742e394 | ||
|
|
0a7000328a | ||
|
|
da7a49bb5c | ||
|
|
5e4439b905 | ||
|
|
ea0883271e | ||
|
|
3d303c7693 | ||
|
|
9f14edaf2b | ||
|
|
d1738b50d2 | ||
|
|
c560628f1f | ||
|
|
c56533bb23 | ||
|
|
1387d7e627 | ||
|
|
16f2e56d8e | ||
|
|
75ffab2160 | ||
|
|
4c19e1ba3a | ||
|
|
2161ae4e5b | ||
|
|
3148c90e28 | ||
|
|
497b2fba8d | ||
|
|
09d5e29f01 | ||
|
|
56b63ebab5 | ||
|
|
3ba776e6ca | ||
|
|
0a784d9236 | ||
|
|
00df7b428f | ||
|
|
27bacc36d4 | ||
|
|
394df49208 | ||
|
|
38847e1079 | ||
|
|
dd86417177 | ||
|
|
1366c8cb44 | ||
|
|
3a910f25e9 | ||
|
|
cc420b70a5 | ||
|
|
caa3ac2068 | ||
|
|
8baaf380dc | ||
|
|
d719a4e0fb | ||
|
|
d254559e20 | ||
|
|
d701758663 | ||
|
|
a11b78d1c3 | ||
|
|
dfb695be72 | ||
|
|
2974b9257a | ||
|
|
d11d07334b | ||
|
|
0c8fe41b84 | ||
|
|
ed0592d6e0 | ||
|
|
94f4147f92 | ||
|
|
67ee8726ef | ||
|
|
e48c78541c | ||
|
|
bf7a9bf5eb | ||
|
|
3fb2d423ba | ||
|
|
74f7311585 | ||
|
|
97c38583e9 | ||
|
|
324eff93fd | ||
|
|
4b1104f52c | ||
|
|
d702fc81a2 | ||
|
|
2a94ab4423 | ||
|
|
37f6c89383 | ||
|
|
35fab0bef3 | ||
|
|
795e932b8f | ||
|
|
8dddffe840 | ||
|
|
1f91a88d7b | ||
|
|
10f43be911 | ||
|
|
87517daf1f | ||
|
|
739ebd3d04 | ||
|
|
4582c71583 | ||
|
|
757f0ced81 | ||
|
|
a471a3f302 | ||
|
|
5a3a8d32ab | ||
|
|
44b109a449 | ||
|
|
229896a4b7 | ||
|
|
997b5ee819 | ||
|
|
ba99eca700 | ||
|
|
82c369322d | ||
|
|
546c5dabc8 | ||
|
|
7d450adf93 | ||
|
|
eed92864f2 | ||
|
|
8fd7852740 | ||
|
|
2a927bb9ea | ||
|
|
c566491ac7 | ||
|
|
7729611a2a | ||
|
|
8861492655 | ||
|
|
fa9d944b32 | ||
|
|
880437f3c0 | ||
|
|
c1e960c83c | ||
|
|
571386c061 | ||
|
|
44d886a18b | ||
|
|
92ac403679 | ||
|
|
dc3dab4e1c | ||
|
|
386594554e | ||
|
|
02a951ad58 | ||
|
|
92f0f4a21c | ||
|
|
645897f8b8 | ||
|
|
ef4179a06c | ||
|
|
a3e4af40c1 | ||
|
|
1dedce5ec6 | ||
|
|
3a4b36fb31 | ||
|
|
25457d31d4 | ||
|
|
4f9dc830b6 | ||
|
|
12d421b42c | ||
|
|
16ebfd6171 | ||
|
|
59c999324e | ||
|
|
624bcc75ab | ||
|
|
54e833502a | ||
|
|
c3242711c7 | ||
|
|
17391e4aad | ||
|
|
48b840a88d | ||
|
|
377fed584f | ||
|
|
14a6687cc9 | ||
|
|
d142893244 | ||
|
|
957a767ed0 | ||
|
|
3553c8e0d4 | ||
|
|
05221f7961 | ||
|
|
d113072a64 | ||
|
|
afa619b7df | ||
|
|
e25bbc19cb | ||
|
|
0251bc9e6c | ||
|
|
7a3f20c57d | ||
|
|
269fc7c8c9 | ||
|
|
db08f74d6a | ||
|
|
5db757ade2 | ||
|
|
59c03d2de5 | ||
|
|
b655c5544d | ||
|
|
9388ee0705 | ||
|
|
f228ec29eb | ||
|
|
7239e94092 | ||
|
|
e33a5f7736 | ||
|
|
c1c990e6f3 | ||
|
|
a890756868 | ||
|
|
015ee2df15 | ||
|
|
c3b2697568 | ||
|
|
dfcabd2834 | ||
|
|
634196cbd6 | ||
|
|
5611e90fda | ||
|
|
c23d95f8f9 | ||
|
|
c2377b62ac | ||
|
|
f328d4cd81 | ||
|
|
fbc1482b90 | ||
|
|
90e07af4f5 | ||
|
|
014c3f3172 | ||
|
|
419009424b | ||
|
|
f599c9bcb8 | ||
|
|
6c696cddb9 | ||
|
|
d787c21f8b | ||
|
|
f96f08be32 | ||
|
|
8ebb51b9a3 | ||
|
|
60e1b82df6 | ||
|
|
459afa158c | ||
|
|
1c3d3b33f6 | ||
|
|
f64365915b | ||
|
|
ec8e6f79b3 | ||
|
|
b89f467983 | ||
|
|
009dbcf8c7 | ||
|
|
4413fd248c | ||
|
|
8962597e69 | ||
|
|
f4d4111fbd | ||
|
|
babac5a4a9 | ||
|
|
f71595fcc9 | ||
|
|
59a5f5f5d0 | ||
|
|
2d5faa7f3d | ||
|
|
32fa950aa1 | ||
|
|
82112c2c6e | ||
|
|
f94bdc358b | ||
|
|
f6466d7062 | ||
|
|
1c5fd2465e | ||
|
|
e99329cff5 | ||
|
|
fabcbaec82 | ||
|
|
c8f50509ed | ||
|
|
165604bb80 | ||
|
|
829bce1c8c | ||
|
|
c3b82165fa | ||
|
|
d673a521d8 | ||
|
|
ee88a79bc3 | ||
|
|
5f3afa3776 | ||
|
|
3cafc10c2b | ||
|
|
2cb1cea196 | ||
|
|
482da21ba7 | ||
|
|
bf80340310 | ||
|
|
5ef15c0daf | ||
|
|
62be958d47 | ||
|
|
1ba236bbce | ||
|
|
a4e08f60fe | ||
|
|
5ba1d1a3f7 | ||
|
|
58dd15a662 | ||
|
|
50a6ee27a0 | ||
|
|
23952d9751 | ||
|
|
2b26514190 | ||
|
|
f8343d0f93 | ||
|
|
cc17bee945 | ||
|
|
ff1ca268a4 | ||
|
|
99c948f578 | ||
|
|
d68a1116dc | ||
|
|
dee1ec1a2a | ||
|
|
9cbfc9856b | ||
|
|
a95a6f9b47 | ||
|
|
c83ce863d7 | ||
|
|
05aa78372b | ||
|
|
38e3dcbaeb | ||
|
|
cfa763962e | ||
|
|
8c156cc651 | ||
|
|
c7aabecc0e | ||
|
|
defacf3398 | ||
|
|
7f2920644e | ||
|
|
c92ff79231 | ||
|
|
3c70c1e463 | ||
|
|
1b3cc4de1a | ||
|
|
b97331cbab | ||
|
|
b7287dbb22 | ||
|
|
2c90735bb1 | ||
|
|
4d5fbeff45 | ||
|
|
ad55722662 | ||
|
|
9c118c8e37 | ||
|
|
267660a661 | ||
|
|
f2017291d6 | ||
|
|
4cc69544b5 | ||
|
|
2649d72d3f | ||
|
|
a852b8e6e1 | ||
|
|
6deb994235 | ||
|
|
b30d285025 | ||
|
|
18476d28dc | ||
|
|
57a4cb9036 | ||
|
|
ebc71b0e46 | ||
|
|
39d68bcdc4 | ||
|
|
e20126a254 | ||
|
|
416fd0aed6 | ||
|
|
661b8081c1 | ||
|
|
64b5f4e7d5 | ||
|
|
1775d43d90 | ||
|
|
142882a8ff | ||
|
|
65cad74eba | ||
|
|
77aaa38a92 | ||
|
|
ea3d8cf728 | ||
|
|
b3a624a572 | ||
|
|
a737be7e16 | ||
|
|
aca2194892 | ||
|
|
3a2b2f9e15 | ||
|
|
a19f713c57 | ||
|
|
8b2ef3c352 | ||
|
|
861bcabd66 | ||
|
|
cc2b64df52 | ||
|
|
a7a0035b0e | ||
|
|
31b1aeeb69 | ||
|
|
3f10fd53bd | ||
|
|
0194d76722 | ||
|
|
98a0291bbd | ||
|
|
f9fc36f0ed | ||
|
|
a2bd2a9bae | ||
|
|
c42222cf0d | ||
|
|
271a4979b7 | ||
|
|
1c980397b8 | ||
|
|
5d957a05b9 | ||
|
|
f75f774ddb | ||
|
|
224c0080ee | ||
|
|
6ea5fea1a7 | ||
|
|
04e1344676 | ||
|
|
0117964ca5 | ||
|
|
9d110d39b2 | ||
|
|
8d7693e5ad | ||
|
|
ec9a30d269 | ||
|
|
56fc2dd753 | ||
|
|
9050591ff9 | ||
|
|
0cf21b190c | ||
|
|
2531ec178e | ||
|
|
81119acaf2 | ||
|
|
05f999903d | ||
|
|
66c02d1e3a | ||
|
|
a05df99a8a | ||
|
|
e200e2f89c | ||
|
|
cdaa0bda5b | ||
|
|
a41f3b1ac6 | ||
|
|
3585cb69f5 | ||
|
|
a53f0ba4db | ||
|
|
7300f6edc2 | ||
|
|
1126eaa346 | ||
|
|
7e5d82d0e8 | ||
|
|
2b2b1cdb85 | ||
|
|
0b7c1609fd | ||
|
|
7760f91a56 | ||
|
|
58d8ab40c4 | ||
|
|
4ab3aa9a39 | ||
|
|
9199639320 | ||
|
|
5ee0fd3519 | ||
|
|
59976ea357 | ||
|
|
62bddded37 | ||
|
|
35a7c81fd8 | ||
|
|
30469a6d88 | ||
|
|
d2d645f74b | ||
|
|
4e5bcd6a3b | ||
|
|
6a8287e192 | ||
|
|
7b344d5629 | ||
|
|
35579e644e | ||
|
|
1bc78a716e | ||
|
|
0d28c108d2 | ||
|
|
6a158d46b8 | ||
|
|
bf7652227a | ||
|
|
2aaee5c331 | ||
|
|
fa2884f9b2 | ||
|
|
9c7ea5bd87 | ||
|
|
5e51ae37cf | ||
|
|
4fa3a38f98 | ||
|
|
d735513e60 | ||
|
|
38c2ec7532 | ||
|
|
f4d4cc3439 | ||
|
|
4ae066c57d | ||
|
|
2d1ff64951 | ||
|
|
73a7f7dae0 | ||
|
|
cf6367b649 | ||
|
|
84fc0e9205 | ||
|
|
941f71faea | ||
|
|
da651eee6f | ||
|
|
eed00f97f9 | ||
|
|
2387a5e9bd | ||
|
|
6b9c1b70ba | ||
|
|
b21c0f59f9 | ||
|
|
5d8e8a2486 | ||
|
|
423df92ff8 | ||
|
|
04a950d7f5 | ||
|
|
65560604a8 | ||
|
|
78dea484c9 | ||
|
|
6a3dbe08de | ||
|
|
9aa976fdf0 | ||
|
|
39eeda8430 | ||
|
|
2878952b1d | ||
|
|
223a1f677b | ||
|
|
7b4b40db5b | ||
|
|
1052603a17 | ||
|
|
4ee1b23e9b | ||
|
|
326e0748c0 | ||
|
|
ea0f74a9f6 | ||
|
|
cdaac0d9bb | ||
|
|
f749734863 | ||
|
|
a15c4d1612 | ||
|
|
290bf52fc5 | ||
|
|
529e889d0e | ||
|
|
5a5a69de17 | ||
|
|
194549a4b0 | ||
|
|
4052fc55f3 | ||
|
|
82d03b5c1b | ||
|
|
c7f123766e | ||
|
|
88acabb355 | ||
|
|
e5f1eb8609 | ||
|
|
eb7ab0860d | ||
|
|
9ed3545b95 | ||
|
|
d223f3865b | ||
|
|
4538db86cf | ||
|
|
3931cae044 | ||
|
|
810a63f808 | ||
|
|
609984d400 | ||
|
|
a57c958058 | ||
|
|
b6dbe8f07b | ||
|
|
29d69338ef | ||
|
|
98de010811 | ||
|
|
63302a6634 | ||
|
|
8568e037bf | ||
|
|
6c4143ca94 | ||
|
|
efa2fa269d | ||
|
|
a2cf6a7be2 | ||
|
|
e493473c35 | ||
|
|
415a2e7fa5 | ||
|
|
ba9a3b4a9b | ||
|
|
174c52958f | ||
|
|
4e41db2d6a | ||
|
|
e8daebdc0c | ||
|
|
582acd7bd1 | ||
|
|
48fe14e263 | ||
|
|
f9fd0f59ff | ||
|
|
3fe4b4b856 | ||
|
|
c510b344af | ||
|
|
980dd285ad | ||
|
|
2b60244e4a | ||
|
|
45a866f194 | ||
|
|
091b8a74fb | ||
|
|
74fa4a2c2d | ||
|
|
327b67a022 | ||
|
|
d0a4db5f4f | ||
|
|
861eaa155f | ||
|
|
3550e7f7f1 | ||
|
|
7d25d976d1 | ||
|
|
7025e321de | ||
|
|
429fa75fcc | ||
|
|
347563adf9 |
43
.cursorrules
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// Vue 3 Composition API .cursorrules
|
||||||
|
|
||||||
|
// Vue 3 Composition API best practices
|
||||||
|
const vue3CompositionApiBestPractices = [
|
||||||
|
"Use setup() function for component logic",
|
||||||
|
"Utilize ref and reactive for reactive state",
|
||||||
|
"Implement computed properties with computed()",
|
||||||
|
"Use watch and watchEffect for side effects",
|
||||||
|
"Implement lifecycle hooks with onMounted, onUpdated, etc.",
|
||||||
|
"Utilize provide/inject for dependency injection",
|
||||||
|
]
|
||||||
|
|
||||||
|
// Folder structure
|
||||||
|
const folderStructure = `
|
||||||
|
src/
|
||||||
|
components/
|
||||||
|
constants/
|
||||||
|
hooks/
|
||||||
|
views/
|
||||||
|
stores/
|
||||||
|
services/
|
||||||
|
App.vue
|
||||||
|
main.ts
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Tailwind CSS best practices
|
||||||
|
const tailwindCssBestPractices = [
|
||||||
|
"Use Tailwind CSS for styling",
|
||||||
|
"Implement responsive design with Tailwind CSS",
|
||||||
|
]
|
||||||
|
|
||||||
|
// Additional instructions
|
||||||
|
const additionalInstructions = `
|
||||||
|
1. Leverage VueUse functions for performance-enhancing styles
|
||||||
|
2. Use lodash for utility functions
|
||||||
|
3. Use TypeScript for type safety
|
||||||
|
4. Implement proper props and emits definitions
|
||||||
|
5. Utilize Vue 3's Teleport component when needed
|
||||||
|
6. Use Suspense for async components
|
||||||
|
7. Implement proper error handling
|
||||||
|
8. Follow Vue 3 style guide and naming conventions
|
||||||
|
9. Use Vite for fast development and building
|
||||||
|
`;
|
||||||
11
.env_example
@@ -6,14 +6,23 @@ PLAYWRIGHT_TEST_URL=http://localhost:5173
|
|||||||
# Note: localhost:8188 does not work.
|
# Note: localhost:8188 does not work.
|
||||||
DEV_SERVER_COMFYUI_URL=http://127.0.0.1:8188
|
DEV_SERVER_COMFYUI_URL=http://127.0.0.1:8188
|
||||||
|
|
||||||
|
# Allow dev server access from remote IP addresses.
|
||||||
|
# If true, the vite dev server will listen on all addresses, including LAN
|
||||||
|
# and public addresses.
|
||||||
|
VITE_REMOTE_DEV=false
|
||||||
|
|
||||||
# The target ComfyUI checkout directory to deploy the frontend code to.
|
# The target ComfyUI checkout directory to deploy the frontend code to.
|
||||||
# The dist directory will be copied to {DEPLOY_COMFYUI_DIR}/custom_web_versions/main/dev
|
# The dist directory will be copied to {DEPLOY_COMFYUI_DIR}/custom_web_versions/main/dev
|
||||||
# Add `--front-end-root {DEPLOY_COMFYUI_DIR}/custom_web_versions/main/dev`
|
# Add `--front-end-root {DEPLOY_COMFYUI_DIR}/custom_web_versions/main/dev`
|
||||||
# to ComfyUI launch script to serve the custom web version.
|
# to ComfyUI launch script to serve the custom web version.
|
||||||
DEPLOY_COMFYUI_DIR=/home/ComfyUI/web
|
DEPLOY_COMFYUI_DIR=/home/ComfyUI/web
|
||||||
|
|
||||||
|
# The directory containing the ComfyUI installation used to run Playwright tests.
|
||||||
|
# If you aren't using a separate install for testing, point this to your regular install.
|
||||||
|
TEST_COMFYUI_DIR=/home/ComfyUI
|
||||||
|
|
||||||
# The directory containing the ComfyUI_examples repo used to extract test workflows.
|
# The directory containing the ComfyUI_examples repo used to extract test workflows.
|
||||||
EXAMPLE_REPO_PATH=tests-ui/ComfyUI_examples
|
EXAMPLE_REPO_PATH=tests-ui/ComfyUI_examples
|
||||||
|
|
||||||
# Whether to enable minification of the frontend code.
|
# Whether to enable minification of the frontend code.
|
||||||
ENABLE_MINIFY=true
|
ENABLE_MINIFY=true
|
||||||
|
|||||||
6
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
@@ -50,6 +50,12 @@ body:
|
|||||||
description: 'Please copy the output from your browser logs here. You can access this by pressing F12 to toggle the developer tools, then navigating to the Console tab.'
|
description: 'Please copy the output from your browser logs here. You can access this by pressing F12 to toggle the developer tools, then navigating to the Console tab.'
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Setting JSON
|
||||||
|
description: 'Please upload the setting file here. The setting file is located at `user/default/comfy.settings.json`'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: browsers
|
id: browsers
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
2
.github/workflows/format.yaml
vendored
@@ -20,4 +20,4 @@ jobs:
|
|||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Run Prettier check
|
- name: Run Prettier check
|
||||||
run: npx prettier --check './**/*.{js,ts,tsx,vue}'
|
run: npm run format:check
|
||||||
162
.github/workflows/i18n-custom-nodes.yaml
vendored
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
name: Update Locales for given custom node repository
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
owner:
|
||||||
|
description: 'Owner of the repository to update locales for'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
repository:
|
||||||
|
description: 'Repository to update locales for'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
fork_owner:
|
||||||
|
description: 'Owner of the forked repository'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: 'Comfy-Org'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-locales:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout ComfyUI
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: 'comfyanonymous/ComfyUI'
|
||||||
|
path: 'ComfyUI'
|
||||||
|
ref: master
|
||||||
|
- name: Checkout ComfyUI_frontend
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: 'Comfy-Org/ComfyUI_frontend'
|
||||||
|
path: 'ComfyUI_frontend'
|
||||||
|
- name: Checkout ComfyUI_devtools
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: 'Comfy-Org/ComfyUI_devtools'
|
||||||
|
path: 'ComfyUI/custom_nodes/ComfyUI_devtools'
|
||||||
|
- name: Checkout custom node repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: ${{ inputs.owner }}/${{ inputs.repository }}
|
||||||
|
path: 'ComfyUI/custom_nodes/${{ inputs.repository }}'
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: lts/*
|
||||||
|
- uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: '3.10'
|
||||||
|
- name: Install ComfyUI requirements
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pip install wait-for-it
|
||||||
|
shell: bash
|
||||||
|
working-directory: ComfyUI
|
||||||
|
- name: Install custom node requirements
|
||||||
|
run: |
|
||||||
|
if [ -f "requirements.txt" ]; then
|
||||||
|
pip install -r requirements.txt
|
||||||
|
fi
|
||||||
|
shell: bash
|
||||||
|
working-directory: ComfyUI/custom_nodes/${{ inputs.repository }}
|
||||||
|
- name: Build & Install ComfyUI_frontend
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
rm -rf ../ComfyUI/web/*
|
||||||
|
mv dist/* ../ComfyUI/web/
|
||||||
|
shell: bash
|
||||||
|
working-directory: ComfyUI_frontend
|
||||||
|
- name: Start ComfyUI server
|
||||||
|
run: |
|
||||||
|
python main.py --cpu --multi-user &
|
||||||
|
wait-for-it --service 127.0.0.1:8188 -t 600
|
||||||
|
working-directory: ComfyUI
|
||||||
|
shell: bash
|
||||||
|
- name: Install Playwright Browsers
|
||||||
|
run: npx playwright install chromium --with-deps
|
||||||
|
working-directory: ComfyUI_frontend
|
||||||
|
- name: Start dev server
|
||||||
|
# Run electron dev server as it is a superset of the web dev server
|
||||||
|
# We do want electron specific UIs to be translated.
|
||||||
|
run: npm run dev:electron &
|
||||||
|
working-directory: ComfyUI_frontend
|
||||||
|
- name: Capture base i18n
|
||||||
|
run: npx tsx scripts/diff-i18n capture
|
||||||
|
working-directory: ComfyUI_frontend
|
||||||
|
- name: Update en.json
|
||||||
|
run: npm run collect-i18n
|
||||||
|
env:
|
||||||
|
PLAYWRIGHT_TEST_URL: http://localhost:5173
|
||||||
|
working-directory: ComfyUI_frontend
|
||||||
|
- name: Update translations
|
||||||
|
run: npm run locale
|
||||||
|
env:
|
||||||
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
working-directory: ComfyUI_frontend
|
||||||
|
- name: Diff base vs updated i18n
|
||||||
|
run: npx tsx scripts/diff-i18n diff
|
||||||
|
working-directory: ComfyUI_frontend
|
||||||
|
- name: Update i18n in custom node repository
|
||||||
|
run: |
|
||||||
|
LOCALE_DIR=ComfyUI/custom_nodes/${{ inputs.repository }}/locales/
|
||||||
|
install -d "$LOCALE_DIR"
|
||||||
|
cp -rf ComfyUI_frontend/temp/diff/* "$LOCALE_DIR"
|
||||||
|
- name: Check and create fork of custom node repository
|
||||||
|
run: |
|
||||||
|
# Try to fork the repository
|
||||||
|
gh repo fork ${{ inputs.owner }}/${{ inputs.repository }} --clone=false || {
|
||||||
|
echo "Fork failed - repository might already be forked"
|
||||||
|
# Exit 0 to prevent the workflow from failing
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Enable workflows on the forked repository
|
||||||
|
gh api \
|
||||||
|
--method PUT \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
"/repos/${{ inputs.fork_owner }}/${{ inputs.repository }}/actions/permissions/workflow" \
|
||||||
|
-F can_approve_pull_request_reviews=true \
|
||||||
|
-F default_workflow_permissions="write" \
|
||||||
|
-F enabled=true
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.PR_GH_TOKEN }}
|
||||||
|
|
||||||
|
- name: Commit changes
|
||||||
|
working-directory: ComfyUI/custom_nodes/${{ inputs.repository }}
|
||||||
|
run: |
|
||||||
|
git config --global user.name 'github-actions'
|
||||||
|
git config --global user.email 'github-actions@github.com'
|
||||||
|
|
||||||
|
# Create and switch to new branch
|
||||||
|
git checkout -b update-locales
|
||||||
|
|
||||||
|
# Stage and commit changes
|
||||||
|
git add -A
|
||||||
|
git commit -m "Update locales"
|
||||||
|
|
||||||
|
- name: Install SSH key For PUSH
|
||||||
|
uses: shimataro/ssh-key-action@v2
|
||||||
|
with:
|
||||||
|
# PR private key from action server
|
||||||
|
key: ${{ secrets.PR_SSH_PRIVATE_KEY }}
|
||||||
|
# github public key to confirm it's github server
|
||||||
|
known_hosts: github.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==
|
||||||
|
|
||||||
|
- name: Push changes
|
||||||
|
working-directory: ComfyUI/custom_nodes/${{ inputs.repository }}
|
||||||
|
run: |
|
||||||
|
# Force push to create the branch
|
||||||
|
echo "Pushing changes to ${{ inputs.fork_owner }}/${{ inputs.repository }}"
|
||||||
|
git push -f git@github.com:${{ inputs.fork_owner }}/${{ inputs.repository }}.git update-locales
|
||||||
|
|
||||||
|
- name: Create PR
|
||||||
|
working-directory: ComfyUI/custom_nodes/${{ inputs.repository }}
|
||||||
|
run: |
|
||||||
|
# Create PR using gh cli
|
||||||
|
gh pr create --title "Update locales for ${{ inputs.repository }}" --repo ${{ inputs.owner }}/${{ inputs.repository }} --head ${{ inputs.fork_owner }}:update-locales --body "Update locales for ${{ inputs.repository }}"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.PR_GH_TOKEN }}
|
||||||
48
.github/workflows/i18n-node-defs.yaml
vendored
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
name: Update Node Definitions Locales
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
trigger_type:
|
||||||
|
description: 'Type of trigger (manual or automatic)'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: 'manual'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-locales:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.1
|
||||||
|
- name: Install Playwright Browsers
|
||||||
|
run: npx playwright install chromium --with-deps
|
||||||
|
working-directory: ComfyUI_frontend
|
||||||
|
- name: Start dev server
|
||||||
|
# Run electron dev server as it is a superset of the web dev server
|
||||||
|
# We do want electron specific UIs to be translated.
|
||||||
|
run: npm run dev:electron &
|
||||||
|
working-directory: ComfyUI_frontend
|
||||||
|
- name: Update en.json
|
||||||
|
run: npm run collect-i18n -- scripts/collect-i18n-node-defs.ts
|
||||||
|
env:
|
||||||
|
PLAYWRIGHT_TEST_URL: http://localhost:5173
|
||||||
|
working-directory: ComfyUI_frontend
|
||||||
|
- name: Update translations
|
||||||
|
run: npm run locale
|
||||||
|
env:
|
||||||
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
working-directory: ComfyUI_frontend
|
||||||
|
- name: Create Pull Request
|
||||||
|
uses: peter-evans/create-pull-request@v7
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.PR_GH_TOKEN }}
|
||||||
|
commit-message: "Update locales for node definitions"
|
||||||
|
title: "Update locales for node definitions"
|
||||||
|
body: |
|
||||||
|
Automated PR to update locales for node definitions
|
||||||
|
|
||||||
|
This PR was created automatically by the frontend update workflow.
|
||||||
|
branch: update-locales-node-defs-${{ github.event.inputs.trigger_type }}-${{ github.run_id }}
|
||||||
|
base: main
|
||||||
|
labels: dependencies
|
||||||
|
path: ComfyUI_frontend
|
||||||
40
.github/workflows/i18n.yaml
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
name: Update Locales
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [ main, master, dev* ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-locales:
|
||||||
|
# Don't run on fork PRs
|
||||||
|
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.1
|
||||||
|
- name: Install Playwright Browsers
|
||||||
|
run: npx playwright install chromium --with-deps
|
||||||
|
working-directory: ComfyUI_frontend
|
||||||
|
- name: Start dev server
|
||||||
|
# Run electron dev server as it is a superset of the web dev server
|
||||||
|
# We do want electron specific UIs to be translated.
|
||||||
|
run: npm run dev:electron &
|
||||||
|
working-directory: ComfyUI_frontend
|
||||||
|
- name: Update en.json
|
||||||
|
run: npm run collect-i18n -- scripts/collect-i18n-general.ts
|
||||||
|
env:
|
||||||
|
PLAYWRIGHT_TEST_URL: http://localhost:5173
|
||||||
|
working-directory: ComfyUI_frontend
|
||||||
|
- name: Update translations
|
||||||
|
run: npm run locale
|
||||||
|
env:
|
||||||
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
working-directory: ComfyUI_frontend
|
||||||
|
- name: Commit updated locales
|
||||||
|
run: |
|
||||||
|
git config --global user.name 'github-actions'
|
||||||
|
git config --global user.email 'github-actions@github.com'
|
||||||
|
git fetch origin ${{ github.head_ref }}
|
||||||
|
git checkout -B ${{ github.head_ref }} origin/${{ github.head_ref }}
|
||||||
|
git add src/locales/
|
||||||
|
git diff --staged --quiet || git commit -m "Update locales [skip ci]"
|
||||||
|
git push origin HEAD:${{ github.head_ref }}
|
||||||
|
working-directory: ComfyUI_frontend
|
||||||
20
.github/workflows/release.yaml
vendored
@@ -25,6 +25,8 @@ jobs:
|
|||||||
id: current_version
|
id: current_version
|
||||||
run: echo ::set-output name=version::$(node -p "require('./package.json').version")
|
run: echo ::set-output name=version::$(node -p "require('./package.json').version")
|
||||||
- name: Build project
|
- name: Build project
|
||||||
|
env:
|
||||||
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||||
run: |
|
run: |
|
||||||
npm ci
|
npm ci
|
||||||
npm run build
|
npm run build
|
||||||
@@ -41,3 +43,21 @@ jobs:
|
|||||||
draft: true
|
draft: true
|
||||||
prerelease: false
|
prerelease: false
|
||||||
make_latest: "true"
|
make_latest: "true"
|
||||||
|
publish_types:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: >
|
||||||
|
github.event.pull_request.merged == true &&
|
||||||
|
contains(github.event.pull_request.labels.*.name, 'Release')
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: lts/*
|
||||||
|
registry-url: "https://registry.npmjs.org"
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run build:types
|
||||||
|
- name: Publish package
|
||||||
|
run: npm publish --access public
|
||||||
|
working-directory: ./dist
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
|||||||
3
.github/workflows/test-browser-exp.yaml
vendored
@@ -5,14 +5,13 @@ name: Update Playwright Expectations
|
|||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [ labeled ]
|
types: [ labeled ]
|
||||||
branches: [ main, master ]
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event.label.name == 'New Browser Test Expectations'
|
if: github.event.label.name == 'New Browser Test Expectations'
|
||||||
steps:
|
steps:
|
||||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v1
|
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.1
|
||||||
- name: Install Playwright Browsers
|
- name: Install Playwright Browsers
|
||||||
run: npx playwright install chromium --with-deps
|
run: npx playwright install chromium --with-deps
|
||||||
working-directory: ComfyUI_frontend
|
working-directory: ComfyUI_frontend
|
||||||
|
|||||||
11
.github/workflows/test-ui.yaml
vendored
@@ -15,18 +15,17 @@ jobs:
|
|||||||
jest-tests:
|
jest-tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v1
|
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.1
|
||||||
- name: Run Jest tests
|
- name: Run Jest tests
|
||||||
run: |
|
run: |
|
||||||
npm run test:generate
|
npm run test:generate
|
||||||
npm run test:generate:examples
|
npm run test:jest -- --verbose
|
||||||
npm test -- --verbose
|
|
||||||
working-directory: ComfyUI_frontend
|
working-directory: ComfyUI_frontend
|
||||||
|
|
||||||
playwright-tests-chromium:
|
playwright-tests-chromium:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v1
|
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.1
|
||||||
- name: Install Playwright Browsers
|
- name: Install Playwright Browsers
|
||||||
run: npx playwright install chromium --with-deps
|
run: npx playwright install chromium --with-deps
|
||||||
working-directory: ComfyUI_frontend
|
working-directory: ComfyUI_frontend
|
||||||
@@ -43,7 +42,7 @@ jobs:
|
|||||||
playwright-tests-chromium-2x:
|
playwright-tests-chromium-2x:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v1
|
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.1
|
||||||
- name: Install Playwright Browsers
|
- name: Install Playwright Browsers
|
||||||
run: npx playwright install chromium --with-deps
|
run: npx playwright install chromium --with-deps
|
||||||
working-directory: ComfyUI_frontend
|
working-directory: ComfyUI_frontend
|
||||||
@@ -60,7 +59,7 @@ jobs:
|
|||||||
playwright-tests-mobile-chrome:
|
playwright-tests-mobile-chrome:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v1
|
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.1
|
||||||
- name: Install Playwright Browsers
|
- name: Install Playwright Browsers
|
||||||
run: npx playwright install chromium --with-deps
|
run: npx playwright install chromium --with-deps
|
||||||
working-directory: ComfyUI_frontend
|
working-directory: ComfyUI_frontend
|
||||||
|
|||||||
53
.github/workflows/update-main.yaml
vendored
@@ -1,53 +0,0 @@
|
|||||||
name: Update Main Repo from PR
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [labeled]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
update-main-repo:
|
|
||||||
if: github.event.label.name == 'Update Main Repo'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout frontend repo PR
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: lts/*
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Build project
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Checkout ComfyUI
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
repository: "comfyanonymous/ComfyUI"
|
|
||||||
path: ComfyUI
|
|
||||||
ref: master
|
|
||||||
|
|
||||||
- name: Copy compiled assets
|
|
||||||
run: |
|
|
||||||
rm -rf ./ComfyUI/web/*
|
|
||||||
cp -R dist/* ./ComfyUI/web/
|
|
||||||
|
|
||||||
- name: Create Pull Request
|
|
||||||
uses: peter-evans/create-pull-request@v5
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.PAT }}
|
|
||||||
commit-message: 'Update frontend assets from PR #${{ github.event.pull_request.number }}'
|
|
||||||
title: 'Update frontend assets from PR #${{ github.event.pull_request.number }}'
|
|
||||||
body: |
|
|
||||||
This PR updates the compiled frontend assets from PR #${{ github.event.pull_request.number }} in the frontend repo.
|
|
||||||
|
|
||||||
Frontend PR: ${{ github.event.pull_request.html_url }}
|
|
||||||
branch: update-frontend-assets-pr-${{ github.event.pull_request.number }}
|
|
||||||
base: main
|
|
||||||
path: ComfyUI
|
|
||||||
9
.gitignore
vendored
@@ -16,6 +16,8 @@ dist-ssr
|
|||||||
.vscode/*
|
.vscode/*
|
||||||
*.code-workspace
|
*.code-workspace
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
!.vscode/tailwind.json
|
||||||
|
!.vscode/settings.json.default
|
||||||
.idea
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.suo
|
*.suo
|
||||||
@@ -39,4 +41,9 @@ browser_tests/*/*-win32.png
|
|||||||
|
|
||||||
.env
|
.env
|
||||||
|
|
||||||
dist.zip
|
dist.zip
|
||||||
|
|
||||||
|
/temp/
|
||||||
|
|
||||||
|
# Generated JSON Schemas
|
||||||
|
/schemas/
|
||||||
|
|||||||
@@ -1 +1,5 @@
|
|||||||
npx lint-staged
|
if [[ "$OS" == "Windows_NT" ]]; then
|
||||||
|
npx.cmd lint-staged
|
||||||
|
else
|
||||||
|
npx lint-staged
|
||||||
|
fi
|
||||||
|
|||||||
17
.i18nrc.cjs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// This file is intentionally kept in CommonJS format (.cjs)
|
||||||
|
// to resolve compatibility issues with dependencies that require CommonJS.
|
||||||
|
// Do not convert this file to ESModule format unless all dependencies support it.
|
||||||
|
const { defineConfig } = require('@lobehub/i18n-cli');
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
modelName: 'gpt-4',
|
||||||
|
splitToken: 1024,
|
||||||
|
entry: 'src/locales/en',
|
||||||
|
entryLocale: 'en',
|
||||||
|
output: 'src/locales',
|
||||||
|
outputLocales: ['zh', 'ru', 'ja', 'ko', 'fr'],
|
||||||
|
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade.
|
||||||
|
'latent' is the short form of 'latent space'.
|
||||||
|
'mask' is in the context of image processing.
|
||||||
|
`
|
||||||
|
});
|
||||||
16
.prettierrc
@@ -2,5 +2,17 @@
|
|||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"semi": false,
|
"semi": false,
|
||||||
"trailingComma": "none"
|
"trailingComma": "none",
|
||||||
}
|
"printWidth": 80,
|
||||||
|
"importOrder": ["^@core/(.*)$", "<THIRD_PARTY_MODULES>", "^@/(.*)$", "^[./]"],
|
||||||
|
"importOrderSeparation": true,
|
||||||
|
"importOrderSortSpecifiers": true,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.{js,cjs,mjs,ts,cts,mts,tsx,vue}",
|
||||||
|
"options": {
|
||||||
|
"plugins": ["@trivago/prettier-plugin-sort-imports"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
5
.vscode/settings.json.default
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"css.customData": [
|
||||||
|
".vscode/tailwind.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
55
.vscode/tailwind.json
vendored
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"version": 1.1,
|
||||||
|
"atDirectives": [
|
||||||
|
{
|
||||||
|
"name": "@tailwind",
|
||||||
|
"description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.",
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"name": "Tailwind Documentation",
|
||||||
|
"url": "https://tailwindcss.com/docs/functions-and-directives#tailwind"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "@apply",
|
||||||
|
"description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that you’d like to extract to a new component.",
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"name": "Tailwind Documentation",
|
||||||
|
"url": "https://tailwindcss.com/docs/functions-and-directives#apply"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "@responsive",
|
||||||
|
"description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n",
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"name": "Tailwind Documentation",
|
||||||
|
"url": "https://tailwindcss.com/docs/functions-and-directives#responsive"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "@screen",
|
||||||
|
"description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n",
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"name": "Tailwind Documentation",
|
||||||
|
"url": "https://tailwindcss.com/docs/functions-and-directives#screen"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "@variants",
|
||||||
|
"description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n",
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"name": "Tailwind Documentation",
|
||||||
|
"url": "https://tailwindcss.com/docs/functions-and-directives#variants"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
17
CODEOWNERS
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Admins
|
||||||
|
* @Comfy-Org/comfy_frontend_devs
|
||||||
|
|
||||||
|
# Maintainers
|
||||||
|
*.md @Comfy-Org/comfy_maintainer
|
||||||
|
/tests-ui/ @Comfy-Org/comfy_maintainer
|
||||||
|
/browser_tests/ @Comfy-Org/comfy_maintainer
|
||||||
|
/.env_example @Comfy-Org/comfy_maintainer
|
||||||
|
|
||||||
|
# Translations (AIGODLIKE team + shinshin86)
|
||||||
|
/src/locales/ @Yorha4D @KarryCharon @DorotaLuna @shinshin86 @Comfy-Org/comfy_maintainer
|
||||||
|
|
||||||
|
# Load 3D extension
|
||||||
|
/src/extensions/core/load3d.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||||
|
|
||||||
|
# Mask Editor extension
|
||||||
|
/src/extensions/core/maskeditor.ts @trsommer @Comfy-Org/comfy_frontend_devs
|
||||||
518
README.md
@@ -33,11 +33,11 @@
|
|||||||
|
|
||||||
### Nightly Release
|
### Nightly Release
|
||||||
|
|
||||||
Nightly releases are published daily at [https://github.com/Comfy-Org/ComfyUI_frontend/releases](https://github.com/Comfy-Org/ComfyUI_frontend/releases).
|
Nightly releases are published daily at [https://github.com/Comfy-Org/ComfyUI_frontend/releases](https://github.com/Comfy-Org/ComfyUI_frontend/releases).
|
||||||
|
|
||||||
To use the latest nightly release, add the following command line argument to your ComfyUI launch script:
|
To use the latest nightly release, add the following command line argument to your ComfyUI launch script:
|
||||||
|
|
||||||
```
|
```bat
|
||||||
--front-end-version Comfy-Org/ComfyUI_frontend@latest
|
--front-end-version Comfy-Org/ComfyUI_frontend@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -52,17 +52,56 @@ pause
|
|||||||
|
|
||||||
### Stable Release
|
### Stable Release
|
||||||
|
|
||||||
Stable releases are published weekly in the ComfyUI main repository, aligned with ComfyUI backend's stable release schedule.
|
Stable releases are published bi-weekly in the ComfyUI main repository.
|
||||||
|
|
||||||
#### Feature Freeze
|
|
||||||
|
|
||||||
There will be a 2-day feature freeze before each stable release. During this period, no new major features will be merged.
|
|
||||||
|
|
||||||
## Release Summary
|
## Release Summary
|
||||||
|
|
||||||
### Major features
|
### Major features
|
||||||
|
|
||||||
<details>
|
<details id='feature-native-translation'>
|
||||||
|
<summary>v1.5: Native translation (i18n)</summary>
|
||||||
|
|
||||||
|
ComfyUI now includes built-in translation support, replacing the need for third-party translation extensions. Select your language
|
||||||
|
in `Comfy > Locale > Language` to translate the interface into English, Chinese (Simplified), Russian, Japanese, or Korean. This native
|
||||||
|
implementation offers better performance, reliability, and maintainability compared to previous solutions.<br>
|
||||||
|
|
||||||
|
More details available here: https://blog.comfy.org/p/native-localization-support-i18n
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details id='feature-mask-editor'>
|
||||||
|
<summary>v1.4: New mask editor</summary>
|
||||||
|
|
||||||
|
https://github.com/Comfy-Org/ComfyUI_frontend/pull/1284 implements a new mask editor.
|
||||||
|
|
||||||
|

|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details id='feature-integrated-server-terminal'>
|
||||||
|
<summary>v1.3.22: Integrated server terminal</summary>
|
||||||
|
|
||||||
|
Press Ctrl + ` to toggle integrated terminal.
|
||||||
|
|
||||||
|
https://github.com/user-attachments/assets/eddedc6a-07a3-4a83-9475-63b3977f6d94
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details id='feature-keybinding-customization'>
|
||||||
|
<summary>v1.3.7: Keybinding customization</summary>
|
||||||
|
|
||||||
|
## Basic UI
|
||||||
|

|
||||||
|
|
||||||
|
## Reset button
|
||||||
|

|
||||||
|
|
||||||
|
## Edit Keybinding
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
[rec.webm](https://github.com/user-attachments/assets/a3984ed9-eb28-4d47-86c0-7fc3efc2b5d0)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details id='feature-node-library-sidebar'>
|
||||||
<summary>v1.2.4: Node library sidebar tab</summary>
|
<summary>v1.2.4: Node library sidebar tab</summary>
|
||||||
|
|
||||||
#### Drag & Drop
|
#### Drag & Drop
|
||||||
@@ -72,13 +111,13 @@ https://github.com/user-attachments/assets/853e20b7-bc0e-49c9-bbce-a2ba7566f92f
|
|||||||
https://github.com/user-attachments/assets/4bbca3ee-318f-4cf0-be32-a5a5541066cf
|
https://github.com/user-attachments/assets/4bbca3ee-318f-4cf0-be32-a5a5541066cf
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details id='feature-queue-sidebar'>
|
||||||
<summary>v1.2.0: Queue/History sidebar tab</summary>
|
<summary>v1.2.0: Queue/History sidebar tab</summary>
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/86e264fe-4d26-4f07-aa9a-83bdd2d02b8f
|
https://github.com/user-attachments/assets/86e264fe-4d26-4f07-aa9a-83bdd2d02b8f
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details id='feature-node-search'>
|
||||||
<summary>v1.1.0: Node search box</summary>
|
<summary>v1.1.0: Node search box</summary>
|
||||||
|
|
||||||
#### Fuzzy search & Node preview
|
#### Fuzzy search & Node preview
|
||||||
@@ -90,28 +129,80 @@ https://github.com/user-attachments/assets/4bbca3ee-318f-4cf0-be32-a5a5541066cf
|
|||||||
|
|
||||||
### QoL changes
|
### QoL changes
|
||||||
|
|
||||||
<details>
|
<details id='feature-nested-group'>
|
||||||
|
<summary>v1.3.32: **Litegraph** Nested group</summary>
|
||||||
|
|
||||||
|
https://github.com/user-attachments/assets/f51adeb1-028e-40af-81e4-0ac13075198a
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details id='feature-group-selection'>
|
||||||
|
<summary>v1.3.24: **Litegraph** Group selection</summary>
|
||||||
|
|
||||||
|
https://github.com/user-attachments/assets/e6230a94-411e-4fba-90cb-6c694200adaa
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details id='feature-toggle-link-visibility'>
|
||||||
|
<summary>v1.3.6: **Litegraph** Toggle link visibility</summary>
|
||||||
|
|
||||||
|
[rec.webm](https://github.com/user-attachments/assets/34e460ac-fbbc-44ef-bfbb-99a84c2ae2be)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details id='feature-auto-widget-conversion'>
|
||||||
|
<summary>v1.3.4: **Litegraph** Auto widget to input conversion</summary>
|
||||||
|
|
||||||
|
Dropping a link of correct type on node widget will automatically convert the widget to input.
|
||||||
|
|
||||||
|
[rec.webm](https://github.com/user-attachments/assets/15cea0b0-b225-4bec-af50-2cdb16dc46bf)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details id='feature-pan-mode'>
|
||||||
|
<summary>v1.3.4: **Litegraph** Canvas pan mode</summary>
|
||||||
|
|
||||||
|
The canvas becomes readonly in pan mode. Pan mode is activated by clicking the pan mode button on the canvas menu
|
||||||
|
or by holding the space key.
|
||||||
|
|
||||||
|
[rec.webm](https://github.com/user-attachments/assets/c7872532-a2ac-44c1-9e7d-9e03b5d1a80b)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details id='feature-shift-drag-link-creation'>
|
||||||
|
<summary>v1.3.1: **Litegraph** Shift drag link to create a new link</summary>
|
||||||
|
|
||||||
|
[rec.webm](https://github.com/user-attachments/assets/7e73aaf9-79e2-4c3c-a26a-911cba3b85e4)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details id='feature-optional-input-donuts'>
|
||||||
|
<summary>v1.2.62: **Litegraph** Show optional input slots as donuts</summary>
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details id='feature-group-title-edit'>
|
||||||
<summary>v1.2.44: **Litegraph** Double click group title to edit</summary>
|
<summary>v1.2.44: **Litegraph** Double click group title to edit</summary>
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/5bf0e2b6-8b3a-40a7-b44f-f0879e9ad26f
|
https://github.com/user-attachments/assets/5bf0e2b6-8b3a-40a7-b44f-f0879e9ad26f
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details id='feature-group-selection-shortcut'>
|
||||||
<summary>v1.2.39: **Litegraph** Group selected nodes with Ctrl + G</summary>
|
<summary>v1.2.39: **Litegraph** Group selected nodes with Ctrl + G</summary>
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/7805dc54-0854-4a28-8bcd-4b007fa01151
|
https://github.com/user-attachments/assets/7805dc54-0854-4a28-8bcd-4b007fa01151
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details id='feature-node-title-edit'>
|
||||||
<summary>v1.2.38: **Litegraph** Double click node title to edit</summary>
|
<summary>v1.2.38: **Litegraph** Double click node title to edit</summary>
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/d61d5d0e-f200-4153-b293-3e3f6a212b30
|
https://github.com/user-attachments/assets/d61d5d0e-f200-4153-b293-3e3f6a212b30
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details id='feature-drag-multi-link'>
|
||||||
<summary>v1.2.7: **Litegraph** drags multiple links with shift pressed</summary>
|
<summary>v1.2.7: **Litegraph** drags multiple links with shift pressed</summary>
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/68826715-bb55-4b2a-be6e-675cfc424afe
|
https://github.com/user-attachments/assets/68826715-bb55-4b2a-be6e-675cfc424afe
|
||||||
@@ -120,7 +211,7 @@ https://github.com/user-attachments/assets/c142c43f-2fe9-4030-8196-b3bfd4c6977d
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details id='feature-auto-connect-link'>
|
||||||
<summary>v1.2.2: **Litegraph** auto connects to correct slot</summary>
|
<summary>v1.2.2: **Litegraph** auto connects to correct slot</summary>
|
||||||
|
|
||||||
#### Before
|
#### Before
|
||||||
@@ -130,14 +221,229 @@ https://github.com/user-attachments/assets/c142c43f-2fe9-4030-8196-b3bfd4c6977d
|
|||||||
https://github.com/user-attachments/assets/b6360ac0-f0d2-447c-9daa-8a2e20c0dc1d
|
https://github.com/user-attachments/assets/b6360ac0-f0d2-447c-9daa-8a2e20c0dc1d
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details id='feature-hide-text-overflow'>
|
||||||
<summary>v1.1.8: **Litegraph** hides text overflow on widget value</summary>
|
<summary>v1.1.8: **Litegraph** hides text overflow on widget value</summary>
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/5696a89d-4a47-4fcc-9e8c-71e1264943f2
|
https://github.com/user-attachments/assets/5696a89d-4a47-4fcc-9e8c-71e1264943f2
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
### Node developers API
|
### Developer APIs
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
<summary>v1.6.13: prompt/confirm/alert replacements for ComfyUI desktop</summary>
|
||||||
|
|
||||||
|
Several browser-only APIs are not available in ComfyUI desktop's electron environment.
|
||||||
|
|
||||||
|
- `window.prompt`
|
||||||
|
- `window.confirm`
|
||||||
|
- `window.alert`
|
||||||
|
|
||||||
|
Please use the following APIs as replacements.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// window.prompt
|
||||||
|
window['app'].extensionManager.dialog
|
||||||
|
.prompt({
|
||||||
|
title: 'Test Prompt',
|
||||||
|
message: 'Test Prompt Message'
|
||||||
|
})
|
||||||
|
.then((value: string) => {
|
||||||
|
// Do something with the value user entered
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
```js
|
||||||
|
// window.confirm
|
||||||
|
window['app'].extensionManager.dialog
|
||||||
|
.confirm({
|
||||||
|
title: 'Test Confirm',
|
||||||
|
message: 'Test Confirm Message'
|
||||||
|
})
|
||||||
|
.then((value: boolean) => {
|
||||||
|
// Do something with the value user entered
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
```js
|
||||||
|
// window.alert
|
||||||
|
window['app'].extensionManager.toast
|
||||||
|
.addAlert("Test Alert")
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>v1.3.34: Register about panel badges</summary>
|
||||||
|
|
||||||
|
```js
|
||||||
|
app.registerExtension({
|
||||||
|
name: 'TestExtension1',
|
||||||
|
aboutPageBadges: [
|
||||||
|
{
|
||||||
|
label: 'Test Badge',
|
||||||
|
url: 'https://example.com',
|
||||||
|
icon: 'pi pi-box'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details id='extension-api-bottom-panel-tabs'>
|
||||||
|
<summary>v1.3.22: Register bottom panel tabs</summary>
|
||||||
|
|
||||||
|
```js
|
||||||
|
app.registerExtension({
|
||||||
|
name: 'TestExtension',
|
||||||
|
bottomPanelTabs: [
|
||||||
|
{
|
||||||
|
id: 'TestTab',
|
||||||
|
title: 'Test Tab',
|
||||||
|
type: 'custom',
|
||||||
|
render: (el) => {
|
||||||
|
el.innerHTML = '<div>Custom tab</div>'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details id='extension-api-settings'>
|
||||||
|
<summary>v1.3.22: New settings API</summary>
|
||||||
|
|
||||||
|
Legacy settings API.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Register a new setting
|
||||||
|
app.ui.settings.addSetting({
|
||||||
|
id: 'TestSetting',
|
||||||
|
name: 'Test Setting',
|
||||||
|
type: 'text',
|
||||||
|
defaultValue: 'Hello, world!'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get the value of a setting
|
||||||
|
const value = app.ui.settings.getSettingValue('TestSetting')
|
||||||
|
|
||||||
|
// Set the value of a setting
|
||||||
|
app.ui.settings.setSettingValue('TestSetting', 'Hello, universe!')
|
||||||
|
```
|
||||||
|
|
||||||
|
New settings API.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Register a new setting
|
||||||
|
app.registerExtension({
|
||||||
|
name: 'TestExtension1',
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
id: 'TestSetting',
|
||||||
|
name: 'Test Setting',
|
||||||
|
type: 'text',
|
||||||
|
defaultValue: 'Hello, world!'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get the value of a setting
|
||||||
|
const value = app.extensionManager.setting.get('TestSetting')
|
||||||
|
|
||||||
|
// Set the value of a setting
|
||||||
|
app.extensionManager.setting.set('TestSetting', 'Hello, universe!')
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details id='extension-api-commands-keybindings'>
|
||||||
|
<summary>v1.3.7: Register commands and keybindings</summary>
|
||||||
|
|
||||||
|
Extensions can call the following API to register commands and keybindings. Do
|
||||||
|
note that keybindings defined in core cannot be overwritten, and some keybindings
|
||||||
|
are reserved by the browser.
|
||||||
|
|
||||||
|
```js
|
||||||
|
app.registerExtension({
|
||||||
|
name: 'TestExtension1',
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
id: 'TestCommand',
|
||||||
|
function: () => {
|
||||||
|
alert('TestCommand')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
keybindings: [
|
||||||
|
{
|
||||||
|
combo: { key: 'k' },
|
||||||
|
commandId: 'TestCommand'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details id='extension-api-topbar-menu'>
|
||||||
|
<summary>v1.3.1: Extension API to register custom topbar menu items</summary>
|
||||||
|
|
||||||
|
Extensions can call the following API to register custom topbar menu items.
|
||||||
|
|
||||||
|
```js
|
||||||
|
app.registerExtension({
|
||||||
|
name: 'TestExtension1',
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
id: 'foo-id',
|
||||||
|
label: 'foo',
|
||||||
|
function: () => {
|
||||||
|
alert(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
menuCommands: [
|
||||||
|
{
|
||||||
|
path: ['ext', 'ext2'],
|
||||||
|
commands: ['foo-id']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details id='extension-api-toast'>
|
||||||
|
<summary>v1.2.27: Extension API to add toast message</summary>i
|
||||||
|
|
||||||
|
Extensions can call the following API to add toast messages.
|
||||||
|
|
||||||
|
```js
|
||||||
|
app.extensionManager.toast.add({
|
||||||
|
severity: 'info',
|
||||||
|
summary: 'Loaded!',
|
||||||
|
detail: 'Extension loaded!',
|
||||||
|
life: 3000
|
||||||
|
})
|
||||||
|
```
|
||||||
|
Documentation of all supported options can be found here: <https://primevue.org/toast/#api.toast.interfaces.ToastMessageOptions>
|
||||||
|
|
||||||
|

|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details id='extension-api-sidebar-tab'>
|
||||||
<summary>v1.2.4: Extension API to register custom sidebar tab</summary>
|
<summary>v1.2.4: Extension API to register custom sidebar tab</summary>
|
||||||
|
|
||||||
Extensions now can call the following API to register a sidebar tab.
|
Extensions now can call the following API to register a sidebar tab.
|
||||||
@@ -162,51 +468,17 @@ We will support custom icons later.
|
|||||||

|

|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>v1.2.27: Extension API to add toast message</summary>
|
|
||||||
|
|
||||||
Extensions can call the following API to add toast messages.
|
|
||||||
|
|
||||||
```js
|
|
||||||
app.extensionManager.toast.add({
|
|
||||||
severity: 'info',
|
|
||||||
summary: 'Loaded!',
|
|
||||||
detail: 'Extension loaded!',
|
|
||||||
life: 3000
|
|
||||||
})
|
|
||||||
```
|
|
||||||
Documentation of all supported options can be found here: <https://primevue.org/toast/#api.toast.interfaces.ToastMessageOptions>
|
|
||||||
|
|
||||||

|
|
||||||
</details>
|
|
||||||
|
|
||||||
## Road Map
|
|
||||||
|
|
||||||
### What has been done
|
|
||||||
|
|
||||||
- Migrate all code to TypeScript with minimal change modification to the original logic.
|
|
||||||
- Bundle all code with Vite's rollup build.
|
|
||||||
- Added a shim layer to be backward compatible with the existing extension system. <https://github.com/huchenlei/ComfyUI_frontend/pull/15>
|
|
||||||
- Front-end dev server.
|
|
||||||
- Zod schema for input validation on ComfyUI workflow.
|
|
||||||
- Make litegraph a npm dependency. <https://github.com/Comfy-Org/ComfyUI_frontend/pull/89>
|
|
||||||
- Introduce Vue to start managing part of the UI.
|
|
||||||
- Easy install and version management (<https://github.com/comfyanonymous/ComfyUI/pull/3897>).
|
|
||||||
- Better node management. Sherlock <https://github.com/Nuked88/ComfyUI-N-Sidebar>.
|
|
||||||
- Replace the existing ComfyUI front-end implementation. <https://github.com/comfyanonymous/ComfyUI/pull/4379>
|
|
||||||
|
|
||||||
|
|
||||||
### What to be done
|
|
||||||
|
|
||||||
- Remove `@ts-ignore`s.
|
|
||||||
- Turn on `strict` on `tsconfig.json`.
|
|
||||||
- Add more widget types for node developers.
|
|
||||||
- LLM streaming node.
|
|
||||||
- Linear mode (Similar to InvokeAI's linear mode).
|
|
||||||
- Keybinding settings management. Register keybindings API for custom nodes.
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
|
||||||
|
- [Vue 3](https://vuejs.org/) with [TypeScript](https://www.typescriptlang.org/)
|
||||||
|
- [Pinia](https://pinia.vuejs.org/) for state management
|
||||||
|
- [PrimeVue](https://primevue.org/) with [TailwindCSS](https://tailwindcss.com/) for UI
|
||||||
|
- [Litegraph](https://github.com/Comfy-Org/litegraph.js) for node editor
|
||||||
|
- [zod](https://zod.dev/) for schema validation
|
||||||
|
- [vue-i18n](https://github.com/intlify/vue-i18n) for internationalization
|
||||||
|
|
||||||
### Git pre-commit hooks
|
### Git pre-commit hooks
|
||||||
|
|
||||||
Run `npm run prepare` to install Git pre-commit hooks. Currently, the pre-commit
|
Run `npm run prepare` to install Git pre-commit hooks. Currently, the pre-commit
|
||||||
@@ -217,17 +489,48 @@ hook is used to auto-format code on commit.
|
|||||||
Note: The dev server will NOT load any extension from the ComfyUI server. Only
|
Note: The dev server will NOT load any extension from the ComfyUI server. Only
|
||||||
core extensions will be loaded.
|
core extensions will be loaded.
|
||||||
|
|
||||||
- Run `npm install` to install the necessary packages
|
|
||||||
- Start local ComfyUI backend at `localhost:8188`
|
- Start local ComfyUI backend at `localhost:8188`
|
||||||
- Run `npm run dev` to start the dev server
|
- Run `npm run dev` to start the dev server
|
||||||
|
- Run `npm run dev:electron` to start the dev server with electron API mocked
|
||||||
|
|
||||||
### Test
|
#### Access dev server on touch devices
|
||||||
|
|
||||||
|
Enable remote access to the dev server by setting `VITE_REMOTE_DEV` in `.env` to `true`.
|
||||||
|
|
||||||
|
After you start the dev server, you should see following logs:
|
||||||
|
|
||||||
|
```
|
||||||
|
> comfyui-frontend@1.3.42 dev
|
||||||
|
> vite
|
||||||
|
|
||||||
|
|
||||||
|
VITE v5.4.6 ready in 488 ms
|
||||||
|
|
||||||
|
➜ Local: http://localhost:5173/
|
||||||
|
➜ Network: http://172.21.80.1:5173/
|
||||||
|
➜ Network: http://192.168.2.20:5173/
|
||||||
|
➜ press h + enter to show help
|
||||||
|
```
|
||||||
|
|
||||||
|
Make sure your desktop machine and touch device are on the same network. On your touch device,
|
||||||
|
navigate to `http://<server_ip>:5173` (e.g. `http://192.168.2.20:5173` here), to access the ComfyUI frontend.
|
||||||
|
|
||||||
|
### Unit Test
|
||||||
|
|
||||||
- `git clone https://github.com/comfyanonymous/ComfyUI_examples.git` to `tests-ui/ComfyUI_examples` or the EXAMPLE_REPO_PATH location specified in .env
|
- `git clone https://github.com/comfyanonymous/ComfyUI_examples.git` to `tests-ui/ComfyUI_examples` or the EXAMPLE_REPO_PATH location specified in .env
|
||||||
- `npm i` to install all dependencies
|
- `npm i` to install all dependencies
|
||||||
- `npm run test:generate` to fetch `tests-ui/data/object_info.json`
|
- `npm run test:generate` to fetch `tests-ui/data/object_info.json`
|
||||||
- `npm run test:generate:examples` to extract the example workflows
|
- `npm run test:jest` to execute all unit tests.
|
||||||
- `npm run test` to execute all unit tests.
|
|
||||||
|
### Component Test
|
||||||
|
|
||||||
|
Component test verifies Vue components in `src/components/`.
|
||||||
|
|
||||||
|
- `npm run test:component` to execute all component tests.
|
||||||
|
|
||||||
|
### Playwright Test
|
||||||
|
|
||||||
|
Playwright test verifies the whole app. See <https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/browser_tests/README.md> for details.
|
||||||
|
|
||||||
### LiteGraph
|
### LiteGraph
|
||||||
|
|
||||||
@@ -236,19 +539,90 @@ This repo is using litegraph package hosted on <https://github.com/Comfy-Org/lit
|
|||||||
### Test litegraph changes
|
### Test litegraph changes
|
||||||
|
|
||||||
- Run `npm link` in the local litegraph repo.
|
- Run `npm link` in the local litegraph repo.
|
||||||
- Run `npm uninstall @comfyorg/litegraph` in this repo.
|
|
||||||
- Run `npm link @comfyorg/litegraph` in this repo.
|
- Run `npm link @comfyorg/litegraph` in this repo.
|
||||||
|
|
||||||
This will replace the litegraph package in this repo with the local litegraph repo.
|
This will replace the litegraph package in this repo with the local litegraph repo.
|
||||||
|
|
||||||
|
## Internationalization (i18n)
|
||||||
|
|
||||||
|
Our project supports multiple languages using `vue-i18n`. This allows users around the world to use the application in their preferred language.
|
||||||
|
|
||||||
|
### Supported Languages
|
||||||
|
|
||||||
|
- en (English)
|
||||||
|
- zh (中文)
|
||||||
|
- ru (Русский)
|
||||||
|
- ja (日本語)
|
||||||
|
- ko (한국어)
|
||||||
|
- fr (Français)
|
||||||
|
|
||||||
|
### How to Add a New Language
|
||||||
|
|
||||||
|
We welcome the addition of new languages. You can add a new language by following these steps:
|
||||||
|
|
||||||
|
#### 1. Generate language files
|
||||||
|
We use [lobe-i18n](https://github.com/lobehub/lobe-cli-toolbox/blob/master/packages/lobe-i18n/README.md) as our translation tool, which integrates with LLM for efficient localization.
|
||||||
|
|
||||||
|
Update the configuration file to include the new language(s) you wish to add:
|
||||||
|
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { defineConfig } = require('@lobehub/i18n-cli');
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
entry: 'src/locales/en.json', // Base language file
|
||||||
|
entryLocale: 'en',
|
||||||
|
output: 'src/locales',
|
||||||
|
outputLocales: ['zh', 'ru', 'ja'], // Add the new language(s) here
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Set your OpenAI API Key by running the following command:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npx lobe-i18n --option
|
||||||
|
```
|
||||||
|
|
||||||
|
Once configured, generate the translation files with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npx lobe-i18n locale
|
||||||
|
```
|
||||||
|
|
||||||
|
This will create the language files for the specified languages in the configuration.
|
||||||
|
|
||||||
|
#### 2. Update i18n Configuration
|
||||||
|
|
||||||
|
Import the newly generated locale file(s) in the `src/i18n.ts` file to include them in the application's i18n setup.
|
||||||
|
|
||||||
|
#### 3. Enable Selection of the New Language
|
||||||
|
|
||||||
|
Add the newly added language to the following item in `src/constants/coreSettings.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: 'Comfy.Locale',
|
||||||
|
name: 'Locale',
|
||||||
|
type: 'combo',
|
||||||
|
// Add the new language(s) here
|
||||||
|
options: [
|
||||||
|
{ value: 'en', text: 'English' },
|
||||||
|
{ value: 'zh', text: '中文' },
|
||||||
|
{ value: 'ru', text: 'Русский' },
|
||||||
|
{ value: 'ja', text: '日本語' }
|
||||||
|
],
|
||||||
|
defaultValue: navigator.language.split('-')[0] || 'en'
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
This will make the new language selectable in the application's settings.
|
||||||
|
|
||||||
|
#### 4. Test the Translations
|
||||||
|
|
||||||
|
Start the development server, switch to the new language, and verify the translations.
|
||||||
|
You can switch languages by opening the ComfyUI Settings and selecting from the `ComfyUI > Locale` dropdown box.
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
- Option 1: Set `DEPLOY_COMFYUI_DIR` in `.env` and run `npm run deploy`.
|
- Option 1: Set `DEPLOY_COMFYUI_DIR` in `.env` and run `npm run deploy`.
|
||||||
- Option 2: Copy everything under `dist/` to `ComfyUI/web/` in your ComfyUI checkout manually.
|
- Option 2: Copy everything under `dist/` to `ComfyUI/web/` in your ComfyUI checkout manually.
|
||||||
|
|
||||||
## Publish release to ComfyUI main repo
|
|
||||||
|
|
||||||
Run following command to publish a release to ComfyUI main repo. The script will create a new branch and do a commit to `web/` folder by checkout `dist.zip`
|
|
||||||
from GitHub release.
|
|
||||||
|
|
||||||
- `python scripts/main_repo_release.py <path_to_comfyui_main_repo> <version>`
|
|
||||||
|
|||||||
@@ -1,604 +0,0 @@
|
|||||||
import type { Page, Locator } from '@playwright/test'
|
|
||||||
import { test as base } from '@playwright/test'
|
|
||||||
import dotenv from 'dotenv'
|
|
||||||
dotenv.config()
|
|
||||||
import * as fs from 'fs'
|
|
||||||
import { NodeBadgeMode } from '../src/types/nodeSource'
|
|
||||||
|
|
||||||
interface Position {
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Size {
|
|
||||||
width: number
|
|
||||||
height: number
|
|
||||||
}
|
|
||||||
|
|
||||||
class ComfyNodeSearchBox {
|
|
||||||
public readonly input: Locator
|
|
||||||
public readonly dropdown: Locator
|
|
||||||
|
|
||||||
constructor(public readonly page: Page) {
|
|
||||||
this.input = page.locator(
|
|
||||||
'.comfy-vue-node-search-container input[type="text"]'
|
|
||||||
)
|
|
||||||
this.dropdown = page.locator(
|
|
||||||
'.comfy-vue-node-search-container .p-autocomplete-list'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fillAndSelectFirstNode(
|
|
||||||
nodeName: string,
|
|
||||||
options?: { suggestionIndex: number }
|
|
||||||
) {
|
|
||||||
await this.input.waitFor({ state: 'visible' })
|
|
||||||
await this.input.fill(nodeName)
|
|
||||||
await this.dropdown.waitFor({ state: 'visible' })
|
|
||||||
// Wait for some time for the auto complete list to update.
|
|
||||||
// The auto complete list is debounced and may take some time to update.
|
|
||||||
await this.page.waitForTimeout(500)
|
|
||||||
await this.dropdown
|
|
||||||
.locator('li')
|
|
||||||
.nth(options?.suggestionIndex || 0)
|
|
||||||
.click()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class NodeLibrarySidebarTab {
|
|
||||||
public readonly tabId: string = 'node-library'
|
|
||||||
constructor(public readonly page: Page) {}
|
|
||||||
|
|
||||||
get tabButton() {
|
|
||||||
return this.page.locator(`.${this.tabId}-tab-button`)
|
|
||||||
}
|
|
||||||
|
|
||||||
get selectedTabButton() {
|
|
||||||
return this.page.locator(
|
|
||||||
`.${this.tabId}-tab-button.side-bar-button-selected`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
get nodeLibrarySearchBoxInput() {
|
|
||||||
return this.page.locator('.node-lib-search-box input[type="text"]')
|
|
||||||
}
|
|
||||||
|
|
||||||
get nodeLibraryTree() {
|
|
||||||
return this.page.locator('.node-lib-tree-explorer')
|
|
||||||
}
|
|
||||||
|
|
||||||
get nodePreview() {
|
|
||||||
return this.page.locator('.node-lib-node-preview')
|
|
||||||
}
|
|
||||||
|
|
||||||
get tabContainer() {
|
|
||||||
return this.page.locator('.sidebar-content-container')
|
|
||||||
}
|
|
||||||
|
|
||||||
get newFolderButton() {
|
|
||||||
return this.tabContainer.locator('.new-folder-button')
|
|
||||||
}
|
|
||||||
|
|
||||||
async open() {
|
|
||||||
if (await this.selectedTabButton.isVisible()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.tabButton.click()
|
|
||||||
await this.nodeLibraryTree.waitFor({ state: 'visible' })
|
|
||||||
}
|
|
||||||
|
|
||||||
folderSelector(folderName: string) {
|
|
||||||
return `.p-tree-node-content:has(> .tree-explorer-node-label:has(.tree-folder .node-label:has-text("${folderName}")))`
|
|
||||||
}
|
|
||||||
|
|
||||||
getFolder(folderName: string) {
|
|
||||||
return this.page.locator(this.folderSelector(folderName))
|
|
||||||
}
|
|
||||||
|
|
||||||
nodeSelector(nodeName: string) {
|
|
||||||
return `.p-tree-node-content:has(> .tree-explorer-node-label:has(.tree-leaf .node-label:has-text("${nodeName}")))`
|
|
||||||
}
|
|
||||||
|
|
||||||
getNode(nodeName: string) {
|
|
||||||
return this.page.locator(this.nodeSelector(nodeName))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ComfyMenu {
|
|
||||||
public readonly sideToolbar: Locator
|
|
||||||
public readonly themeToggleButton: Locator
|
|
||||||
|
|
||||||
constructor(public readonly page: Page) {
|
|
||||||
this.sideToolbar = page.locator('.side-tool-bar-container')
|
|
||||||
this.themeToggleButton = page.locator('.comfy-vue-theme-toggle')
|
|
||||||
}
|
|
||||||
|
|
||||||
get nodeLibraryTab() {
|
|
||||||
return new NodeLibrarySidebarTab(this.page)
|
|
||||||
}
|
|
||||||
|
|
||||||
async toggleTheme() {
|
|
||||||
await this.themeToggleButton.click()
|
|
||||||
await this.page.evaluate(() => {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
window['app'].ui.settings.addEventListener(
|
|
||||||
'Comfy.ColorPalette.change',
|
|
||||||
resolve,
|
|
||||||
{ once: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
setTimeout(resolve, 5000)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async getThemeId() {
|
|
||||||
return await this.page.evaluate(async () => {
|
|
||||||
return await window['app'].ui.settings.getSettingValue(
|
|
||||||
'Comfy.ColorPalette'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ComfyPage {
|
|
||||||
public readonly url: string
|
|
||||||
// All canvas position operations are based on default view of canvas.
|
|
||||||
public readonly canvas: Locator
|
|
||||||
public readonly widgetTextBox: Locator
|
|
||||||
|
|
||||||
// Buttons
|
|
||||||
public readonly resetViewButton: Locator
|
|
||||||
public readonly queueButton: Locator
|
|
||||||
|
|
||||||
// Inputs
|
|
||||||
public readonly workflowUploadInput: Locator
|
|
||||||
|
|
||||||
// Components
|
|
||||||
public readonly searchBox: ComfyNodeSearchBox
|
|
||||||
public readonly menu: ComfyMenu
|
|
||||||
|
|
||||||
constructor(public readonly page: Page) {
|
|
||||||
this.url = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
|
||||||
this.canvas = page.locator('#graph-canvas')
|
|
||||||
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
|
|
||||||
this.resetViewButton = page.getByRole('button', { name: 'Reset View' })
|
|
||||||
this.queueButton = page.getByRole('button', { name: 'Queue Prompt' })
|
|
||||||
this.workflowUploadInput = page.locator('#comfy-file-input')
|
|
||||||
this.searchBox = new ComfyNodeSearchBox(page)
|
|
||||||
this.menu = new ComfyMenu(page)
|
|
||||||
}
|
|
||||||
|
|
||||||
async getGraphNodesCount(): Promise<number> {
|
|
||||||
return await this.page.evaluate(() => {
|
|
||||||
return window['app']?.graph?.nodes?.length || 0
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async setup() {
|
|
||||||
await this.goto()
|
|
||||||
// Unify font for consistent screenshots.
|
|
||||||
await this.page.addStyleTag({
|
|
||||||
url: 'https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap'
|
|
||||||
})
|
|
||||||
await this.page.addStyleTag({
|
|
||||||
url: 'https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap'
|
|
||||||
})
|
|
||||||
await this.page.addStyleTag({
|
|
||||||
content: `
|
|
||||||
* {
|
|
||||||
font-family: 'Roboto Mono', 'Noto Color Emoji';
|
|
||||||
}`
|
|
||||||
})
|
|
||||||
await this.page.waitForFunction(() => document.fonts.ready)
|
|
||||||
await this.page.waitForFunction(
|
|
||||||
() => window['app'] !== undefined && window['app'].vueAppReady
|
|
||||||
)
|
|
||||||
await this.page.evaluate(() => {
|
|
||||||
window['app']['canvas'].show_info = false
|
|
||||||
})
|
|
||||||
await this.nextFrame()
|
|
||||||
// Reset view to force re-rendering of canvas. So that info fields like fps
|
|
||||||
// become hidden.
|
|
||||||
await this.resetView()
|
|
||||||
|
|
||||||
// Hide all badges by default.
|
|
||||||
await this.setSetting('Comfy.NodeBadge.NodeIdBadgeMode', NodeBadgeMode.None)
|
|
||||||
await this.setSetting(
|
|
||||||
'Comfy.NodeBadge.NodeSourceBadgeMode',
|
|
||||||
NodeBadgeMode.None
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
public assetPath(fileName: string) {
|
|
||||||
return `./browser_tests/assets/${fileName}`
|
|
||||||
}
|
|
||||||
|
|
||||||
async setSetting(settingId: string, settingValue: any) {
|
|
||||||
return await this.page.evaluate(
|
|
||||||
async ({ id, value }) => {
|
|
||||||
await window['app'].ui.settings.setSettingValueAsync(id, value)
|
|
||||||
},
|
|
||||||
{ id: settingId, value: settingValue }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSetting(settingId: string) {
|
|
||||||
return await this.page.evaluate(async (id) => {
|
|
||||||
return await window['app'].ui.settings.getSettingValue(id)
|
|
||||||
}, settingId)
|
|
||||||
}
|
|
||||||
|
|
||||||
async reload() {
|
|
||||||
await this.page.reload({ timeout: 15000 })
|
|
||||||
await this.setup()
|
|
||||||
}
|
|
||||||
|
|
||||||
async goto() {
|
|
||||||
await this.page.goto(this.url)
|
|
||||||
}
|
|
||||||
|
|
||||||
async nextFrame() {
|
|
||||||
await this.page.evaluate(() => {
|
|
||||||
return new Promise<number>(requestAnimationFrame)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async delay(ms: number) {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadWorkflow(workflowName: string) {
|
|
||||||
await this.workflowUploadInput.setInputFiles(
|
|
||||||
this.assetPath(`${workflowName}.json`)
|
|
||||||
)
|
|
||||||
await this.nextFrame()
|
|
||||||
}
|
|
||||||
|
|
||||||
async resetView() {
|
|
||||||
if (await this.resetViewButton.isVisible()) {
|
|
||||||
await this.resetViewButton.click()
|
|
||||||
}
|
|
||||||
// Avoid "Reset View" button highlight.
|
|
||||||
await this.page.mouse.move(10, 10)
|
|
||||||
await this.nextFrame()
|
|
||||||
}
|
|
||||||
|
|
||||||
async clickTextEncodeNode1() {
|
|
||||||
await this.canvas.click({
|
|
||||||
position: {
|
|
||||||
x: 618,
|
|
||||||
y: 191
|
|
||||||
}
|
|
||||||
})
|
|
||||||
await this.nextFrame()
|
|
||||||
}
|
|
||||||
|
|
||||||
async clickTextEncodeNodeToggler() {
|
|
||||||
await this.canvas.click({
|
|
||||||
position: {
|
|
||||||
x: 430,
|
|
||||||
y: 171
|
|
||||||
}
|
|
||||||
})
|
|
||||||
await this.nextFrame()
|
|
||||||
}
|
|
||||||
|
|
||||||
async clickTextEncodeNode2() {
|
|
||||||
await this.canvas.click({
|
|
||||||
position: {
|
|
||||||
x: 622,
|
|
||||||
y: 400
|
|
||||||
}
|
|
||||||
})
|
|
||||||
await this.nextFrame()
|
|
||||||
}
|
|
||||||
|
|
||||||
async clickEmptySpace() {
|
|
||||||
await this.canvas.click({
|
|
||||||
position: {
|
|
||||||
x: 35,
|
|
||||||
y: 31
|
|
||||||
}
|
|
||||||
})
|
|
||||||
await this.nextFrame()
|
|
||||||
}
|
|
||||||
|
|
||||||
async dragAndDrop(source: Position, target: Position) {
|
|
||||||
await this.page.mouse.move(source.x, source.y)
|
|
||||||
await this.page.mouse.down()
|
|
||||||
await this.page.mouse.move(target.x, target.y)
|
|
||||||
await this.page.mouse.up()
|
|
||||||
await this.nextFrame()
|
|
||||||
}
|
|
||||||
|
|
||||||
async dragAndDropFile(fileName: string) {
|
|
||||||
const filePath = this.assetPath(fileName)
|
|
||||||
|
|
||||||
// Read the file content
|
|
||||||
const buffer = fs.readFileSync(filePath)
|
|
||||||
|
|
||||||
// Get file type
|
|
||||||
const getFileType = (fileName: string) => {
|
|
||||||
if (fileName.endsWith('.png')) return 'image/png'
|
|
||||||
if (fileName.endsWith('.webp')) return 'image/webp'
|
|
||||||
if (fileName.endsWith('.json')) return 'application/json'
|
|
||||||
return 'application/octet-stream'
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileType = getFileType(fileName)
|
|
||||||
|
|
||||||
await this.page.evaluate(
|
|
||||||
async ({ buffer, fileName, fileType }) => {
|
|
||||||
const file = new File([new Uint8Array(buffer)], fileName, {
|
|
||||||
type: fileType
|
|
||||||
})
|
|
||||||
const dataTransfer = new DataTransfer()
|
|
||||||
dataTransfer.items.add(file)
|
|
||||||
|
|
||||||
const dropEvent = new DragEvent('drop', {
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
dataTransfer
|
|
||||||
})
|
|
||||||
|
|
||||||
Object.defineProperty(dropEvent, 'preventDefault', {
|
|
||||||
value: () => {},
|
|
||||||
writable: false
|
|
||||||
})
|
|
||||||
|
|
||||||
Object.defineProperty(dropEvent, 'stopPropagation', {
|
|
||||||
value: () => {},
|
|
||||||
writable: false
|
|
||||||
})
|
|
||||||
|
|
||||||
document.dispatchEvent(dropEvent)
|
|
||||||
},
|
|
||||||
{ buffer: [...new Uint8Array(buffer)], fileName, fileType }
|
|
||||||
)
|
|
||||||
|
|
||||||
await this.nextFrame()
|
|
||||||
}
|
|
||||||
|
|
||||||
async dragNode2() {
|
|
||||||
await this.dragAndDrop({ x: 622, y: 400 }, { x: 622, y: 300 })
|
|
||||||
await this.nextFrame()
|
|
||||||
}
|
|
||||||
|
|
||||||
async disconnectEdge() {
|
|
||||||
// CLIP input anchor
|
|
||||||
await this.page.mouse.move(427, 198)
|
|
||||||
await this.page.mouse.down()
|
|
||||||
await this.page.mouse.move(427, 98)
|
|
||||||
await this.page.mouse.up()
|
|
||||||
// Move out the way to avoid highlight of menu item.
|
|
||||||
await this.page.mouse.move(10, 10)
|
|
||||||
await this.nextFrame()
|
|
||||||
}
|
|
||||||
|
|
||||||
async connectEdge() {
|
|
||||||
// CLIP output anchor on Load Checkpoint Node.
|
|
||||||
await this.page.mouse.move(332, 509)
|
|
||||||
await this.page.mouse.down()
|
|
||||||
// CLIP input anchor on CLIP Text Encode Node.
|
|
||||||
await this.page.mouse.move(427, 198)
|
|
||||||
await this.page.mouse.up()
|
|
||||||
await this.nextFrame()
|
|
||||||
}
|
|
||||||
|
|
||||||
async adjustWidgetValue() {
|
|
||||||
// Adjust Empty Latent Image's width input.
|
|
||||||
const page = this.page
|
|
||||||
await page.locator('#graph-canvas').click({
|
|
||||||
position: {
|
|
||||||
x: 724,
|
|
||||||
y: 645
|
|
||||||
}
|
|
||||||
})
|
|
||||||
await page.locator('input[type="text"]').click()
|
|
||||||
await page.locator('input[type="text"]').fill('128')
|
|
||||||
await page.locator('input[type="text"]').press('Enter')
|
|
||||||
await this.nextFrame()
|
|
||||||
}
|
|
||||||
|
|
||||||
async zoom(deltaY: number, steps: number = 1) {
|
|
||||||
await this.page.mouse.move(10, 10)
|
|
||||||
for (let i = 0; i < steps; i++) {
|
|
||||||
await this.page.mouse.wheel(0, deltaY)
|
|
||||||
}
|
|
||||||
await this.nextFrame()
|
|
||||||
}
|
|
||||||
|
|
||||||
async pan(offset: Position, safeSpot?: Position) {
|
|
||||||
safeSpot = safeSpot || { x: 10, y: 10 }
|
|
||||||
await this.page.mouse.move(safeSpot.x, safeSpot.y)
|
|
||||||
await this.page.mouse.down()
|
|
||||||
// TEMPORARY HACK: Multiple pans open the search menu, so cheat and keep it closed.
|
|
||||||
// TODO: Fix that (double-click at not-the-same-coordinations should not open the menu)
|
|
||||||
await this.page.keyboard.press('Escape')
|
|
||||||
await this.page.mouse.move(offset.x + safeSpot.x, offset.y + safeSpot.y)
|
|
||||||
await this.page.mouse.up()
|
|
||||||
await this.nextFrame()
|
|
||||||
}
|
|
||||||
|
|
||||||
async rightClickCanvas() {
|
|
||||||
await this.page.mouse.click(10, 10, { button: 'right' })
|
|
||||||
await this.nextFrame()
|
|
||||||
}
|
|
||||||
|
|
||||||
async doubleClickCanvas() {
|
|
||||||
await this.page.mouse.dblclick(10, 10)
|
|
||||||
await this.nextFrame()
|
|
||||||
}
|
|
||||||
|
|
||||||
async clickEmptyLatentNode() {
|
|
||||||
await this.canvas.click({
|
|
||||||
position: {
|
|
||||||
x: 724,
|
|
||||||
y: 625
|
|
||||||
}
|
|
||||||
})
|
|
||||||
this.page.mouse.move(10, 10)
|
|
||||||
await this.nextFrame()
|
|
||||||
}
|
|
||||||
|
|
||||||
async rightClickEmptyLatentNode() {
|
|
||||||
await this.canvas.click({
|
|
||||||
position: {
|
|
||||||
x: 724,
|
|
||||||
y: 645
|
|
||||||
},
|
|
||||||
button: 'right'
|
|
||||||
})
|
|
||||||
this.page.mouse.move(10, 10)
|
|
||||||
await this.nextFrame()
|
|
||||||
}
|
|
||||||
|
|
||||||
async select2Nodes() {
|
|
||||||
// Select 2 CLIP nodes.
|
|
||||||
await this.page.keyboard.down('Control')
|
|
||||||
await this.clickTextEncodeNode1()
|
|
||||||
await this.clickTextEncodeNode2()
|
|
||||||
await this.page.keyboard.up('Control')
|
|
||||||
await this.nextFrame()
|
|
||||||
}
|
|
||||||
|
|
||||||
async ctrlC() {
|
|
||||||
await this.page.keyboard.down('Control')
|
|
||||||
await this.page.keyboard.press('KeyC')
|
|
||||||
await this.page.keyboard.up('Control')
|
|
||||||
await this.nextFrame()
|
|
||||||
}
|
|
||||||
|
|
||||||
async ctrlV() {
|
|
||||||
await this.page.keyboard.down('Control')
|
|
||||||
await this.page.keyboard.press('KeyV')
|
|
||||||
await this.page.keyboard.up('Control')
|
|
||||||
await this.nextFrame()
|
|
||||||
}
|
|
||||||
|
|
||||||
async ctrlZ() {
|
|
||||||
await this.page.keyboard.down('Control')
|
|
||||||
await this.page.keyboard.press('KeyZ')
|
|
||||||
await this.page.keyboard.up('Control')
|
|
||||||
await this.nextFrame()
|
|
||||||
}
|
|
||||||
|
|
||||||
async ctrlY() {
|
|
||||||
await this.page.keyboard.down('Control')
|
|
||||||
await this.page.keyboard.press('KeyY')
|
|
||||||
await this.page.keyboard.up('Control')
|
|
||||||
await this.nextFrame()
|
|
||||||
}
|
|
||||||
|
|
||||||
async ctrlArrowUp() {
|
|
||||||
await this.page.keyboard.down('Control')
|
|
||||||
await this.page.keyboard.press('ArrowUp')
|
|
||||||
await this.page.keyboard.up('Control')
|
|
||||||
await this.nextFrame()
|
|
||||||
}
|
|
||||||
|
|
||||||
async ctrlArrowDown() {
|
|
||||||
await this.page.keyboard.down('Control')
|
|
||||||
await this.page.keyboard.press('ArrowDown')
|
|
||||||
await this.page.keyboard.up('Control')
|
|
||||||
await this.nextFrame()
|
|
||||||
}
|
|
||||||
|
|
||||||
async closeMenu() {
|
|
||||||
await this.page.click('button.comfy-close-menu-btn')
|
|
||||||
await this.nextFrame()
|
|
||||||
}
|
|
||||||
|
|
||||||
async resizeNode(
|
|
||||||
nodePos: Position,
|
|
||||||
nodeSize: Size,
|
|
||||||
ratioX: number,
|
|
||||||
ratioY: number,
|
|
||||||
revertAfter: boolean = false
|
|
||||||
) {
|
|
||||||
const bottomRight = {
|
|
||||||
x: nodePos.x + nodeSize.width,
|
|
||||||
y: nodePos.y + nodeSize.height
|
|
||||||
}
|
|
||||||
const target = {
|
|
||||||
x: nodePos.x + nodeSize.width * ratioX,
|
|
||||||
y: nodePos.y + nodeSize.height * ratioY
|
|
||||||
}
|
|
||||||
await this.dragAndDrop(bottomRight, target)
|
|
||||||
await this.nextFrame()
|
|
||||||
if (revertAfter) {
|
|
||||||
await this.dragAndDrop(target, bottomRight)
|
|
||||||
await this.nextFrame()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async resizeKsamplerNode(
|
|
||||||
percentX: number,
|
|
||||||
percentY: number,
|
|
||||||
revertAfter: boolean = false
|
|
||||||
) {
|
|
||||||
const ksamplerPos = {
|
|
||||||
x: 864,
|
|
||||||
y: 157
|
|
||||||
}
|
|
||||||
const ksamplerSize = {
|
|
||||||
width: 315,
|
|
||||||
height: 292
|
|
||||||
}
|
|
||||||
this.resizeNode(ksamplerPos, ksamplerSize, percentX, percentY, revertAfter)
|
|
||||||
}
|
|
||||||
|
|
||||||
async resizeLoadCheckpointNode(
|
|
||||||
percentX: number,
|
|
||||||
percentY: number,
|
|
||||||
revertAfter: boolean = false
|
|
||||||
) {
|
|
||||||
const loadCheckpointPos = {
|
|
||||||
x: 25,
|
|
||||||
y: 440
|
|
||||||
}
|
|
||||||
const loadCheckpointSize = {
|
|
||||||
width: 320,
|
|
||||||
height: 120
|
|
||||||
}
|
|
||||||
this.resizeNode(
|
|
||||||
loadCheckpointPos,
|
|
||||||
loadCheckpointSize,
|
|
||||||
percentX,
|
|
||||||
percentY,
|
|
||||||
revertAfter
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async resizeEmptyLatentNode(
|
|
||||||
percentX: number,
|
|
||||||
percentY: number,
|
|
||||||
revertAfter: boolean = false
|
|
||||||
) {
|
|
||||||
const emptyLatentPos = {
|
|
||||||
x: 475,
|
|
||||||
y: 580
|
|
||||||
}
|
|
||||||
const emptyLatentSize = {
|
|
||||||
width: 303,
|
|
||||||
height: 132
|
|
||||||
}
|
|
||||||
this.resizeNode(
|
|
||||||
emptyLatentPos,
|
|
||||||
emptyLatentSize,
|
|
||||||
percentX,
|
|
||||||
percentY,
|
|
||||||
revertAfter
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const comfyPageFixture = base.extend<{ comfyPage: ComfyPage }>({
|
|
||||||
comfyPage: async ({ page }, use) => {
|
|
||||||
const comfyPage = new ComfyPage(page)
|
|
||||||
await comfyPage.setup()
|
|
||||||
await use(comfyPage)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -2,8 +2,16 @@
|
|||||||
|
|
||||||
This document outlines the setup and usage of Playwright for testing the ComfyUI_frontend project.
|
This document outlines the setup and usage of Playwright for testing the ComfyUI_frontend project.
|
||||||
|
|
||||||
|
## WARNING
|
||||||
|
|
||||||
|
The browser tests will change the ComfyUI backend state, such as user settings and saved workflows.
|
||||||
|
If `TEST_COMFYUI_DIR` in `.env` isn't set to your `(Comfy Path)/ComfyUI` directory, these changes won't be automatically restored.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
|
Clone <https://github.com/Comfy-Org/ComfyUI_devtools> to your `custom_nodes` directory.
|
||||||
|
ComfyUI_devtools adds additional API endpoints and nodes to ComfyUI for browser testing.
|
||||||
|
|
||||||
Ensure you have Node.js v20 or later installed. Then, set up the Chromium test driver:
|
Ensure you have Node.js v20 or later installed. Then, set up the Chromium test driver:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -26,6 +34,8 @@ There are two ways to run the tests:
|
|||||||
```
|
```
|
||||||
This opens a user interface where you can select specific tests to run and inspect the test execution timeline.
|
This opens a user interface where you can select specific tests to run and inspect the test execution timeline.
|
||||||
|
|
||||||
|
To run the same test multiple times in Playwright's UI mode, you must launch the main ComfyUI process with the `--multi-user` flag.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Screenshot Expectations
|
## Screenshot Expectations
|
||||||
|
|||||||
126
browser_tests/actionbar.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import type { Response } from '@playwright/test'
|
||||||
|
import { expect, mergeTests } from '@playwright/test'
|
||||||
|
|
||||||
|
import type { StatusWsMessage } from '../src/types/apiTypes.ts'
|
||||||
|
import { comfyPageFixture } from './fixtures/ComfyPage'
|
||||||
|
import { webSocketFixture } from './fixtures/ws.ts'
|
||||||
|
|
||||||
|
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||||
|
|
||||||
|
test.describe('Actionbar', () => {
|
||||||
|
test.beforeEach(async ({ comfyPage }) => {
|
||||||
|
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This test ensures that the autoqueue change mode can only queue one change at a time
|
||||||
|
*/
|
||||||
|
test('Does not auto-queue multiple changes at a time', async ({
|
||||||
|
comfyPage,
|
||||||
|
ws
|
||||||
|
}) => {
|
||||||
|
// Enable change auto-queue mode
|
||||||
|
const queueOpts = await comfyPage.actionbar.queueButton.toggleOptions()
|
||||||
|
expect(await queueOpts.getMode()).toBe('disabled')
|
||||||
|
await queueOpts.setMode('change')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
expect(await queueOpts.getMode()).toBe('change')
|
||||||
|
await comfyPage.actionbar.queueButton.toggleOptions()
|
||||||
|
|
||||||
|
// Intercept the prompt queue endpoint
|
||||||
|
let promptNumber = 0
|
||||||
|
comfyPage.page.route('**/api/prompt', async (route, req) => {
|
||||||
|
await new Promise((r) => setTimeout(r, 100))
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt_id: promptNumber,
|
||||||
|
number: ++promptNumber,
|
||||||
|
node_errors: {},
|
||||||
|
// Include the request data to validate which prompt was queued so we can validate the width
|
||||||
|
__request: req.postDataJSON()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start watching for a message to prompt
|
||||||
|
const requestPromise = comfyPage.page.waitForResponse('**/api/prompt')
|
||||||
|
|
||||||
|
// Find and set the width on the latent node
|
||||||
|
const triggerChange = async (value: number) => {
|
||||||
|
return await comfyPage.page.evaluate((value) => {
|
||||||
|
const node = window['app'].graph._nodes.find(
|
||||||
|
(n) => n.type === 'EmptyLatentImage'
|
||||||
|
)
|
||||||
|
node.widgets[0].value = value
|
||||||
|
window[
|
||||||
|
'app'
|
||||||
|
].extensionManager.workflow.activeWorkflow.changeTracker.checkState()
|
||||||
|
}, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger a status websocket message
|
||||||
|
const triggerStatus = async (queueSize: number) => {
|
||||||
|
await ws.trigger({
|
||||||
|
type: 'status',
|
||||||
|
data: {
|
||||||
|
status: {
|
||||||
|
exec_info: {
|
||||||
|
queue_remaining: queueSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as StatusWsMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the width from the queue response
|
||||||
|
const getQueuedWidth = async (resp: Promise<Response>) => {
|
||||||
|
const obj = await (await resp).json()
|
||||||
|
return obj['__request']['prompt']['5']['inputs']['width']
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger a bunch of changes
|
||||||
|
const START = 32
|
||||||
|
const END = 64
|
||||||
|
for (let i = START; i <= END; i += 8) {
|
||||||
|
await triggerChange(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the queued width is the first value
|
||||||
|
expect(
|
||||||
|
await getQueuedWidth(requestPromise),
|
||||||
|
'the first queued prompt should be the first change width'
|
||||||
|
).toBe(START)
|
||||||
|
|
||||||
|
// Ensure that no other changes are queued
|
||||||
|
await expect(
|
||||||
|
comfyPage.page.waitForResponse('**/api/prompt', { timeout: 250 })
|
||||||
|
).rejects.toThrow()
|
||||||
|
expect(
|
||||||
|
promptNumber,
|
||||||
|
'only 1 prompt should have been queued even though there were multiple changes'
|
||||||
|
).toBe(1)
|
||||||
|
|
||||||
|
// Trigger a status update so auto-queue re-runs
|
||||||
|
await triggerStatus(1)
|
||||||
|
await triggerStatus(0)
|
||||||
|
|
||||||
|
// Ensure the queued width is the last queued value
|
||||||
|
expect(
|
||||||
|
await getQueuedWidth(comfyPage.page.waitForResponse('**/api/prompt')),
|
||||||
|
'last queued prompt width should be the last change'
|
||||||
|
).toBe(END)
|
||||||
|
expect(promptNumber, 'queued prompt count should be 2').toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Can dock actionbar into top menu', async ({ comfyPage }) => {
|
||||||
|
await comfyPage.page.dragAndDrop(
|
||||||
|
'.actionbar .drag-handle',
|
||||||
|
'.comfyui-menu',
|
||||||
|
{
|
||||||
|
targetPosition: { x: 0, y: 0 }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
expect(await comfyPage.actionbar.isDocked()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
37
browser_tests/assets/collapsed_multiline.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"last_node_id": 1,
|
||||||
|
"last_link_id": 0,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "CLIPTextEncode",
|
||||||
|
"pos": [20, 50],
|
||||||
|
"size": [400, 200],
|
||||||
|
"flags": { "collapsed": true },
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "clip",
|
||||||
|
"type": "CLIP",
|
||||||
|
"link": null,
|
||||||
|
"localized_name": "clip"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "CONDITIONING",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"links": null,
|
||||||
|
"localized_name": "CONDITIONING"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {},
|
||||||
|
"widgets_values": ["Should not be displayed."]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [],
|
||||||
|
"groups": [],
|
||||||
|
"config": {},
|
||||||
|
"version": 0.4
|
||||||
|
}
|
||||||
135
browser_tests/assets/default.json
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
{
|
||||||
|
"last_node_id": 9,
|
||||||
|
"last_link_id": 9,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"type": "CLIPTextEncode",
|
||||||
|
"pos": [413, 389],
|
||||||
|
"size": [425.27801513671875, 180.6060791015625],
|
||||||
|
"flags": {},
|
||||||
|
"order": 3,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [{ "name": "clip", "type": "CLIP", "link": 5 }],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "CONDITIONING",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"links": [6],
|
||||||
|
"slot_index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {},
|
||||||
|
"widgets_values": ["text, watermark"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"type": "CLIPTextEncode",
|
||||||
|
"pos": [415, 186],
|
||||||
|
"size": [422.84503173828125, 164.31304931640625],
|
||||||
|
"flags": {},
|
||||||
|
"order": 2,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [{ "name": "clip", "type": "CLIP", "link": 3 }],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "CONDITIONING",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"links": [4],
|
||||||
|
"slot_index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {},
|
||||||
|
"widgets_values": [
|
||||||
|
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"type": "EmptyLatentImage",
|
||||||
|
"pos": [473, 609],
|
||||||
|
"size": [315, 106],
|
||||||
|
"flags": {},
|
||||||
|
"order": 1,
|
||||||
|
"mode": 0,
|
||||||
|
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [2], "slot_index": 0 }],
|
||||||
|
"properties": {},
|
||||||
|
"widgets_values": [512, 512, 1]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"type": "KSampler",
|
||||||
|
"pos": [863, 186],
|
||||||
|
"size": [315, 262],
|
||||||
|
"flags": {},
|
||||||
|
"order": 4,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{ "name": "model", "type": "MODEL", "link": 1 },
|
||||||
|
{ "name": "positive", "type": "CONDITIONING", "link": 4 },
|
||||||
|
{ "name": "negative", "type": "CONDITIONING", "link": 6 },
|
||||||
|
{ "name": "latent_image", "type": "LATENT", "link": 2 }
|
||||||
|
],
|
||||||
|
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [7], "slot_index": 0 }],
|
||||||
|
"properties": {},
|
||||||
|
"widgets_values": [156680208700286, true, 20, 8, "euler", "normal", 1]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"type": "VAEDecode",
|
||||||
|
"pos": [1209, 188],
|
||||||
|
"size": [210, 46],
|
||||||
|
"flags": {},
|
||||||
|
"order": 5,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{ "name": "samples", "type": "LATENT", "link": 7 },
|
||||||
|
{ "name": "vae", "type": "VAE", "link": 8 }
|
||||||
|
],
|
||||||
|
"outputs": [{ "name": "IMAGE", "type": "IMAGE", "links": [9], "slot_index": 0 }],
|
||||||
|
"properties": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"type": "SaveImage",
|
||||||
|
"pos": [1451, 189],
|
||||||
|
"size": [210, 26],
|
||||||
|
"flags": {},
|
||||||
|
"order": 6,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [{ "name": "images", "type": "IMAGE", "link": 9 }],
|
||||||
|
"properties": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"type": "CheckpointLoaderSimple",
|
||||||
|
"pos": [26, 474],
|
||||||
|
"size": [315, 98],
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"outputs": [
|
||||||
|
{ "name": "MODEL", "type": "MODEL", "links": [1], "slot_index": 0 },
|
||||||
|
{ "name": "CLIP", "type": "CLIP", "links": [3, 5], "slot_index": 1 },
|
||||||
|
{ "name": "VAE", "type": "VAE", "links": [8], "slot_index": 2 }
|
||||||
|
],
|
||||||
|
"properties": {},
|
||||||
|
"widgets_values": ["v1-5-pruned-emaonly.ckpt"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [
|
||||||
|
[1, 4, 0, 3, 0, "MODEL"],
|
||||||
|
[2, 5, 0, 3, 3, "LATENT"],
|
||||||
|
[3, 4, 1, 6, 0, "CLIP"],
|
||||||
|
[4, 6, 0, 3, 1, "CONDITIONING"],
|
||||||
|
[5, 4, 1, 7, 0, "CLIP"],
|
||||||
|
[6, 7, 0, 3, 2, "CONDITIONING"],
|
||||||
|
[7, 3, 0, 8, 0, "LATENT"],
|
||||||
|
[8, 4, 2, 8, 1, "VAE"],
|
||||||
|
[9, 8, 0, 9, 0, "IMAGE"]
|
||||||
|
],
|
||||||
|
"groups": [],
|
||||||
|
"config": {},
|
||||||
|
"extra": {},
|
||||||
|
"version": 0.4
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 4.5 KiB |
504
browser_tests/assets/every_node_color.json
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
{
|
||||||
|
"last_node_id": 13,
|
||||||
|
"last_link_id": 9,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"type": "KSampler",
|
||||||
|
"pos": {
|
||||||
|
"0": 863,
|
||||||
|
"1": 186
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"0": 315,
|
||||||
|
"1": 262
|
||||||
|
},
|
||||||
|
"flags": {},
|
||||||
|
"order": 7,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "model",
|
||||||
|
"type": "MODEL",
|
||||||
|
"link": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "positive",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"link": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "negative",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"link": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "latent_image",
|
||||||
|
"type": "LATENT",
|
||||||
|
"link": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "LATENT",
|
||||||
|
"type": "LATENT",
|
||||||
|
"links": [
|
||||||
|
7
|
||||||
|
],
|
||||||
|
"slot_index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "KSampler"
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
156680208700286,
|
||||||
|
"randomize",
|
||||||
|
20,
|
||||||
|
8,
|
||||||
|
"euler",
|
||||||
|
"normal",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"color": "#432",
|
||||||
|
"bgcolor": "#653"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"type": "CheckpointLoaderSimple",
|
||||||
|
"pos": {
|
||||||
|
"0": 36,
|
||||||
|
"1": 172
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"0": 315,
|
||||||
|
"1": 98
|
||||||
|
},
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "MODEL",
|
||||||
|
"type": "MODEL",
|
||||||
|
"links": [
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"slot_index": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CLIP",
|
||||||
|
"type": "CLIP",
|
||||||
|
"links": [
|
||||||
|
3,
|
||||||
|
5
|
||||||
|
],
|
||||||
|
"slot_index": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "VAE",
|
||||||
|
"type": "VAE",
|
||||||
|
"links": [
|
||||||
|
8
|
||||||
|
],
|
||||||
|
"slot_index": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "CheckpointLoaderSimple"
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
"Stable-diffusion/v1-5-pruned-emaonly.safetensors"
|
||||||
|
],
|
||||||
|
"color": "#322",
|
||||||
|
"bgcolor": "#533"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"type": "EmptyLatentImage",
|
||||||
|
"pos": {
|
||||||
|
"0": 473,
|
||||||
|
"1": 609
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"0": 315,
|
||||||
|
"1": 106
|
||||||
|
},
|
||||||
|
"flags": {},
|
||||||
|
"order": 1,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "LATENT",
|
||||||
|
"type": "LATENT",
|
||||||
|
"links": [
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"slot_index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "EmptyLatentImage"
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
512,
|
||||||
|
512,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"color": "#323",
|
||||||
|
"bgcolor": "#535"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"type": "CLIPTextEncode",
|
||||||
|
"pos": {
|
||||||
|
"0": 415,
|
||||||
|
"1": 186
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"0": 422.84503173828125,
|
||||||
|
"1": 164.31304931640625
|
||||||
|
},
|
||||||
|
"flags": {},
|
||||||
|
"order": 5,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "clip",
|
||||||
|
"type": "CLIP",
|
||||||
|
"link": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "CONDITIONING",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"links": [
|
||||||
|
4
|
||||||
|
],
|
||||||
|
"slot_index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "CLIPTextEncode"
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||||
|
],
|
||||||
|
"color": "#233",
|
||||||
|
"bgcolor": "#355"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"type": "CLIPTextEncode",
|
||||||
|
"pos": {
|
||||||
|
"0": 413,
|
||||||
|
"1": 389
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"0": 425.27801513671875,
|
||||||
|
"1": 180.6060791015625
|
||||||
|
},
|
||||||
|
"flags": {},
|
||||||
|
"order": 6,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "clip",
|
||||||
|
"type": "CLIP",
|
||||||
|
"link": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "CONDITIONING",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"links": [
|
||||||
|
6
|
||||||
|
],
|
||||||
|
"slot_index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "CLIPTextEncode"
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
"text, watermark"
|
||||||
|
],
|
||||||
|
"color": "#323",
|
||||||
|
"bgcolor": "#535"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"type": "VAEDecode",
|
||||||
|
"pos": {
|
||||||
|
"0": 866,
|
||||||
|
"1": 502
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"0": 210,
|
||||||
|
"1": 46
|
||||||
|
},
|
||||||
|
"flags": {},
|
||||||
|
"order": 8,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "samples",
|
||||||
|
"type": "LATENT",
|
||||||
|
"link": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "vae",
|
||||||
|
"type": "VAE",
|
||||||
|
"link": 8
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "IMAGE",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"links": [
|
||||||
|
9
|
||||||
|
],
|
||||||
|
"slot_index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "VAEDecode"
|
||||||
|
},
|
||||||
|
"widgets_values": [],
|
||||||
|
"color": "#222",
|
||||||
|
"bgcolor": "#000"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"type": "SaveImage",
|
||||||
|
"pos": {
|
||||||
|
"0": 857,
|
||||||
|
"1": 611
|
||||||
|
},
|
||||||
|
"size": [
|
||||||
|
214.2000732421875,
|
||||||
|
59.4000244140625
|
||||||
|
],
|
||||||
|
"flags": {},
|
||||||
|
"order": 9,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "images",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"link": 9
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [],
|
||||||
|
"properties": {},
|
||||||
|
"widgets_values": [
|
||||||
|
"ComfyUI"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"type": "CheckpointLoaderSimple",
|
||||||
|
"pos": {
|
||||||
|
"0": 42,
|
||||||
|
"1": 329
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"0": 315,
|
||||||
|
"1": 98
|
||||||
|
},
|
||||||
|
"flags": {},
|
||||||
|
"order": 2,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "MODEL",
|
||||||
|
"type": "MODEL",
|
||||||
|
"links": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CLIP",
|
||||||
|
"type": "CLIP",
|
||||||
|
"links": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "VAE",
|
||||||
|
"type": "VAE",
|
||||||
|
"links": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "CheckpointLoaderSimple"
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
"Stable-diffusion/v1-5-pruned-emaonly.safetensors"
|
||||||
|
],
|
||||||
|
"color": "#332922",
|
||||||
|
"bgcolor": "#593930"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"type": "CheckpointLoaderSimple",
|
||||||
|
"pos": {
|
||||||
|
"0": 40,
|
||||||
|
"1": 494
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"0": 315,
|
||||||
|
"1": 98
|
||||||
|
},
|
||||||
|
"flags": {},
|
||||||
|
"order": 3,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "MODEL",
|
||||||
|
"type": "MODEL",
|
||||||
|
"links": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CLIP",
|
||||||
|
"type": "CLIP",
|
||||||
|
"links": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "VAE",
|
||||||
|
"type": "VAE",
|
||||||
|
"links": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "CheckpointLoaderSimple"
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
"Stable-diffusion/v1-5-pruned-emaonly.safetensors"
|
||||||
|
],
|
||||||
|
"color": "#223",
|
||||||
|
"bgcolor": "#335"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 13,
|
||||||
|
"type": "ImageScale",
|
||||||
|
"pos": {
|
||||||
|
"0": 42,
|
||||||
|
"1": 650
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"0": 315,
|
||||||
|
"1": 130
|
||||||
|
},
|
||||||
|
"flags": {},
|
||||||
|
"order": 4,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "image",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"link": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "IMAGE",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"links": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "ImageScale"
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
"nearest-exact",
|
||||||
|
512,
|
||||||
|
512,
|
||||||
|
"disabled"
|
||||||
|
],
|
||||||
|
"color": "#2a363b",
|
||||||
|
"bgcolor": "#3f5159"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
4,
|
||||||
|
0,
|
||||||
|
3,
|
||||||
|
0,
|
||||||
|
"MODEL"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
2,
|
||||||
|
5,
|
||||||
|
0,
|
||||||
|
3,
|
||||||
|
3,
|
||||||
|
"LATENT"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
3,
|
||||||
|
4,
|
||||||
|
1,
|
||||||
|
6,
|
||||||
|
0,
|
||||||
|
"CLIP"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
4,
|
||||||
|
6,
|
||||||
|
0,
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
"CONDITIONING"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
5,
|
||||||
|
4,
|
||||||
|
1,
|
||||||
|
7,
|
||||||
|
0,
|
||||||
|
"CLIP"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
6,
|
||||||
|
7,
|
||||||
|
0,
|
||||||
|
3,
|
||||||
|
2,
|
||||||
|
"CONDITIONING"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
7,
|
||||||
|
3,
|
||||||
|
0,
|
||||||
|
8,
|
||||||
|
0,
|
||||||
|
"LATENT"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
8,
|
||||||
|
4,
|
||||||
|
2,
|
||||||
|
8,
|
||||||
|
1,
|
||||||
|
"VAE"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
9,
|
||||||
|
8,
|
||||||
|
0,
|
||||||
|
9,
|
||||||
|
0,
|
||||||
|
"IMAGE"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"groups": [],
|
||||||
|
"config": {},
|
||||||
|
"extra": {},
|
||||||
|
"version": 0.4
|
||||||
|
}
|
||||||
BIN
browser_tests/assets/example.webp
Normal file
|
After Width: | Height: | Size: 828 B |
62
browser_tests/assets/force_input.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"last_node_id": 5,
|
||||||
|
"last_link_id": 0,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"type": "DevToolsNodeWithForceInput",
|
||||||
|
"pos": {
|
||||||
|
"0": 9,
|
||||||
|
"1": 39
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"0": 315,
|
||||||
|
"1": 106
|
||||||
|
},
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "int_input",
|
||||||
|
"type": "INT",
|
||||||
|
"link": null,
|
||||||
|
"widget": {
|
||||||
|
"name": "int_input"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "float_input",
|
||||||
|
"type": "FLOAT",
|
||||||
|
"link": null,
|
||||||
|
"widget": {
|
||||||
|
"name": "float_input"
|
||||||
|
},
|
||||||
|
"shape": 7
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "DevToolsNodeWithForceInput"
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [],
|
||||||
|
"groups": [],
|
||||||
|
"config": {},
|
||||||
|
"extra": {
|
||||||
|
"ds": {
|
||||||
|
"scale": 1,
|
||||||
|
"offset": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": 0.4
|
||||||
|
}
|
||||||
404
browser_tests/assets/group_node_v1.3.3.json
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
{
|
||||||
|
"last_node_id": 10,
|
||||||
|
"last_link_id": 9,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"type": "workflow>group_node",
|
||||||
|
"pos": {
|
||||||
|
"0": 26,
|
||||||
|
"1": 186
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"0": 400,
|
||||||
|
"1": 390
|
||||||
|
},
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "workflow>group_node"
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
512,
|
||||||
|
512,
|
||||||
|
1,
|
||||||
|
"v1-5-pruned-emaonly.ckpt",
|
||||||
|
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,",
|
||||||
|
"text, watermark",
|
||||||
|
156680208700286,
|
||||||
|
"randomize",
|
||||||
|
20,
|
||||||
|
8,
|
||||||
|
"euler",
|
||||||
|
"normal",
|
||||||
|
1,
|
||||||
|
"ComfyUI"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [],
|
||||||
|
"groups": [],
|
||||||
|
"config": {},
|
||||||
|
"extra": {
|
||||||
|
"groupNodes": {
|
||||||
|
"group_node": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": -1,
|
||||||
|
"type": "EmptyLatentImage",
|
||||||
|
"pos": {
|
||||||
|
"0": 473,
|
||||||
|
"1": 609
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"0": 315,
|
||||||
|
"1": 106
|
||||||
|
},
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "LATENT",
|
||||||
|
"type": "LATENT",
|
||||||
|
"links": [],
|
||||||
|
"slot_index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "EmptyLatentImage"
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
512,
|
||||||
|
512,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"index": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": -1,
|
||||||
|
"type": "CheckpointLoaderSimple",
|
||||||
|
"pos": {
|
||||||
|
"0": 26,
|
||||||
|
"1": 474
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"0": 315,
|
||||||
|
"1": 98
|
||||||
|
},
|
||||||
|
"flags": {},
|
||||||
|
"order": 1,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "MODEL",
|
||||||
|
"type": "MODEL",
|
||||||
|
"links": [],
|
||||||
|
"slot_index": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CLIP",
|
||||||
|
"type": "CLIP",
|
||||||
|
"links": [],
|
||||||
|
"slot_index": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "VAE",
|
||||||
|
"type": "VAE",
|
||||||
|
"links": [],
|
||||||
|
"slot_index": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "CheckpointLoaderSimple"
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
"v1-5-pruned-emaonly.ckpt"
|
||||||
|
],
|
||||||
|
"index": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": -1,
|
||||||
|
"type": "CLIPTextEncode",
|
||||||
|
"pos": {
|
||||||
|
"0": 415,
|
||||||
|
"1": 186
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"0": 422.84503173828125,
|
||||||
|
"1": 164.31304931640625
|
||||||
|
},
|
||||||
|
"flags": {},
|
||||||
|
"order": 2,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "clip",
|
||||||
|
"type": "CLIP",
|
||||||
|
"link": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "CONDITIONING",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"links": [],
|
||||||
|
"slot_index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "CLIPTextEncode"
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||||
|
],
|
||||||
|
"index": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": -1,
|
||||||
|
"type": "CLIPTextEncode",
|
||||||
|
"pos": {
|
||||||
|
"0": 413,
|
||||||
|
"1": 389
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"0": 425.27801513671875,
|
||||||
|
"1": 180.6060791015625
|
||||||
|
},
|
||||||
|
"flags": {},
|
||||||
|
"order": 3,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "clip",
|
||||||
|
"type": "CLIP",
|
||||||
|
"link": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "CONDITIONING",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"links": [],
|
||||||
|
"slot_index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "CLIPTextEncode"
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
"text, watermark"
|
||||||
|
],
|
||||||
|
"index": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": -1,
|
||||||
|
"type": "KSampler",
|
||||||
|
"pos": {
|
||||||
|
"0": 863,
|
||||||
|
"1": 186
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"0": 315,
|
||||||
|
"1": 262
|
||||||
|
},
|
||||||
|
"flags": {},
|
||||||
|
"order": 4,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "model",
|
||||||
|
"type": "MODEL",
|
||||||
|
"link": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "positive",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"link": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "negative",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"link": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "latent_image",
|
||||||
|
"type": "LATENT",
|
||||||
|
"link": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "LATENT",
|
||||||
|
"type": "LATENT",
|
||||||
|
"links": [],
|
||||||
|
"slot_index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "KSampler"
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
156680208700286,
|
||||||
|
"randomize",
|
||||||
|
20,
|
||||||
|
8,
|
||||||
|
"euler",
|
||||||
|
"normal",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"index": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": -1,
|
||||||
|
"type": "VAEDecode",
|
||||||
|
"pos": {
|
||||||
|
"0": 1209,
|
||||||
|
"1": 188
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"0": 210,
|
||||||
|
"1": 46
|
||||||
|
},
|
||||||
|
"flags": {},
|
||||||
|
"order": 5,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "samples",
|
||||||
|
"type": "LATENT",
|
||||||
|
"link": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "vae",
|
||||||
|
"type": "VAE",
|
||||||
|
"link": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "IMAGE",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"links": [],
|
||||||
|
"slot_index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "VAEDecode"
|
||||||
|
},
|
||||||
|
"index": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": -1,
|
||||||
|
"type": "SaveImage",
|
||||||
|
"pos": {
|
||||||
|
"0": 1451,
|
||||||
|
"1": 189
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"0": 210,
|
||||||
|
"1": 58
|
||||||
|
},
|
||||||
|
"flags": {},
|
||||||
|
"order": 6,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "images",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"link": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [],
|
||||||
|
"properties": {},
|
||||||
|
"widgets_values": [
|
||||||
|
"ComfyUI"
|
||||||
|
],
|
||||||
|
"index": 6
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
4,
|
||||||
|
"CLIP"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
3,
|
||||||
|
0,
|
||||||
|
4,
|
||||||
|
"CLIP"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
4,
|
||||||
|
0,
|
||||||
|
4,
|
||||||
|
"MODEL"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
4,
|
||||||
|
1,
|
||||||
|
6,
|
||||||
|
"CONDITIONING"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
3,
|
||||||
|
0,
|
||||||
|
4,
|
||||||
|
2,
|
||||||
|
7,
|
||||||
|
"CONDITIONING"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
4,
|
||||||
|
3,
|
||||||
|
5,
|
||||||
|
"LATENT"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
4,
|
||||||
|
0,
|
||||||
|
5,
|
||||||
|
0,
|
||||||
|
3,
|
||||||
|
"LATENT"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
5,
|
||||||
|
1,
|
||||||
|
4,
|
||||||
|
"VAE"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
5,
|
||||||
|
0,
|
||||||
|
6,
|
||||||
|
0,
|
||||||
|
8,
|
||||||
|
"IMAGE"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"external": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": 0.4
|
||||||
|
}
|
||||||
BIN
browser_tests/assets/image32x32.webp
Normal file
|
After Width: | Height: | Size: 488 B |
BIN
browser_tests/assets/image64x64.webp
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 316 KiB After Width: | Height: | Size: 260 KiB |
252
browser_tests/assets/legacy_group_node.json
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
{
|
||||||
|
"last_node_id": 15,
|
||||||
|
"last_link_id": 9,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"type": "workflow/hello",
|
||||||
|
"pos": {
|
||||||
|
"0": 566,
|
||||||
|
"1": 316
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"0": 468.5999755859375,
|
||||||
|
"1": 582
|
||||||
|
},
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "model",
|
||||||
|
"type": "MODEL",
|
||||||
|
"link": null,
|
||||||
|
"label": "model"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "positive",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"link": null,
|
||||||
|
"label": "positive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "negative",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"link": null,
|
||||||
|
"label": "negative"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "latent_image",
|
||||||
|
"type": "LATENT",
|
||||||
|
"link": null,
|
||||||
|
"label": "latent_image"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "KSampler model",
|
||||||
|
"type": "MODEL",
|
||||||
|
"link": null,
|
||||||
|
"label": "KSampler model"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "KSampler positive",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"link": null,
|
||||||
|
"label": "KSampler positive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "KSampler negative",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"link": null,
|
||||||
|
"label": "KSampler negative"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "KSampler latent_image",
|
||||||
|
"type": "LATENT",
|
||||||
|
"link": null,
|
||||||
|
"label": "KSampler latent_image"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "LATENT",
|
||||||
|
"type": "LATENT",
|
||||||
|
"links": null,
|
||||||
|
"shape": 3,
|
||||||
|
"label": "LATENT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "KSampler LATENT",
|
||||||
|
"type": "LATENT",
|
||||||
|
"links": null,
|
||||||
|
"shape": 3,
|
||||||
|
"label": "KSampler LATENT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "workflow/hello"
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
"enable",
|
||||||
|
0,
|
||||||
|
"randomize",
|
||||||
|
20,
|
||||||
|
8,
|
||||||
|
"euler",
|
||||||
|
"normal",
|
||||||
|
0,
|
||||||
|
10000,
|
||||||
|
"disable",
|
||||||
|
0,
|
||||||
|
"randomize",
|
||||||
|
20,
|
||||||
|
8,
|
||||||
|
"euler",
|
||||||
|
"normal",
|
||||||
|
1
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [],
|
||||||
|
"groups": [],
|
||||||
|
"config": {},
|
||||||
|
"extra": {
|
||||||
|
"groupNodes": {
|
||||||
|
"hello": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": -1,
|
||||||
|
"type": "KSamplerAdvanced",
|
||||||
|
"pos": {
|
||||||
|
"0": 351.3332824707031,
|
||||||
|
"1": 577.3333129882812
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"0": 315,
|
||||||
|
"1": 334
|
||||||
|
},
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "model",
|
||||||
|
"type": "MODEL",
|
||||||
|
"link": null,
|
||||||
|
"label": "model"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "positive",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"link": null,
|
||||||
|
"label": "positive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "negative",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"link": null,
|
||||||
|
"label": "negative"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "latent_image",
|
||||||
|
"type": "LATENT",
|
||||||
|
"link": null,
|
||||||
|
"label": "latent_image"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "LATENT",
|
||||||
|
"type": "LATENT",
|
||||||
|
"links": null,
|
||||||
|
"shape": 3,
|
||||||
|
"label": "LATENT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "KSamplerAdvanced"
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
"enable",
|
||||||
|
0,
|
||||||
|
"randomize",
|
||||||
|
20,
|
||||||
|
8,
|
||||||
|
"euler",
|
||||||
|
"normal",
|
||||||
|
0,
|
||||||
|
10000,
|
||||||
|
"disable"
|
||||||
|
],
|
||||||
|
"index": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": -1,
|
||||||
|
"type": "KSampler",
|
||||||
|
"pos": {
|
||||||
|
"0": 636,
|
||||||
|
"1": 427
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"0": 315,
|
||||||
|
"1": 262
|
||||||
|
},
|
||||||
|
"flags": {},
|
||||||
|
"order": 1,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "model",
|
||||||
|
"type": "MODEL",
|
||||||
|
"link": null,
|
||||||
|
"label": "model"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "positive",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"link": null,
|
||||||
|
"label": "positive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "negative",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"link": null,
|
||||||
|
"label": "negative"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "latent_image",
|
||||||
|
"type": "LATENT",
|
||||||
|
"link": null,
|
||||||
|
"label": "latent_image"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "LATENT",
|
||||||
|
"type": "LATENT",
|
||||||
|
"links": null,
|
||||||
|
"shape": 3,
|
||||||
|
"label": "LATENT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "KSampler"
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
0,
|
||||||
|
"randomize",
|
||||||
|
20,
|
||||||
|
8,
|
||||||
|
"euler",
|
||||||
|
"normal",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"index": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [],
|
||||||
|
"external": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": 0.4
|
||||||
|
}
|
||||||
227
browser_tests/assets/mixed_graph_items.json
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
{
|
||||||
|
"last_node_id": 3,
|
||||||
|
"last_link_id": 0,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"type": "KSampler",
|
||||||
|
"pos": {
|
||||||
|
"0": 420,
|
||||||
|
"1": 130
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"0": 315,
|
||||||
|
"1": 262
|
||||||
|
},
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "model",
|
||||||
|
"type": "MODEL",
|
||||||
|
"link": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "positive",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"link": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "negative",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"link": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "latent_image",
|
||||||
|
"type": "LATENT",
|
||||||
|
"link": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "LATENT",
|
||||||
|
"type": "LATENT",
|
||||||
|
"links": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "KSampler"
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
0,
|
||||||
|
"randomize",
|
||||||
|
20,
|
||||||
|
8,
|
||||||
|
"euler",
|
||||||
|
"normal",
|
||||||
|
1
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"type": "KSampler",
|
||||||
|
"pos": {
|
||||||
|
"0": 820,
|
||||||
|
"1": 130
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"0": 315,
|
||||||
|
"1": 262
|
||||||
|
},
|
||||||
|
"flags": {},
|
||||||
|
"order": 1,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "model",
|
||||||
|
"type": "MODEL",
|
||||||
|
"link": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "positive",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"link": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "negative",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"link": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "latent_image",
|
||||||
|
"type": "LATENT",
|
||||||
|
"link": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "LATENT",
|
||||||
|
"type": "LATENT",
|
||||||
|
"links": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "KSampler"
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
0,
|
||||||
|
"randomize",
|
||||||
|
20,
|
||||||
|
8,
|
||||||
|
"euler",
|
||||||
|
"normal",
|
||||||
|
1
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "KSampler",
|
||||||
|
"pos": {
|
||||||
|
"0": 30,
|
||||||
|
"1": 130
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"0": 315,
|
||||||
|
"1": 262
|
||||||
|
},
|
||||||
|
"flags": {},
|
||||||
|
"order": 2,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "model",
|
||||||
|
"type": "MODEL",
|
||||||
|
"link": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "positive",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"link": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "negative",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"link": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "latent_image",
|
||||||
|
"type": "LATENT",
|
||||||
|
"link": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "LATENT",
|
||||||
|
"type": "LATENT",
|
||||||
|
"links": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "KSampler"
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
0,
|
||||||
|
"randomize",
|
||||||
|
20,
|
||||||
|
8,
|
||||||
|
"euler",
|
||||||
|
"normal",
|
||||||
|
1
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [],
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"title": "Group",
|
||||||
|
"bounding": [
|
||||||
|
406.9701232910156,
|
||||||
|
59.079444885253906,
|
||||||
|
335,
|
||||||
|
345.6000061035156
|
||||||
|
],
|
||||||
|
"color": "#3f789e",
|
||||||
|
"font_size": 24,
|
||||||
|
"flags": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"title": "Group Parent",
|
||||||
|
"bounding": [
|
||||||
|
796.9703979492188,
|
||||||
|
14.796443939208984,
|
||||||
|
355,
|
||||||
|
399.20001220703125
|
||||||
|
],
|
||||||
|
"color": "#3f789e",
|
||||||
|
"font_size": 24,
|
||||||
|
"flags": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"title": "Group Child",
|
||||||
|
"bounding": [
|
||||||
|
806.9703979492188,
|
||||||
|
58.39643096923828,
|
||||||
|
335,
|
||||||
|
345.6000061035156
|
||||||
|
],
|
||||||
|
"color": "#3f789e",
|
||||||
|
"font_size": 24,
|
||||||
|
"flags": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"config": {},
|
||||||
|
"extra": {
|
||||||
|
"ds": {
|
||||||
|
"scale": 1,
|
||||||
|
"offset": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": 0.4
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 152 B |
128
browser_tests/assets/old_workflow_converted_input.json
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
{
|
||||||
|
"last_node_id": 2,
|
||||||
|
"last_link_id": 1,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "ControlNetApplyAdvanced",
|
||||||
|
"pos": {
|
||||||
|
"0": 449,
|
||||||
|
"1": 204
|
||||||
|
},
|
||||||
|
"size": [
|
||||||
|
340.20001220703125,
|
||||||
|
166
|
||||||
|
],
|
||||||
|
"flags": {},
|
||||||
|
"order": 1,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "positive",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"link": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "negative",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"link": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "control_net",
|
||||||
|
"type": "CONTROL_NET",
|
||||||
|
"link": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "image",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"link": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "strength",
|
||||||
|
"type": "FLOAT",
|
||||||
|
"link": 1,
|
||||||
|
"widget": {
|
||||||
|
"name": "strength"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "positive",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"links": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "negative",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"links": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "ControlNetApplyAdvanced"
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"type": "PrimitiveNode",
|
||||||
|
"pos": {
|
||||||
|
"0": 177,
|
||||||
|
"1": 265
|
||||||
|
},
|
||||||
|
"size": [
|
||||||
|
210,
|
||||||
|
82
|
||||||
|
],
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "FLOAT",
|
||||||
|
"type": "FLOAT",
|
||||||
|
"links": [
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"widget": {
|
||||||
|
"name": "strength"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Run widget replace on values": false
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
1,
|
||||||
|
"fixed"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
4,
|
||||||
|
"FLOAT"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"groups": [],
|
||||||
|
"config": {},
|
||||||
|
"extra": {
|
||||||
|
"ds": {
|
||||||
|
"scale": 1,
|
||||||
|
"offset": {
|
||||||
|
"0": 47.541666666666515,
|
||||||
|
"1": 186.9375
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": 0.4
|
||||||
|
}
|
||||||
44
browser_tests/assets/only_optional_inputs.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"last_node_id": 11,
|
||||||
|
"last_link_id": 9,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"type": "DevToolsNodeWithOnlyOptionalInput",
|
||||||
|
"pos": {
|
||||||
|
"0": 150,
|
||||||
|
"1": 464.2916564941406
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"0": 400,
|
||||||
|
"1": 200
|
||||||
|
},
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "clip",
|
||||||
|
"type": "CLIP",
|
||||||
|
"link": null,
|
||||||
|
"shape": 7,
|
||||||
|
"label": "clip"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "DevToolsNodeWithOnlyOptionalInput"
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
""
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [],
|
||||||
|
"groups": [],
|
||||||
|
"config": {},
|
||||||
|
"extra": {
|
||||||
|
"groupNodes": {}
|
||||||
|
},
|
||||||
|
"version": 0.4
|
||||||
|
}
|
||||||
57
browser_tests/assets/optional_input_correct_shape.json
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"last_node_id": 5,
|
||||||
|
"last_link_id": 0,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"type": "DevToolsNodeWithOptionalInput",
|
||||||
|
"pos": {
|
||||||
|
"0": 19,
|
||||||
|
"1": 46
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"0": 302.4000244140625,
|
||||||
|
"1": 46
|
||||||
|
},
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "required_input",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"link": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "optional_input",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"link": null,
|
||||||
|
"shape": 7
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "IMAGE",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"links": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "DevToolsNodeWithOptionalInput"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [],
|
||||||
|
"groups": [],
|
||||||
|
"config": {},
|
||||||
|
"extra": {
|
||||||
|
"ds": {
|
||||||
|
"scale": 1,
|
||||||
|
"offset": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": 0.4
|
||||||
|
}
|
||||||
56
browser_tests/assets/optional_input_no_shape.json
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"last_node_id": 5,
|
||||||
|
"last_link_id": 0,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"type": "DevToolsNodeWithOptionalInput",
|
||||||
|
"pos": {
|
||||||
|
"0": 19,
|
||||||
|
"1": 46
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"0": 302.4000244140625,
|
||||||
|
"1": 46
|
||||||
|
},
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "required_input",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"link": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "optional_input",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"link": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "IMAGE",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"links": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "DevToolsNodeWithOptionalInput"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [],
|
||||||
|
"groups": [],
|
||||||
|
"config": {},
|
||||||
|
"extra": {
|
||||||
|
"ds": {
|
||||||
|
"scale": 1,
|
||||||
|
"offset": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": 0.4
|
||||||
|
}
|
||||||
57
browser_tests/assets/optional_input_wrong_shape.json
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"last_node_id": 5,
|
||||||
|
"last_link_id": 0,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"type": "DevToolsNodeWithOptionalInput",
|
||||||
|
"pos": {
|
||||||
|
"0": 19,
|
||||||
|
"1": 46
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"0": 302.4000244140625,
|
||||||
|
"1": 46
|
||||||
|
},
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "required_input",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"link": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "optional_input",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"link": null,
|
||||||
|
"shape": 6
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "IMAGE",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"links": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "DevToolsNodeWithOptionalInput"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [],
|
||||||
|
"groups": [],
|
||||||
|
"config": {},
|
||||||
|
"extra": {
|
||||||
|
"ds": {
|
||||||
|
"scale": 1,
|
||||||
|
"offset": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": 0.4
|
||||||
|
}
|
||||||
90
browser_tests/assets/oversized_group.json
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
{
|
||||||
|
"last_node_id": 9,
|
||||||
|
"last_link_id": 9,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"type": "KSampler",
|
||||||
|
"pos": [
|
||||||
|
37,
|
||||||
|
98
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
315,
|
||||||
|
262
|
||||||
|
],
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "model",
|
||||||
|
"type": "MODEL",
|
||||||
|
"link": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "positive",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"link": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "negative",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"link": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "latent_image",
|
||||||
|
"type": "LATENT",
|
||||||
|
"link": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "LATENT",
|
||||||
|
"type": "LATENT",
|
||||||
|
"links": [],
|
||||||
|
"slot_index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "KSampler"
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
156680208700286,
|
||||||
|
"randomize",
|
||||||
|
20,
|
||||||
|
8,
|
||||||
|
"euler",
|
||||||
|
"normal",
|
||||||
|
1
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [],
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Group",
|
||||||
|
"bounding": [
|
||||||
|
23,
|
||||||
|
23,
|
||||||
|
900,
|
||||||
|
825
|
||||||
|
],
|
||||||
|
"color": "#3f789e",
|
||||||
|
"font_size": 24,
|
||||||
|
"flags": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"config": {},
|
||||||
|
"extra": {
|
||||||
|
"ds": {
|
||||||
|
"scale": 1,
|
||||||
|
"offset": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": 0.4
|
||||||
|
}
|
||||||
104
browser_tests/assets/primitive_node_unconnected.json
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
{
|
||||||
|
"last_node_id": 2,
|
||||||
|
"last_link_id": 1,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"type": "KSampler",
|
||||||
|
"pos": {
|
||||||
|
"0": 304.3653259277344,
|
||||||
|
"1": 42.15586471557617
|
||||||
|
},
|
||||||
|
"size": [
|
||||||
|
315,
|
||||||
|
262
|
||||||
|
],
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "model",
|
||||||
|
"type": "MODEL",
|
||||||
|
"link": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "positive",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"link": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "negative",
|
||||||
|
"type": "CONDITIONING",
|
||||||
|
"link": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "latent_image",
|
||||||
|
"type": "LATENT",
|
||||||
|
"link": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "LATENT",
|
||||||
|
"type": "LATENT",
|
||||||
|
"links": null,
|
||||||
|
"shape": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "KSampler"
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
0,
|
||||||
|
"randomize",
|
||||||
|
20,
|
||||||
|
8,
|
||||||
|
"euler",
|
||||||
|
"normal",
|
||||||
|
1
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "PrimitiveNode",
|
||||||
|
"pos": {
|
||||||
|
"0": 14,
|
||||||
|
"1": 43
|
||||||
|
},
|
||||||
|
"size": [
|
||||||
|
203.1999969482422,
|
||||||
|
40.368401303242536
|
||||||
|
],
|
||||||
|
"flags": {},
|
||||||
|
"order": 1,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "connect to widget input",
|
||||||
|
"type": "*",
|
||||||
|
"links": [],
|
||||||
|
"slot_index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Run widget replace on values": false
|
||||||
|
},
|
||||||
|
"widgets_values": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [],
|
||||||
|
"groups": [],
|
||||||
|
"config": {},
|
||||||
|
"extra": {
|
||||||
|
"ds": {
|
||||||
|
"scale": 1,
|
||||||
|
"offset": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": 0.4
|
||||||
|
}
|
||||||
49
browser_tests/assets/simple_slider.json
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"last_node_id": 12,
|
||||||
|
"last_link_id": 9,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"type": "DevToolsSimpleSlider",
|
||||||
|
"pos": [
|
||||||
|
50,
|
||||||
|
50
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
315,
|
||||||
|
58
|
||||||
|
],
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "FLOAT",
|
||||||
|
"type": "FLOAT",
|
||||||
|
"links": null,
|
||||||
|
"label": "FLOAT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "DevToolsSimpleSlider"
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
0.5
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [],
|
||||||
|
"groups": [],
|
||||||
|
"config": {},
|
||||||
|
"extra": {
|
||||||
|
"ds": {
|
||||||
|
"scale": 1,
|
||||||
|
"offset": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": 0.4
|
||||||
|
}
|
||||||
116
browser_tests/assets/single_connected_reroute_node.json
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
{
|
||||||
|
"last_node_id": 16,
|
||||||
|
"last_link_id": 18,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"type": "VAEDecode",
|
||||||
|
"pos": [620, 260],
|
||||||
|
"size": [210, 46],
|
||||||
|
"flags": {},
|
||||||
|
"order": 2,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "samples",
|
||||||
|
"type": "LATENT",
|
||||||
|
"link": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "vae",
|
||||||
|
"type": "VAE",
|
||||||
|
"link": 18
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "IMAGE",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"links": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "VAEDecode"
|
||||||
|
},
|
||||||
|
"widgets_values": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 13,
|
||||||
|
"type": "Reroute",
|
||||||
|
"pos": [510, 280],
|
||||||
|
"size": [75, 26],
|
||||||
|
"flags": {},
|
||||||
|
"order": 1,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "",
|
||||||
|
"type": "*",
|
||||||
|
"link": 13
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "",
|
||||||
|
"type": "VAE",
|
||||||
|
"links": [18],
|
||||||
|
"slot_index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"showOutputText": false,
|
||||||
|
"horizontal": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"type": "CheckpointLoaderSimple",
|
||||||
|
"pos": [160, 240],
|
||||||
|
"size": [315, 98],
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "MODEL",
|
||||||
|
"type": "MODEL",
|
||||||
|
"links": [],
|
||||||
|
"slot_index": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CLIP",
|
||||||
|
"type": "CLIP",
|
||||||
|
"links": [],
|
||||||
|
"slot_index": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "VAE",
|
||||||
|
"type": "VAE",
|
||||||
|
"links": [13],
|
||||||
|
"slot_index": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "CheckpointLoaderSimple"
|
||||||
|
},
|
||||||
|
"widgets_values": ["v1-5-pruned-emaonly.safetensors"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [
|
||||||
|
[13, 4, 2, 13, 0, "*"],
|
||||||
|
[18, 13, 0, 12, 1, "VAE"]
|
||||||
|
],
|
||||||
|
"groups": [],
|
||||||
|
"config": {},
|
||||||
|
"extra": {
|
||||||
|
"ds": {
|
||||||
|
"scale": 1,
|
||||||
|
"offset": {
|
||||||
|
"0": 0,
|
||||||
|
"1": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": 0.4
|
||||||
|
}
|
||||||
42
browser_tests/assets/string_input.json
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"last_node_id": 1,
|
||||||
|
"last_link_id": 0,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"type": "DevToolsNodeWithStringInput",
|
||||||
|
"pos": [
|
||||||
|
15,
|
||||||
|
48
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
315,
|
||||||
|
58
|
||||||
|
],
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [],
|
||||||
|
"properties": {
|
||||||
|
"Node name for S&R": "DevToolsNodeWithStringInput"
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
""
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [],
|
||||||
|
"groups": [],
|
||||||
|
"config": {},
|
||||||
|
"extra": {
|
||||||
|
"ds": {
|
||||||
|
"scale": 1,
|
||||||
|
"offset": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": 0.4
|
||||||
|
}
|
||||||
52
browser_tests/browserTabTitle.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { expect } from '@playwright/test'
|
||||||
|
|
||||||
|
import { comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||||
|
|
||||||
|
test.describe('Browser tab title', () => {
|
||||||
|
test.describe('Beta Menu', () => {
|
||||||
|
test.beforeEach(async ({ comfyPage }) => {
|
||||||
|
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Can display workflow name', async ({ comfyPage }) => {
|
||||||
|
const workflowName = await comfyPage.page.evaluate(async () => {
|
||||||
|
return window['app'].extensionManager.workflow.activeWorkflow.filename
|
||||||
|
})
|
||||||
|
expect(await comfyPage.page.title()).toBe(`*${workflowName} - ComfyUI`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Failing on CI
|
||||||
|
// Cannot reproduce locally
|
||||||
|
test.skip('Can display workflow name with unsaved changes', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
const workflowName = await comfyPage.page.evaluate(async () => {
|
||||||
|
return window['app'].extensionManager.workflow.activeWorkflow.filename
|
||||||
|
})
|
||||||
|
expect(await comfyPage.page.title()).toBe(`${workflowName} - ComfyUI`)
|
||||||
|
|
||||||
|
await comfyPage.menu.topbar.saveWorkflow('test')
|
||||||
|
expect(await comfyPage.page.title()).toBe('test - ComfyUI')
|
||||||
|
|
||||||
|
const textBox = comfyPage.widgetTextBox
|
||||||
|
await textBox.fill('Hello World')
|
||||||
|
await comfyPage.clickEmptySpace()
|
||||||
|
expect(await comfyPage.page.title()).toBe(`*test - ComfyUI`)
|
||||||
|
|
||||||
|
// Delete the saved workflow for cleanup.
|
||||||
|
await comfyPage.page.evaluate(async () => {
|
||||||
|
return window['app'].extensionManager.workflow.activeWorkflow.delete()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Legacy Menu', () => {
|
||||||
|
test.beforeEach(async ({ comfyPage }) => {
|
||||||
|
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Can display default title', async ({ comfyPage }) => {
|
||||||
|
expect(await comfyPage.page.title()).toBe('ComfyUI')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
167
browser_tests/changeTracker.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import {
|
||||||
|
ComfyPage,
|
||||||
|
comfyExpect as expect,
|
||||||
|
comfyPageFixture as test
|
||||||
|
} from './fixtures/ComfyPage'
|
||||||
|
|
||||||
|
async function beforeChange(comfyPage: ComfyPage) {
|
||||||
|
await comfyPage.page.evaluate(() => {
|
||||||
|
window['app'].canvas.emitBeforeChange()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async function afterChange(comfyPage: ComfyPage) {
|
||||||
|
await comfyPage.page.evaluate(() => {
|
||||||
|
window['app'].canvas.emitAfterChange()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Change Tracker', () => {
|
||||||
|
test.describe('Undo/Redo', () => {
|
||||||
|
test.beforeEach(async ({ comfyPage }) => {
|
||||||
|
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||||
|
await comfyPage.setupWorkflowsDirectory({})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Can undo multiple operations', async ({ comfyPage }) => {
|
||||||
|
expect(await comfyPage.getUndoQueueSize()).toBe(0)
|
||||||
|
expect(await comfyPage.getRedoQueueSize()).toBe(0)
|
||||||
|
|
||||||
|
// Save, confirm no errors & workflow modified flag removed
|
||||||
|
await comfyPage.menu.topbar.saveWorkflow('undo-redo-test')
|
||||||
|
expect(await comfyPage.getToastErrorCount()).toBe(0)
|
||||||
|
expect(await comfyPage.isCurrentWorkflowModified()).toBe(false)
|
||||||
|
expect(await comfyPage.getUndoQueueSize()).toBe(0)
|
||||||
|
expect(await comfyPage.getRedoQueueSize()).toBe(0)
|
||||||
|
|
||||||
|
const node = (await comfyPage.getFirstNodeRef())!
|
||||||
|
await node.click('title')
|
||||||
|
await node.click('collapse')
|
||||||
|
await expect(node).toBeCollapsed()
|
||||||
|
expect(await comfyPage.isCurrentWorkflowModified()).toBe(true)
|
||||||
|
expect(await comfyPage.getUndoQueueSize()).toBe(1)
|
||||||
|
expect(await comfyPage.getRedoQueueSize()).toBe(0)
|
||||||
|
|
||||||
|
await comfyPage.ctrlB()
|
||||||
|
await expect(node).toBeBypassed()
|
||||||
|
expect(await comfyPage.isCurrentWorkflowModified()).toBe(true)
|
||||||
|
expect(await comfyPage.getUndoQueueSize()).toBe(2)
|
||||||
|
expect(await comfyPage.getRedoQueueSize()).toBe(0)
|
||||||
|
|
||||||
|
await comfyPage.ctrlZ()
|
||||||
|
await expect(node).not.toBeBypassed()
|
||||||
|
expect(await comfyPage.isCurrentWorkflowModified()).toBe(true)
|
||||||
|
expect(await comfyPage.getUndoQueueSize()).toBe(1)
|
||||||
|
expect(await comfyPage.getRedoQueueSize()).toBe(1)
|
||||||
|
|
||||||
|
await comfyPage.ctrlZ()
|
||||||
|
await expect(node).not.toBeCollapsed()
|
||||||
|
expect(await comfyPage.isCurrentWorkflowModified()).toBe(false)
|
||||||
|
expect(await comfyPage.getUndoQueueSize()).toBe(0)
|
||||||
|
expect(await comfyPage.getRedoQueueSize()).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Can group multiple change actions into a single transaction', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
const node = (await comfyPage.getFirstNodeRef())!
|
||||||
|
expect(node).toBeTruthy()
|
||||||
|
await expect(node).not.toBeCollapsed()
|
||||||
|
await expect(node).not.toBeBypassed()
|
||||||
|
|
||||||
|
// Make changes outside set
|
||||||
|
// Bypass + collapse node
|
||||||
|
await node.click('title')
|
||||||
|
await node.click('collapse')
|
||||||
|
await comfyPage.ctrlB()
|
||||||
|
await expect(node).toBeCollapsed()
|
||||||
|
await expect(node).toBeBypassed()
|
||||||
|
|
||||||
|
// Undo, undo, ensure both changes undone
|
||||||
|
await comfyPage.ctrlZ()
|
||||||
|
await expect(node).not.toBeBypassed()
|
||||||
|
await expect(node).toBeCollapsed()
|
||||||
|
await comfyPage.ctrlZ()
|
||||||
|
await expect(node).not.toBeBypassed()
|
||||||
|
await expect(node).not.toBeCollapsed()
|
||||||
|
|
||||||
|
// Prevent clicks registering a double-click
|
||||||
|
await comfyPage.clickEmptySpace()
|
||||||
|
await node.click('title')
|
||||||
|
|
||||||
|
// Run again, but within a change transaction
|
||||||
|
await beforeChange(comfyPage)
|
||||||
|
|
||||||
|
await node.click('collapse')
|
||||||
|
await comfyPage.ctrlB()
|
||||||
|
await expect(node).toBeCollapsed()
|
||||||
|
await expect(node).toBeBypassed()
|
||||||
|
|
||||||
|
// End transaction
|
||||||
|
await afterChange(comfyPage)
|
||||||
|
|
||||||
|
// Ensure undo reverts both changes
|
||||||
|
await comfyPage.ctrlZ()
|
||||||
|
await expect(node).not.toBeBypassed()
|
||||||
|
await expect(node).not.toBeCollapsed()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Can nest multiple change transactions without adding undo steps', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
const node = (await comfyPage.getFirstNodeRef())!
|
||||||
|
const bypassAndPin = async () => {
|
||||||
|
await beforeChange(comfyPage)
|
||||||
|
await comfyPage.ctrlB()
|
||||||
|
await expect(node).toBeBypassed()
|
||||||
|
await comfyPage.page.keyboard.press('KeyP')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await expect(node).toBePinned()
|
||||||
|
await afterChange(comfyPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
const collapse = async () => {
|
||||||
|
await beforeChange(comfyPage)
|
||||||
|
await node.click('collapse', { moveMouseToEmptyArea: true })
|
||||||
|
await expect(node).toBeCollapsed()
|
||||||
|
await afterChange(comfyPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
const multipleChanges = async () => {
|
||||||
|
await beforeChange(comfyPage)
|
||||||
|
// Call other actions that uses begin/endChange
|
||||||
|
await node.click('title')
|
||||||
|
await collapse()
|
||||||
|
await bypassAndPin()
|
||||||
|
await afterChange(comfyPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
await multipleChanges()
|
||||||
|
|
||||||
|
await comfyPage.ctrlZ()
|
||||||
|
await expect(node).not.toBeBypassed()
|
||||||
|
await expect(node).not.toBePinned()
|
||||||
|
await expect(node).not.toBeCollapsed()
|
||||||
|
|
||||||
|
await comfyPage.ctrlY()
|
||||||
|
await expect(node).toBeBypassed()
|
||||||
|
await expect(node).toBePinned()
|
||||||
|
await expect(node).toBeCollapsed()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Can detect changes in workflow.extra', async ({ comfyPage }) => {
|
||||||
|
expect(await comfyPage.getUndoQueueSize()).toBe(0)
|
||||||
|
await comfyPage.page.evaluate(() => {
|
||||||
|
window['app'].graph.extra.foo = 'bar'
|
||||||
|
})
|
||||||
|
// Click empty space to trigger a change detection.
|
||||||
|
await comfyPage.clickEmptySpace()
|
||||||
|
expect(await comfyPage.getUndoQueueSize()).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Ignores changes in workflow.ds', async ({ comfyPage }) => {
|
||||||
|
expect(await comfyPage.getUndoQueueSize()).toBe(0)
|
||||||
|
await comfyPage.pan({ x: 10, y: 10 })
|
||||||
|
expect(await comfyPage.getUndoQueueSize()).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { expect } from '@playwright/test'
|
import { expect } from '@playwright/test'
|
||||||
import { comfyPageFixture as test } from './ComfyPage'
|
|
||||||
|
|
||||||
const customColorPalettes = {
|
import type { Palette } from '../src/types/colorPaletteTypes'
|
||||||
|
import { comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||||
|
|
||||||
|
const customColorPalettes: Record<string, Palette> = {
|
||||||
obsidian: {
|
obsidian: {
|
||||||
version: 102,
|
version: 102,
|
||||||
id: 'obsidian',
|
id: 'obsidian',
|
||||||
@@ -127,23 +129,151 @@ const customColorPalettes = {
|
|||||||
'tr-odd-bg-color': 'rgba(19,19,19,.9)'
|
'tr-odd-bg-color': 'rgba(19,19,19,.9)'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
// A custom light theme with fg color red
|
||||||
|
light_red: {
|
||||||
|
id: 'light_red',
|
||||||
|
name: 'Light Red',
|
||||||
|
light_theme: true,
|
||||||
|
colors: {
|
||||||
|
node_slot: {},
|
||||||
|
litegraph_base: {},
|
||||||
|
comfy_base: {
|
||||||
|
'fg-color': '#ff0000'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe('Color Palette', () => {
|
test.describe('Color Palette', () => {
|
||||||
test.beforeEach(async ({ comfyPage }) => {
|
test('Can show custom color palette', async ({ comfyPage }) => {
|
||||||
await comfyPage.setSetting('Comfy.CustomColorPalettes', customColorPalettes)
|
await comfyPage.setSetting('Comfy.CustomColorPalettes', customColorPalettes)
|
||||||
|
// Reload to apply the new setting. Setting Comfy.CustomColorPalettes directly
|
||||||
|
// doesn't update the store immediately.
|
||||||
|
await comfyPage.setup()
|
||||||
|
|
||||||
|
await comfyPage.setSetting('Comfy.ColorPalette', 'obsidian_dark')
|
||||||
|
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||||
|
'custom-color-palette-obsidian-dark.png'
|
||||||
|
)
|
||||||
|
await comfyPage.setSetting('Comfy.ColorPalette', 'light_red')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||||
|
'custom-color-palette-light-red.png'
|
||||||
|
)
|
||||||
|
|
||||||
|
await comfyPage.setSetting('Comfy.ColorPalette', 'dark')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await expect(comfyPage.canvas).toHaveScreenshot('default-color-palette.png')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Can show custom color palette', async ({ comfyPage }) => {
|
test('Can add custom color palette', async ({ comfyPage }) => {
|
||||||
|
await comfyPage.page.evaluate((p) => {
|
||||||
|
window['app'].extensionManager.colorPalette.addCustomColorPalette(p)
|
||||||
|
}, customColorPalettes.obsidian_dark)
|
||||||
|
expect(await comfyPage.getToastErrorCount()).toBe(0)
|
||||||
|
|
||||||
|
await comfyPage.setSetting('Comfy.ColorPalette', 'obsidian_dark')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||||
|
'custom-color-palette-obsidian-dark.png'
|
||||||
|
)
|
||||||
|
// Legacy `custom_` prefix is still supported
|
||||||
await comfyPage.setSetting('Comfy.ColorPalette', 'custom_obsidian_dark')
|
await comfyPage.setSetting('Comfy.ColorPalette', 'custom_obsidian_dark')
|
||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||||
'custom-color-palette-obsidian-dark.png'
|
'custom-color-palette-obsidian-dark.png'
|
||||||
)
|
)
|
||||||
// Reset to default color palette for other tests
|
})
|
||||||
await comfyPage.setSetting('Comfy.ColorPalette', 'dark')
|
})
|
||||||
await comfyPage.nextFrame()
|
|
||||||
await expect(comfyPage.canvas).toHaveScreenshot('default-color-palette.png')
|
test.describe('Node Color Adjustments', () => {
|
||||||
|
test.beforeEach(async ({ comfyPage }) => {
|
||||||
|
await comfyPage.loadWorkflow('every_node_color')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should adjust opacity via node opacity setting', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.setSetting('Comfy.Node.Opacity', 0.5)
|
||||||
|
await comfyPage.page.waitForTimeout(128)
|
||||||
|
|
||||||
|
// Drag mouse to force canvas to redraw
|
||||||
|
await comfyPage.page.mouse.move(0, 0)
|
||||||
|
|
||||||
|
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-0.5.png')
|
||||||
|
|
||||||
|
await comfyPage.setSetting('Comfy.Node.Opacity', 1.0)
|
||||||
|
await comfyPage.page.waitForTimeout(128)
|
||||||
|
|
||||||
|
await comfyPage.page.mouse.move(8, 8)
|
||||||
|
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-1.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should persist color adjustments when changing themes', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.setSetting('Comfy.Node.Opacity', 0.2)
|
||||||
|
await comfyPage.setSetting('Comfy.ColorPalette', 'arc')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await comfyPage.page.mouse.move(0, 0)
|
||||||
|
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||||
|
'node-opacity-0.2-arc-theme.png'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should not serialize color adjustments in workflow', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.setSetting('Comfy.Node.Opacity', 0.5)
|
||||||
|
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
|
||||||
|
const saveWorkflowInterval = 1000
|
||||||
|
await comfyPage.page.waitForTimeout(saveWorkflowInterval)
|
||||||
|
const workflow = await comfyPage.page.evaluate(() => {
|
||||||
|
return localStorage.getItem('workflow')
|
||||||
|
})
|
||||||
|
for (const node of JSON.parse(workflow).nodes) {
|
||||||
|
if (node.bgcolor) expect(node.bgcolor).not.toMatch(/hsla/)
|
||||||
|
if (node.color) expect(node.color).not.toMatch(/hsla/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should lighten node colors when switching to light theme', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await expect(comfyPage.canvas).toHaveScreenshot('node-lightened-colors.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Context menu color adjustments', () => {
|
||||||
|
test.beforeEach(async ({ comfyPage }) => {
|
||||||
|
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
|
||||||
|
await comfyPage.setSetting('Comfy.Node.Opacity', 0.3)
|
||||||
|
const node = await comfyPage.getFirstNodeRef()
|
||||||
|
await node?.clickContextMenuOption('Colors')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should persist color adjustments when changing custom node colors', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.page
|
||||||
|
.locator('.litemenu-entry.submenu span:has-text("red")')
|
||||||
|
.click()
|
||||||
|
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||||
|
'node-opacity-0.3-color-changed.png'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should persist color adjustments when removing custom node color', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.page
|
||||||
|
.locator('.litemenu-entry.submenu span:has-text("No color")')
|
||||||
|
.click()
|
||||||
|
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||||
|
'node-opacity-0.3-color-removed.png'
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 134 KiB |
50
browser_tests/commands.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { expect } from '@playwright/test'
|
||||||
|
|
||||||
|
import { comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||||
|
|
||||||
|
test.describe('Keybindings', () => {
|
||||||
|
test('Should execute command', async ({ comfyPage }) => {
|
||||||
|
await comfyPage.registerCommand('TestCommand', () => {
|
||||||
|
window['foo'] = true
|
||||||
|
})
|
||||||
|
|
||||||
|
await comfyPage.executeCommand('TestCommand')
|
||||||
|
expect(await comfyPage.page.evaluate(() => window['foo'])).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Should execute async command', async ({ comfyPage }) => {
|
||||||
|
await comfyPage.registerCommand('TestCommand', async () => {
|
||||||
|
await new Promise<void>((resolve) =>
|
||||||
|
setTimeout(() => {
|
||||||
|
window['foo'] = true
|
||||||
|
resolve()
|
||||||
|
}, 5)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
await comfyPage.executeCommand('TestCommand')
|
||||||
|
expect(await comfyPage.page.evaluate(() => window['foo'])).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Should handle command errors', async ({ comfyPage }) => {
|
||||||
|
await comfyPage.registerCommand('TestCommand', () => {
|
||||||
|
throw new Error('Test error')
|
||||||
|
})
|
||||||
|
|
||||||
|
await comfyPage.executeCommand('TestCommand')
|
||||||
|
await expect(comfyPage.page.locator('.p-toast')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Should handle async command errors', async ({ comfyPage }) => {
|
||||||
|
await comfyPage.registerCommand('TestCommand', async () => {
|
||||||
|
await new Promise<void>((resolve, reject) =>
|
||||||
|
setTimeout(() => {
|
||||||
|
reject(new Error('Test error'))
|
||||||
|
}, 5)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
await comfyPage.executeCommand('TestCommand')
|
||||||
|
await expect(comfyPage.page.locator('.p-toast')).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { expect } from '@playwright/test'
|
import { expect } from '@playwright/test'
|
||||||
import { comfyPageFixture as test } from './ComfyPage'
|
|
||||||
|
import { comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||||
|
|
||||||
test.describe('Copy Paste', () => {
|
test.describe('Copy Paste', () => {
|
||||||
test('Can copy and paste node', async ({ comfyPage }) => {
|
test('Can copy and paste node', async ({ comfyPage }) => {
|
||||||
@@ -10,28 +11,28 @@ test.describe('Copy Paste', () => {
|
|||||||
await expect(comfyPage.canvas).toHaveScreenshot('copied-node.png')
|
await expect(comfyPage.canvas).toHaveScreenshot('copied-node.png')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Can copy and paste node with link', async ({ comfyPage }) => {
|
||||||
|
await comfyPage.clickTextEncodeNode1()
|
||||||
|
await comfyPage.page.mouse.move(10, 10)
|
||||||
|
await comfyPage.ctrlC()
|
||||||
|
await comfyPage.page.keyboard.press('Control+Shift+V')
|
||||||
|
await expect(comfyPage.canvas).toHaveScreenshot('copied-node-with-link.png')
|
||||||
|
})
|
||||||
|
|
||||||
test('Can copy and paste text', async ({ comfyPage }) => {
|
test('Can copy and paste text', async ({ comfyPage }) => {
|
||||||
const textBox = comfyPage.widgetTextBox
|
const textBox = comfyPage.widgetTextBox
|
||||||
await textBox.click()
|
await textBox.click()
|
||||||
const originalString = await textBox.inputValue()
|
const originalString = await textBox.inputValue()
|
||||||
await textBox.selectText()
|
await textBox.selectText()
|
||||||
await comfyPage.ctrlC()
|
await comfyPage.ctrlC(null)
|
||||||
await comfyPage.ctrlV()
|
await comfyPage.ctrlV(null)
|
||||||
await comfyPage.ctrlV()
|
await comfyPage.ctrlV(null)
|
||||||
const resultString = await textBox.inputValue()
|
const resultString = await textBox.inputValue()
|
||||||
expect(resultString).toBe(originalString + originalString)
|
expect(resultString).toBe(originalString + originalString)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Can copy and paste widget value', async ({ comfyPage }) => {
|
test('Can copy and paste widget value', async ({ comfyPage }) => {
|
||||||
// Copy width value (512) from empty latent node to KSampler's seed.
|
// Copy width value (512) from empty latent node to KSampler's seed.
|
||||||
// Empty latent node's width
|
|
||||||
await comfyPage.canvas.click({
|
|
||||||
position: {
|
|
||||||
x: 718,
|
|
||||||
y: 643
|
|
||||||
}
|
|
||||||
})
|
|
||||||
await comfyPage.ctrlC()
|
|
||||||
// KSampler's seed
|
// KSampler's seed
|
||||||
await comfyPage.canvas.click({
|
await comfyPage.canvas.click({
|
||||||
position: {
|
position: {
|
||||||
@@ -39,7 +40,15 @@ test.describe('Copy Paste', () => {
|
|||||||
y: 281
|
y: 281
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
await comfyPage.ctrlV()
|
await comfyPage.ctrlC(null)
|
||||||
|
// Empty latent node's width
|
||||||
|
await comfyPage.canvas.click({
|
||||||
|
position: {
|
||||||
|
x: 718,
|
||||||
|
y: 643
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await comfyPage.ctrlV(null)
|
||||||
await comfyPage.page.keyboard.press('Enter')
|
await comfyPage.page.keyboard.press('Enter')
|
||||||
await expect(comfyPage.canvas).toHaveScreenshot('copied-widget-value.png')
|
await expect(comfyPage.canvas).toHaveScreenshot('copied-widget-value.png')
|
||||||
})
|
})
|
||||||
@@ -51,14 +60,14 @@ test.describe('Copy Paste', () => {
|
|||||||
comfyPage
|
comfyPage
|
||||||
}) => {
|
}) => {
|
||||||
await comfyPage.clickEmptyLatentNode()
|
await comfyPage.clickEmptyLatentNode()
|
||||||
await comfyPage.ctrlC()
|
await comfyPage.ctrlC(null)
|
||||||
const textBox = comfyPage.widgetTextBox
|
const textBox = comfyPage.widgetTextBox
|
||||||
await textBox.click()
|
await textBox.click()
|
||||||
await textBox.inputValue()
|
await textBox.inputValue()
|
||||||
await textBox.selectText()
|
await textBox.selectText()
|
||||||
await comfyPage.ctrlC()
|
await comfyPage.ctrlC(null)
|
||||||
await comfyPage.ctrlV()
|
await comfyPage.ctrlV(null)
|
||||||
await comfyPage.ctrlV()
|
await comfyPage.ctrlV(null)
|
||||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||||
'paste-in-text-area-with-node-previously-copied.png'
|
'paste-in-text-area-with-node-previously-copied.png'
|
||||||
)
|
)
|
||||||
@@ -69,10 +78,10 @@ test.describe('Copy Paste', () => {
|
|||||||
await textBox.click()
|
await textBox.click()
|
||||||
await textBox.inputValue()
|
await textBox.inputValue()
|
||||||
await textBox.selectText()
|
await textBox.selectText()
|
||||||
await comfyPage.ctrlC()
|
await comfyPage.ctrlC(null)
|
||||||
// Unfocus textbox.
|
// Unfocus textbox.
|
||||||
await comfyPage.page.mouse.click(10, 10)
|
await comfyPage.page.mouse.click(10, 10)
|
||||||
await comfyPage.ctrlV()
|
await comfyPage.ctrlV(null)
|
||||||
await expect(comfyPage.canvas).toHaveScreenshot('no-node-copied.png')
|
await expect(comfyPage.canvas).toHaveScreenshot('no-node-copied.png')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -86,4 +95,23 @@ test.describe('Copy Paste', () => {
|
|||||||
await comfyPage.page.keyboard.up('Alt')
|
await comfyPage.page.keyboard.up('Alt')
|
||||||
await expect(comfyPage.canvas).toHaveScreenshot('drag-copy-copied-node.png')
|
await expect(comfyPage.canvas).toHaveScreenshot('drag-copy-copied-node.png')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Can undo paste multiple nodes as single action', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
const initialCount = await comfyPage.getGraphNodesCount()
|
||||||
|
expect(initialCount).toBeGreaterThan(1)
|
||||||
|
await comfyPage.canvas.click()
|
||||||
|
await comfyPage.ctrlA()
|
||||||
|
await comfyPage.page.mouse.move(10, 10)
|
||||||
|
await comfyPage.ctrlC()
|
||||||
|
await comfyPage.ctrlV()
|
||||||
|
|
||||||
|
const pasteCount = await comfyPage.getGraphNodesCount()
|
||||||
|
expect(pasteCount).toBe(initialCount * 2)
|
||||||
|
|
||||||
|
await comfyPage.ctrlZ()
|
||||||
|
const undoCount = await comfyPage.getGraphNodesCount()
|
||||||
|
expect(undoCount).toBe(initialCount)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
@@ -1,5 +1,7 @@
|
|||||||
import { expect } from '@playwright/test'
|
import { expect } from '@playwright/test'
|
||||||
import { comfyPageFixture as test } from './ComfyPage'
|
|
||||||
|
import { Keybinding } from '../src/types/keyBindingTypes'
|
||||||
|
import { comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||||
|
|
||||||
test.describe('Load workflow warning', () => {
|
test.describe('Load workflow warning', () => {
|
||||||
test('Should display a warning when loading a workflow with missing nodes', async ({
|
test('Should display a warning when loading a workflow with missing nodes', async ({
|
||||||
@@ -13,38 +15,13 @@ test.describe('Load workflow warning', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Does not report warning when switching between opened workflows', async ({
|
|
||||||
comfyPage
|
|
||||||
}) => {
|
|
||||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
|
||||||
await comfyPage.loadWorkflow('missing_nodes')
|
|
||||||
await comfyPage.page.locator('.p-dialog-close-button').click()
|
|
||||||
|
|
||||||
// Load default workflow
|
|
||||||
const workflowSelector = comfyPage.page.locator(
|
|
||||||
'button.comfyui-workflows-button'
|
|
||||||
)
|
|
||||||
await workflowSelector.hover()
|
|
||||||
await workflowSelector.click()
|
|
||||||
await comfyPage.page.locator('button[title="Load default workflow"]').click()
|
|
||||||
|
|
||||||
// Switch back to the missing_nodes workflow
|
|
||||||
await workflowSelector.click()
|
|
||||||
await comfyPage.page.locator('span:has-text("missing_nodes")').first().click()
|
|
||||||
await comfyPage.nextFrame()
|
|
||||||
|
|
||||||
await expect(comfyPage.page.locator('.comfy-missing-nodes')).not.toBeVisible()
|
|
||||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Does not report warning on undo/redo', async ({ comfyPage }) => {
|
test('Does not report warning on undo/redo', async ({ comfyPage }) => {
|
||||||
|
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
|
||||||
|
|
||||||
await comfyPage.loadWorkflow('missing_nodes')
|
await comfyPage.loadWorkflow('missing_nodes')
|
||||||
await comfyPage.page.locator('.p-dialog-close-button').click()
|
await comfyPage.closeDialog()
|
||||||
await comfyPage.nextFrame()
|
|
||||||
|
|
||||||
// Make a change to the graph
|
// Make a change to the graph
|
||||||
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
|
|
||||||
await comfyPage.page.waitForTimeout(256)
|
|
||||||
await comfyPage.doubleClickCanvas()
|
await comfyPage.doubleClickCanvas()
|
||||||
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
|
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
|
||||||
|
|
||||||
@@ -61,46 +38,164 @@ test.describe('Execution error', () => {
|
|||||||
}) => {
|
}) => {
|
||||||
await comfyPage.loadWorkflow('execution_error')
|
await comfyPage.loadWorkflow('execution_error')
|
||||||
await comfyPage.queueButton.click()
|
await comfyPage.queueButton.click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
// Wait for the element with the .comfy-execution-error selector to be visible
|
// Wait for the element with the .comfy-execution-error selector to be visible
|
||||||
const executionError = comfyPage.page.locator('.comfy-error-report')
|
const executionError = comfyPage.page.locator('.comfy-error-report')
|
||||||
await expect(executionError).toBeVisible()
|
await expect(executionError).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Can display Issue Report form', async ({ comfyPage }) => {
|
||||||
|
await comfyPage.loadWorkflow('execution_error')
|
||||||
|
await comfyPage.queueButton.click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
|
||||||
|
await comfyPage.page.getByLabel('Help Fix This').click()
|
||||||
|
const issueReportForm = comfyPage.page.getByText(
|
||||||
|
'Submit Error Report (Optional)'
|
||||||
|
)
|
||||||
|
await expect(issueReportForm).toBeVisible()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test.describe('Missing models warning', () => {
|
test.describe('Missing models warning', () => {
|
||||||
test.beforeEach(async ({ comfyPage }) => {
|
test.beforeEach(async ({ comfyPage }) => {
|
||||||
|
await comfyPage.setSetting('Comfy.Workflow.ShowMissingModelsWarning', true)
|
||||||
await comfyPage.page.evaluate((url: string) => {
|
await comfyPage.page.evaluate((url: string) => {
|
||||||
return fetch(`${url}/api/devtools/cleanup_fake_model`)
|
return fetch(`${url}/api/devtools/cleanup_fake_model`)
|
||||||
}, comfyPage.url)
|
}, comfyPage.url)
|
||||||
await comfyPage.setSetting('Comfy.Workflow.ModelDownload.AllowedSources', [
|
|
||||||
'http://localhost:8188'
|
|
||||||
])
|
|
||||||
await comfyPage.setSetting('Comfy.Workflow.ModelDownload.AllowedSuffixes', [
|
|
||||||
'.safetensors'
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Should display a warning when missing models are found', async ({
|
// Flaky test after parallelization
|
||||||
|
// https://github.com/Comfy-Org/ComfyUI_frontend/pull/1400
|
||||||
|
test.skip('Should display a warning when missing models are found', async ({
|
||||||
comfyPage
|
comfyPage
|
||||||
}) => {
|
}) => {
|
||||||
await comfyPage.setSetting('Comfy.Workflow.ShowMissingModelsWarning', true)
|
|
||||||
|
|
||||||
// The fake_model.safetensors is served by
|
// The fake_model.safetensors is served by
|
||||||
// https://github.com/Comfy-Org/ComfyUI_devtools/blob/main/__init__.py
|
// https://github.com/Comfy-Org/ComfyUI_devtools/blob/main/__init__.py
|
||||||
await comfyPage.loadWorkflow('missing_models')
|
await comfyPage.loadWorkflow('missing_models')
|
||||||
|
|
||||||
// Wait for the element with the .comfy-missing-models selector to be visible
|
|
||||||
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
||||||
await expect(missingModelsWarning).toBeVisible()
|
await expect(missingModelsWarning).toBeVisible()
|
||||||
|
|
||||||
// Click the download button
|
|
||||||
const downloadButton = comfyPage.page.getByLabel('Download')
|
const downloadButton = comfyPage.page.getByLabel('Download')
|
||||||
await expect(downloadButton).toBeVisible()
|
await expect(downloadButton).toBeVisible()
|
||||||
|
const downloadPromise = comfyPage.page.waitForEvent('download')
|
||||||
await downloadButton.click()
|
await downloadButton.click()
|
||||||
|
|
||||||
// Wait for the element with the .download-complete selector to be visible
|
const download = await downloadPromise
|
||||||
const downloadComplete = comfyPage.page.locator('.download-complete')
|
expect(download.suggestedFilename()).toBe('fake_model.safetensors')
|
||||||
await expect(downloadComplete).toBeVisible()
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Settings', () => {
|
||||||
|
test('@mobile Should be visible on mobile', async ({ comfyPage }) => {
|
||||||
|
await comfyPage.page.keyboard.press('Control+,')
|
||||||
|
const settingsContent = comfyPage.page.locator('.settings-content')
|
||||||
|
await expect(settingsContent).toBeVisible()
|
||||||
|
const isUsableHeight = await settingsContent.evaluate(
|
||||||
|
(el) => el.clientHeight > 30
|
||||||
|
)
|
||||||
|
expect(isUsableHeight).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Can open settings with hotkey', async ({ comfyPage }) => {
|
||||||
|
await comfyPage.page.keyboard.down('ControlOrMeta')
|
||||||
|
await comfyPage.page.keyboard.press(',')
|
||||||
|
await comfyPage.page.keyboard.up('ControlOrMeta')
|
||||||
|
const settingsLocator = comfyPage.page.locator('.settings-container')
|
||||||
|
await expect(settingsLocator).toBeVisible()
|
||||||
|
await comfyPage.page.keyboard.press('Escape')
|
||||||
|
await expect(settingsLocator).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Can change canvas zoom speed setting', async ({ comfyPage }) => {
|
||||||
|
const maxSpeed = 2.5
|
||||||
|
await comfyPage.setSetting('Comfy.Graph.ZoomSpeed', maxSpeed)
|
||||||
|
await test.step('Setting should persist', async () => {
|
||||||
|
expect(await comfyPage.getSetting('Comfy.Graph.ZoomSpeed')).toBe(maxSpeed)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Should persist keybinding setting', async ({ comfyPage }) => {
|
||||||
|
// Open the settings dialog
|
||||||
|
await comfyPage.page.keyboard.press('Control+,')
|
||||||
|
await comfyPage.page.waitForSelector('.settings-container')
|
||||||
|
|
||||||
|
// Open the keybinding tab
|
||||||
|
await comfyPage.page.getByLabel('Keybinding').click()
|
||||||
|
await comfyPage.page.waitForSelector(
|
||||||
|
'[placeholder="Search Keybindings..."]'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Focus the 'New Blank Workflow' row
|
||||||
|
const newBlankWorkflowRow = comfyPage.page.locator('tr', {
|
||||||
|
has: comfyPage.page.getByRole('cell', { name: 'New Blank Workflow' })
|
||||||
|
})
|
||||||
|
await newBlankWorkflowRow.click()
|
||||||
|
|
||||||
|
// Click edit button
|
||||||
|
const editKeybindingButton = newBlankWorkflowRow.locator('.pi-pencil')
|
||||||
|
await editKeybindingButton.click()
|
||||||
|
|
||||||
|
// Set new keybinding
|
||||||
|
const input = comfyPage.page.getByPlaceholder('Press keys for new binding')
|
||||||
|
await input.press('Alt+n')
|
||||||
|
|
||||||
|
const requestPromise = comfyPage.page.waitForRequest(
|
||||||
|
'**/api/settings/Comfy.Keybinding.NewBindings'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Save keybinding
|
||||||
|
const saveButton = comfyPage.page
|
||||||
|
.getByLabel('Comfy.NewBlankWorkflow')
|
||||||
|
.getByLabel('Save')
|
||||||
|
await saveButton.click()
|
||||||
|
|
||||||
|
const request = await requestPromise
|
||||||
|
const expectedSetting: Keybinding = {
|
||||||
|
commandId: 'Comfy.NewBlankWorkflow',
|
||||||
|
combo: {
|
||||||
|
key: 'n',
|
||||||
|
ctrl: false,
|
||||||
|
alt: true,
|
||||||
|
shift: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(request.postData()).toContain(JSON.stringify(expectedSetting))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Feedback dialog', () => {
|
||||||
|
test('Should open from topmenu help command', async ({ comfyPage }) => {
|
||||||
|
// Open feedback dialog from top menu
|
||||||
|
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||||
|
await comfyPage.menu.topbar.triggerTopbarCommand(['Help', 'Feedback'])
|
||||||
|
|
||||||
|
// Verify feedback dialog content is visible
|
||||||
|
const feedbackHeader = comfyPage.page.getByRole('heading', {
|
||||||
|
name: 'Feedback'
|
||||||
|
})
|
||||||
|
await expect(feedbackHeader).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Should close when close button clicked', async ({ comfyPage }) => {
|
||||||
|
// Open feedback dialog
|
||||||
|
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||||
|
await comfyPage.menu.topbar.triggerTopbarCommand(['Help', 'Feedback'])
|
||||||
|
|
||||||
|
const feedbackHeader = comfyPage.page.getByRole('heading', {
|
||||||
|
name: 'Feedback'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Close feedback dialog
|
||||||
|
await comfyPage.page
|
||||||
|
.getByLabel('', { exact: true })
|
||||||
|
.getByLabel('Close')
|
||||||
|
.click()
|
||||||
|
await feedbackHeader.waitFor({ state: 'hidden' })
|
||||||
|
|
||||||
|
// Verify dialog is closed
|
||||||
|
await expect(feedbackHeader).not.toBeVisible()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
27
browser_tests/domWidget.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { expect } from '@playwright/test'
|
||||||
|
|
||||||
|
import { comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||||
|
|
||||||
|
test.describe('DOM Widget', () => {
|
||||||
|
test('Collapsed multiline textarea is not visible', async ({ comfyPage }) => {
|
||||||
|
await comfyPage.loadWorkflow('collapsed_multiline')
|
||||||
|
|
||||||
|
expect(comfyPage.page.locator('.comfy-multiline-input')).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Multiline textarea correctly collapses', async ({ comfyPage }) => {
|
||||||
|
const multilineTextAreas = comfyPage.page.locator('.comfy-multiline-input')
|
||||||
|
const firstMultiline = multilineTextAreas.first()
|
||||||
|
const lastMultiline = multilineTextAreas.last()
|
||||||
|
|
||||||
|
await expect(firstMultiline).toBeVisible()
|
||||||
|
await expect(lastMultiline).toBeVisible()
|
||||||
|
|
||||||
|
const nodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
|
||||||
|
for (const node of nodes) {
|
||||||
|
await node.click('collapse')
|
||||||
|
}
|
||||||
|
await expect(firstMultiline).not.toBeVisible()
|
||||||
|
await expect(lastMultiline).not.toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
199
browser_tests/extensionAPI.spec.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { expect } from '@playwright/test'
|
||||||
|
|
||||||
|
import { comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||||
|
|
||||||
|
test.describe('Topbar commands', () => {
|
||||||
|
test.beforeEach(async ({ comfyPage }) => {
|
||||||
|
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Should allow registering topbar commands', async ({ comfyPage }) => {
|
||||||
|
await comfyPage.page.evaluate(() => {
|
||||||
|
window['app'].registerExtension({
|
||||||
|
name: 'TestExtension1',
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
id: 'foo',
|
||||||
|
label: 'foo-command',
|
||||||
|
function: () => {
|
||||||
|
window['foo'] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
menuCommands: [
|
||||||
|
{
|
||||||
|
path: ['ext'],
|
||||||
|
commands: ['foo']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await comfyPage.menu.topbar.triggerTopbarCommand(['ext', 'foo-command'])
|
||||||
|
expect(await comfyPage.page.evaluate(() => window['foo'])).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Should not allow register command defined in other extension', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.registerCommand('foo', () => alert(1))
|
||||||
|
await comfyPage.page.evaluate(() => {
|
||||||
|
window['app'].registerExtension({
|
||||||
|
name: 'TestExtension1',
|
||||||
|
menuCommands: [
|
||||||
|
{
|
||||||
|
path: ['ext'],
|
||||||
|
commands: ['foo']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const menuItem = comfyPage.menu.topbar.getMenuItem('ext')
|
||||||
|
expect(await menuItem.count()).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Should allow registering keybindings', async ({ comfyPage }) => {
|
||||||
|
await comfyPage.page.evaluate(() => {
|
||||||
|
const app = window['app']
|
||||||
|
app.registerExtension({
|
||||||
|
name: 'TestExtension1',
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
id: 'TestCommand',
|
||||||
|
function: () => {
|
||||||
|
window['TestCommand'] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
keybindings: [
|
||||||
|
{
|
||||||
|
combo: { key: 'k' },
|
||||||
|
commandId: 'TestCommand'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await comfyPage.page.keyboard.press('k')
|
||||||
|
expect(await comfyPage.page.evaluate(() => window['TestCommand'])).toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Settings', () => {
|
||||||
|
test('Should allow adding settings', async ({ comfyPage }) => {
|
||||||
|
await comfyPage.page.evaluate(() => {
|
||||||
|
window['app'].registerExtension({
|
||||||
|
name: 'TestExtension1',
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
id: 'TestSetting',
|
||||||
|
name: 'Test Setting',
|
||||||
|
type: 'text',
|
||||||
|
defaultValue: 'Hello, world!',
|
||||||
|
onChange: () => {
|
||||||
|
window['changeCount'] = (window['changeCount'] ?? 0) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// onChange is called when the setting is first added
|
||||||
|
expect(await comfyPage.page.evaluate(() => window['changeCount'])).toBe(1)
|
||||||
|
expect(await comfyPage.getSetting('TestSetting')).toBe('Hello, world!')
|
||||||
|
|
||||||
|
await comfyPage.setSetting('TestSetting', 'Hello, universe!')
|
||||||
|
expect(await comfyPage.getSetting('TestSetting')).toBe('Hello, universe!')
|
||||||
|
expect(await comfyPage.page.evaluate(() => window['changeCount'])).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Should allow setting boolean settings', async ({ comfyPage }) => {
|
||||||
|
await comfyPage.page.evaluate(() => {
|
||||||
|
window['app'].registerExtension({
|
||||||
|
name: 'TestExtension1',
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
id: 'Comfy.TestSetting',
|
||||||
|
name: 'Test Setting',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
onChange: () => {
|
||||||
|
window['changeCount'] = (window['changeCount'] ?? 0) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await comfyPage.getSetting('Comfy.TestSetting')).toBe(false)
|
||||||
|
expect(await comfyPage.page.evaluate(() => window['changeCount'])).toBe(1)
|
||||||
|
|
||||||
|
await comfyPage.settingDialog.open()
|
||||||
|
await comfyPage.settingDialog.toggleBooleanSetting('Comfy.TestSetting')
|
||||||
|
expect(await comfyPage.getSetting('Comfy.TestSetting')).toBe(true)
|
||||||
|
expect(await comfyPage.page.evaluate(() => window['changeCount'])).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('About panel', () => {
|
||||||
|
test('Should allow adding badges', async ({ comfyPage }) => {
|
||||||
|
await comfyPage.page.evaluate(() => {
|
||||||
|
window['app'].registerExtension({
|
||||||
|
name: 'TestExtension1',
|
||||||
|
aboutPageBadges: [
|
||||||
|
{
|
||||||
|
label: 'Test Badge',
|
||||||
|
url: 'https://example.com',
|
||||||
|
icon: 'pi pi-box'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await comfyPage.settingDialog.open()
|
||||||
|
await comfyPage.settingDialog.goToAboutPanel()
|
||||||
|
const badge = comfyPage.page.locator('.about-badge').last()
|
||||||
|
expect(badge).toBeDefined()
|
||||||
|
expect(await badge.textContent()).toContain('Test Badge')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Dialog', () => {
|
||||||
|
test('Should allow showing a prompt dialog', async ({ comfyPage }) => {
|
||||||
|
await comfyPage.page.evaluate(() => {
|
||||||
|
window['app'].extensionManager.dialog
|
||||||
|
.prompt({
|
||||||
|
title: 'Test Prompt',
|
||||||
|
message: 'Test Prompt Message'
|
||||||
|
})
|
||||||
|
.then((value: string) => {
|
||||||
|
window['value'] = value
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await comfyPage.fillPromptDialog('Hello, world!')
|
||||||
|
expect(await comfyPage.page.evaluate(() => window['value'])).toBe(
|
||||||
|
'Hello, world!'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Should allow showing a confirmation dialog', async ({
|
||||||
|
comfyPage
|
||||||
|
}) => {
|
||||||
|
await comfyPage.page.evaluate(() => {
|
||||||
|
window['app'].extensionManager.dialog
|
||||||
|
.confirm({
|
||||||
|
title: 'Test Confirm',
|
||||||
|
message: 'Test Confirm Message'
|
||||||
|
})
|
||||||
|
.then((value: boolean) => {
|
||||||
|
window['value'] = value
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await comfyPage.confirmDialog.click('confirm')
|
||||||
|
expect(await comfyPage.page.evaluate(() => window['value'])).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
962
browser_tests/fixtures/ComfyPage.ts
Normal file
@@ -0,0 +1,962 @@
|
|||||||
|
import type { APIRequestContext, Locator, Page } from '@playwright/test'
|
||||||
|
import { expect } from '@playwright/test'
|
||||||
|
import { test as base } from '@playwright/test'
|
||||||
|
import dotenv from 'dotenv'
|
||||||
|
import * as fs from 'fs'
|
||||||
|
|
||||||
|
import type { useWorkspaceStore } from '../../src/stores/workspaceStore'
|
||||||
|
import type { NodeId } from '../../src/types/comfyWorkflow'
|
||||||
|
import type { KeyCombo } from '../../src/types/keyBindingTypes'
|
||||||
|
import { NodeBadgeMode } from '../../src/types/nodeSource'
|
||||||
|
import { ComfyActionbar } from '../helpers/actionbar'
|
||||||
|
import { ComfyTemplates } from '../helpers/templates'
|
||||||
|
import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox'
|
||||||
|
import { SettingDialog } from './components/SettingDialog'
|
||||||
|
import {
|
||||||
|
NodeLibrarySidebarTab,
|
||||||
|
QueueSidebarTab,
|
||||||
|
WorkflowsSidebarTab
|
||||||
|
} from './components/SidebarTab'
|
||||||
|
import { Topbar } from './components/Topbar'
|
||||||
|
import type { Position, Size } from './types'
|
||||||
|
import { NodeReference } from './utils/litegraphUtils'
|
||||||
|
import TaskHistory from './utils/taskHistory'
|
||||||
|
|
||||||
|
dotenv.config()
|
||||||
|
|
||||||
|
type WorkspaceStore = ReturnType<typeof useWorkspaceStore>
|
||||||
|
|
||||||
|
class ComfyMenu {
|
||||||
|
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
|
||||||
|
private _workflowsTab: WorkflowsSidebarTab | null = null
|
||||||
|
private _queueTab: QueueSidebarTab | null = null
|
||||||
|
private _topbar: Topbar | null = null
|
||||||
|
|
||||||
|
public readonly sideToolbar: Locator
|
||||||
|
public readonly themeToggleButton: Locator
|
||||||
|
public readonly saveButton: Locator
|
||||||
|
|
||||||
|
constructor(public readonly page: Page) {
|
||||||
|
this.sideToolbar = page.locator('.side-tool-bar-container')
|
||||||
|
this.themeToggleButton = page.locator('.comfy-vue-theme-toggle')
|
||||||
|
this.saveButton = page
|
||||||
|
.locator('button[title="Save the current workflow"]')
|
||||||
|
.nth(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
get nodeLibraryTab() {
|
||||||
|
this._nodeLibraryTab ??= new NodeLibrarySidebarTab(this.page)
|
||||||
|
return this._nodeLibraryTab
|
||||||
|
}
|
||||||
|
|
||||||
|
get workflowsTab() {
|
||||||
|
this._workflowsTab ??= new WorkflowsSidebarTab(this.page)
|
||||||
|
return this._workflowsTab
|
||||||
|
}
|
||||||
|
|
||||||
|
get queueTab() {
|
||||||
|
this._queueTab ??= new QueueSidebarTab(this.page)
|
||||||
|
return this._queueTab
|
||||||
|
}
|
||||||
|
|
||||||
|
get topbar() {
|
||||||
|
this._topbar ??= new Topbar(this.page)
|
||||||
|
return this._topbar
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleTheme() {
|
||||||
|
await this.themeToggleButton.click()
|
||||||
|
await this.page.evaluate(() => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
window['app'].ui.settings.addEventListener(
|
||||||
|
'Comfy.ColorPalette.change',
|
||||||
|
resolve,
|
||||||
|
{ once: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
setTimeout(resolve, 5000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getThemeId() {
|
||||||
|
return await this.page.evaluate(async () => {
|
||||||
|
return await window['app'].ui.settings.getSettingValue(
|
||||||
|
'Comfy.ColorPalette'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FolderStructure = {
|
||||||
|
[key: string]: FolderStructure | string
|
||||||
|
}
|
||||||
|
|
||||||
|
type KeysOfType<T, Match> = {
|
||||||
|
[K in keyof T]: T[K] extends Match ? K : never
|
||||||
|
}[keyof T]
|
||||||
|
|
||||||
|
class ConfirmDialog {
|
||||||
|
public readonly delete: Locator
|
||||||
|
public readonly overwrite: Locator
|
||||||
|
public readonly reject: Locator
|
||||||
|
public readonly confirm: Locator
|
||||||
|
|
||||||
|
constructor(public readonly page: Page) {
|
||||||
|
this.delete = page.locator('button.p-button[aria-label="Delete"]')
|
||||||
|
this.overwrite = page.locator('button.p-button[aria-label="Overwrite"]')
|
||||||
|
this.reject = page.locator('button.p-button[aria-label="Cancel"]')
|
||||||
|
this.confirm = page.locator('button.p-button[aria-label="Confirm"]')
|
||||||
|
}
|
||||||
|
|
||||||
|
async click(locator: KeysOfType<ConfirmDialog, Locator>) {
|
||||||
|
const loc = this[locator]
|
||||||
|
await expect(loc).toBeVisible()
|
||||||
|
await loc.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ComfyPage {
|
||||||
|
private _history: TaskHistory | null = null
|
||||||
|
|
||||||
|
public readonly url: string
|
||||||
|
// All canvas position operations are based on default view of canvas.
|
||||||
|
public readonly canvas: Locator
|
||||||
|
public readonly widgetTextBox: Locator
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
public readonly resetViewButton: Locator
|
||||||
|
public readonly queueButton: Locator
|
||||||
|
|
||||||
|
// Inputs
|
||||||
|
public readonly workflowUploadInput: Locator
|
||||||
|
|
||||||
|
// Components
|
||||||
|
public readonly searchBox: ComfyNodeSearchBox
|
||||||
|
public readonly menu: ComfyMenu
|
||||||
|
public readonly actionbar: ComfyActionbar
|
||||||
|
public readonly templates: ComfyTemplates
|
||||||
|
public readonly settingDialog: SettingDialog
|
||||||
|
public readonly confirmDialog: ConfirmDialog
|
||||||
|
|
||||||
|
/** Worker index to test user ID */
|
||||||
|
public readonly userIds: string[] = []
|
||||||
|
|
||||||
|
/** Test user ID for the current context */
|
||||||
|
get id() {
|
||||||
|
return this.userIds[comfyPageFixture.info().parallelIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly page: Page,
|
||||||
|
public readonly request: APIRequestContext
|
||||||
|
) {
|
||||||
|
this.url = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||||
|
this.canvas = page.locator('#graph-canvas')
|
||||||
|
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
|
||||||
|
this.resetViewButton = page.getByRole('button', { name: 'Reset View' })
|
||||||
|
this.queueButton = page.getByRole('button', { name: 'Queue Prompt' })
|
||||||
|
this.workflowUploadInput = page.locator('#comfy-file-input')
|
||||||
|
this.searchBox = new ComfyNodeSearchBox(page)
|
||||||
|
this.menu = new ComfyMenu(page)
|
||||||
|
this.actionbar = new ComfyActionbar(page)
|
||||||
|
this.templates = new ComfyTemplates(page)
|
||||||
|
this.settingDialog = new SettingDialog(page)
|
||||||
|
this.confirmDialog = new ConfirmDialog(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
convertLeafToContent(structure: FolderStructure): FolderStructure {
|
||||||
|
const result: FolderStructure = {}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(structure)) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const filePath = this.assetPath(value)
|
||||||
|
result[key] = fs.readFileSync(filePath, 'utf-8')
|
||||||
|
} else {
|
||||||
|
result[key] = this.convertLeafToContent(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGraphNodesCount(): Promise<number> {
|
||||||
|
return await this.page.evaluate(() => {
|
||||||
|
return window['app']?.graph?.nodes?.length || 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSelectedGraphNodesCount(): Promise<number> {
|
||||||
|
return await this.page.evaluate(() => {
|
||||||
|
return (
|
||||||
|
window['app']?.graph?.nodes?.filter(
|
||||||
|
(node: any) => node.is_selected === true
|
||||||
|
).length || 0
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async setupWorkflowsDirectory(structure: FolderStructure) {
|
||||||
|
const resp = await this.request.post(
|
||||||
|
`${this.url}/api/devtools/setup_folder_structure`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
tree_structure: this.convertLeafToContent(structure),
|
||||||
|
base_path: `user/${this.id}/workflows`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (resp.status() !== 200) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to setup workflows directory: ${await resp.text()}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setupUser(username: string) {
|
||||||
|
const res = await this.request.get(`${this.url}/api/users`)
|
||||||
|
if (res.status() !== 200)
|
||||||
|
throw new Error(`Failed to retrieve users: ${await res.text()}`)
|
||||||
|
|
||||||
|
const apiRes = await res.json()
|
||||||
|
const user = Object.entries(apiRes?.users ?? {}).find(
|
||||||
|
([, name]) => name === username
|
||||||
|
)
|
||||||
|
const id = user?.[0]
|
||||||
|
|
||||||
|
return id ? id : await this.createUser(username)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUser(username: string) {
|
||||||
|
const resp = await this.request.post(`${this.url}/api/users`, {
|
||||||
|
data: { username }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (resp.status() !== 200)
|
||||||
|
throw new Error(`Failed to create user: ${await resp.text()}`)
|
||||||
|
|
||||||
|
return await resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async setupSettings(settings: Record<string, any>) {
|
||||||
|
const resp = await this.request.post(
|
||||||
|
`${this.url}/api/devtools/set_settings`,
|
||||||
|
{
|
||||||
|
data: settings
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (resp.status() !== 200) {
|
||||||
|
throw new Error(`Failed to setup settings: ${await resp.text()}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupHistory(): TaskHistory {
|
||||||
|
this._history ??= new TaskHistory(this)
|
||||||
|
return this._history
|
||||||
|
}
|
||||||
|
|
||||||
|
async setup({ clearStorage = true }: { clearStorage?: boolean } = {}) {
|
||||||
|
await this.goto()
|
||||||
|
if (clearStorage) {
|
||||||
|
await this.page.evaluate((id) => {
|
||||||
|
localStorage.clear()
|
||||||
|
sessionStorage.clear()
|
||||||
|
localStorage.setItem('Comfy.userId', id)
|
||||||
|
}, this.id)
|
||||||
|
}
|
||||||
|
await this.goto()
|
||||||
|
|
||||||
|
// Unify font for consistent screenshots.
|
||||||
|
await this.page.addStyleTag({
|
||||||
|
url: 'https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap'
|
||||||
|
})
|
||||||
|
await this.page.addStyleTag({
|
||||||
|
url: 'https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap'
|
||||||
|
})
|
||||||
|
await this.page.addStyleTag({
|
||||||
|
content: `
|
||||||
|
* {
|
||||||
|
font-family: 'Roboto Mono', 'Noto Color Emoji';
|
||||||
|
}`
|
||||||
|
})
|
||||||
|
await this.page.waitForFunction(() => document.fonts.ready)
|
||||||
|
await this.page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
// window['app'] => GraphCanvas ready
|
||||||
|
// window['app'].extensionManager => GraphView ready
|
||||||
|
window['app'] && window['app'].extensionManager
|
||||||
|
)
|
||||||
|
await this.page.waitForSelector('.p-blockui-mask', { state: 'hidden' })
|
||||||
|
await this.nextFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
public assetPath(fileName: string) {
|
||||||
|
return `./browser_tests/assets/${fileName}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async executeCommand(commandId: string) {
|
||||||
|
await this.page.evaluate((id: string) => {
|
||||||
|
return window['app'].extensionManager.command.execute(id)
|
||||||
|
}, commandId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async registerCommand(
|
||||||
|
commandId: string,
|
||||||
|
command: (() => void) | (() => Promise<void>)
|
||||||
|
) {
|
||||||
|
await this.page.evaluate(
|
||||||
|
({ commandId, commandStr }) => {
|
||||||
|
const app = window['app']
|
||||||
|
const randomSuffix = Math.random().toString(36).substring(2, 8)
|
||||||
|
const extensionName = `TestExtension_${randomSuffix}`
|
||||||
|
|
||||||
|
app.registerExtension({
|
||||||
|
name: extensionName,
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
id: commandId,
|
||||||
|
function: eval(commandStr)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ commandId, commandStr: command.toString() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async registerKeybinding(keyCombo: KeyCombo, command: () => void) {
|
||||||
|
await this.page.evaluate(
|
||||||
|
({ keyCombo, commandStr }) => {
|
||||||
|
const app = window['app']
|
||||||
|
const randomSuffix = Math.random().toString(36).substring(2, 8)
|
||||||
|
const extensionName = `TestExtension_${randomSuffix}`
|
||||||
|
const commandId = `TestCommand_${randomSuffix}`
|
||||||
|
|
||||||
|
app.registerExtension({
|
||||||
|
name: extensionName,
|
||||||
|
keybindings: [
|
||||||
|
{
|
||||||
|
combo: keyCombo,
|
||||||
|
commandId: commandId
|
||||||
|
}
|
||||||
|
],
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
id: commandId,
|
||||||
|
function: eval(commandStr)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ keyCombo, commandStr: command.toString() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async setSetting(settingId: string, settingValue: any) {
|
||||||
|
return await this.page.evaluate(
|
||||||
|
async ({ id, value }) => {
|
||||||
|
await window['app'].extensionManager.setting.set(id, value)
|
||||||
|
},
|
||||||
|
{ id: settingId, value: settingValue }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSetting(settingId: string) {
|
||||||
|
return await this.page.evaluate(async (id) => {
|
||||||
|
return await window['app'].extensionManager.setting.get(id)
|
||||||
|
}, settingId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async goto() {
|
||||||
|
await this.page.goto(this.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
async nextFrame() {
|
||||||
|
await this.page.evaluate(() => {
|
||||||
|
return new Promise<number>(requestAnimationFrame)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async delay(ms: number) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadWorkflow(workflowName: string) {
|
||||||
|
await this.workflowUploadInput.setInputFiles(
|
||||||
|
this.assetPath(`${workflowName}.json`)
|
||||||
|
)
|
||||||
|
await this.nextFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetView() {
|
||||||
|
if (await this.resetViewButton.isVisible()) {
|
||||||
|
await this.resetViewButton.click()
|
||||||
|
}
|
||||||
|
// Avoid "Reset View" button highlight.
|
||||||
|
await this.page.mouse.move(10, 10)
|
||||||
|
await this.nextFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
async getToastErrorCount() {
|
||||||
|
return await this.page
|
||||||
|
.locator('.p-toast-message.p-toast-message-error')
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVisibleToastCount() {
|
||||||
|
return await this.page.locator('.p-toast:visible').count()
|
||||||
|
}
|
||||||
|
|
||||||
|
async clickTextEncodeNode1() {
|
||||||
|
await this.canvas.click({
|
||||||
|
position: {
|
||||||
|
x: 618,
|
||||||
|
y: 191
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await this.nextFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
async clickTextEncodeNodeToggler() {
|
||||||
|
await this.canvas.click({
|
||||||
|
position: {
|
||||||
|
x: 430,
|
||||||
|
y: 171
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await this.nextFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
async clickTextEncodeNode2() {
|
||||||
|
await this.canvas.click({
|
||||||
|
position: {
|
||||||
|
x: 622,
|
||||||
|
y: 400
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await this.nextFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
async clickEmptySpace() {
|
||||||
|
await this.canvas.click({
|
||||||
|
position: {
|
||||||
|
x: 35,
|
||||||
|
y: 31
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await this.nextFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
async dragAndDrop(source: Position, target: Position) {
|
||||||
|
await this.page.mouse.move(source.x, source.y)
|
||||||
|
await this.page.mouse.down()
|
||||||
|
await this.page.mouse.move(target.x, target.y)
|
||||||
|
await this.page.mouse.up()
|
||||||
|
await this.nextFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
async dragAndDropFile(fileName: string) {
|
||||||
|
const filePath = this.assetPath(fileName)
|
||||||
|
|
||||||
|
// Read the file content
|
||||||
|
const buffer = fs.readFileSync(filePath)
|
||||||
|
|
||||||
|
// Get file type
|
||||||
|
const getFileType = (fileName: string) => {
|
||||||
|
if (fileName.endsWith('.png')) return 'image/png'
|
||||||
|
if (fileName.endsWith('.webp')) return 'image/webp'
|
||||||
|
if (fileName.endsWith('.json')) return 'application/json'
|
||||||
|
return 'application/octet-stream'
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileType = getFileType(fileName)
|
||||||
|
|
||||||
|
await this.page.evaluate(
|
||||||
|
async ({ buffer, fileName, fileType }) => {
|
||||||
|
const file = new File([new Uint8Array(buffer)], fileName, {
|
||||||
|
type: fileType
|
||||||
|
})
|
||||||
|
const dataTransfer = new DataTransfer()
|
||||||
|
dataTransfer.items.add(file)
|
||||||
|
|
||||||
|
const dropEvent = new DragEvent('drop', {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
dataTransfer
|
||||||
|
})
|
||||||
|
|
||||||
|
Object.defineProperty(dropEvent, 'preventDefault', {
|
||||||
|
value: () => {},
|
||||||
|
writable: false
|
||||||
|
})
|
||||||
|
|
||||||
|
Object.defineProperty(dropEvent, 'stopPropagation', {
|
||||||
|
value: () => {},
|
||||||
|
writable: false
|
||||||
|
})
|
||||||
|
|
||||||
|
document.dispatchEvent(dropEvent)
|
||||||
|
},
|
||||||
|
{ buffer: [...new Uint8Array(buffer)], fileName, fileType }
|
||||||
|
)
|
||||||
|
|
||||||
|
await this.nextFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
async dragNode2() {
|
||||||
|
await this.dragAndDrop({ x: 622, y: 400 }, { x: 622, y: 300 })
|
||||||
|
await this.nextFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default graph positions
|
||||||
|
get clipTextEncodeNode1InputSlot(): Position {
|
||||||
|
return { x: 427, y: 198 }
|
||||||
|
}
|
||||||
|
|
||||||
|
get clipTextEncodeNode2InputSlot(): Position {
|
||||||
|
return { x: 422, y: 402 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// A point on input edge.
|
||||||
|
get clipTextEncodeNode2InputLinkPath(): Position {
|
||||||
|
return {
|
||||||
|
x: 395,
|
||||||
|
y: 422
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get loadCheckpointNodeClipOutputSlot(): Position {
|
||||||
|
return { x: 332, y: 509 }
|
||||||
|
}
|
||||||
|
|
||||||
|
get emptySpace(): Position {
|
||||||
|
return { x: 427, y: 98 }
|
||||||
|
}
|
||||||
|
|
||||||
|
get promptDialogInput() {
|
||||||
|
return this.page.locator('.p-dialog-content input[type="text"]')
|
||||||
|
}
|
||||||
|
|
||||||
|
async fillPromptDialog(value: string) {
|
||||||
|
await this.promptDialogInput.fill(value)
|
||||||
|
await this.page.keyboard.press('Enter')
|
||||||
|
await this.promptDialogInput.waitFor({ state: 'hidden' })
|
||||||
|
await this.nextFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnectEdge() {
|
||||||
|
await this.dragAndDrop(this.clipTextEncodeNode1InputSlot, this.emptySpace)
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectEdge() {
|
||||||
|
await this.dragAndDrop(
|
||||||
|
this.loadCheckpointNodeClipOutputSlot,
|
||||||
|
this.clipTextEncodeNode1InputSlot
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async adjustWidgetValue() {
|
||||||
|
// Adjust Empty Latent Image's width input.
|
||||||
|
const page = this.page
|
||||||
|
await page.locator('#graph-canvas').click({
|
||||||
|
position: {
|
||||||
|
x: 724,
|
||||||
|
y: 645
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const dialogInput = page.locator('.graphdialog input[type="text"]')
|
||||||
|
await dialogInput.click()
|
||||||
|
await dialogInput.fill('128')
|
||||||
|
await dialogInput.press('Enter')
|
||||||
|
await this.nextFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
async zoom(deltaY: number, steps: number = 1) {
|
||||||
|
await this.page.mouse.move(10, 10)
|
||||||
|
for (let i = 0; i < steps; i++) {
|
||||||
|
await this.page.mouse.wheel(0, deltaY)
|
||||||
|
}
|
||||||
|
await this.nextFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
async pan(offset: Position, safeSpot?: Position) {
|
||||||
|
safeSpot = safeSpot || { x: 10, y: 10 }
|
||||||
|
await this.page.mouse.move(safeSpot.x, safeSpot.y)
|
||||||
|
await this.page.mouse.down()
|
||||||
|
await this.page.mouse.move(offset.x + safeSpot.x, offset.y + safeSpot.y)
|
||||||
|
await this.page.mouse.up()
|
||||||
|
await this.nextFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
async panWithTouch(offset: Position, safeSpot?: Position) {
|
||||||
|
safeSpot = safeSpot || { x: 10, y: 10 }
|
||||||
|
const client = await this.page.context().newCDPSession(this.page)
|
||||||
|
await client.send('Input.dispatchTouchEvent', {
|
||||||
|
type: 'touchStart',
|
||||||
|
touchPoints: [safeSpot]
|
||||||
|
})
|
||||||
|
await client.send('Input.dispatchTouchEvent', {
|
||||||
|
type: 'touchMove',
|
||||||
|
touchPoints: [{ x: offset.x + safeSpot.x, y: offset.y + safeSpot.y }]
|
||||||
|
})
|
||||||
|
await client.send('Input.dispatchTouchEvent', {
|
||||||
|
type: 'touchEnd',
|
||||||
|
touchPoints: []
|
||||||
|
})
|
||||||
|
await this.nextFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
async rightClickCanvas() {
|
||||||
|
await this.page.mouse.click(10, 10, { button: 'right' })
|
||||||
|
await this.nextFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
async clickContextMenuItem(name: string): Promise<void> {
|
||||||
|
await this.page.getByRole('menuitem', { name }).click()
|
||||||
|
await this.nextFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
async doubleClickCanvas() {
|
||||||
|
await this.page.mouse.dblclick(10, 10, { delay: 5 })
|
||||||
|
await this.nextFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
async clickEmptyLatentNode() {
|
||||||
|
await this.canvas.click({
|
||||||
|
position: {
|
||||||
|
x: 724,
|
||||||
|
y: 625
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.page.mouse.move(10, 10)
|
||||||
|
await this.nextFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
async rightClickEmptyLatentNode() {
|
||||||
|
await this.canvas.click({
|
||||||
|
position: {
|
||||||
|
x: 724,
|
||||||
|
y: 645
|
||||||
|
},
|
||||||
|
button: 'right'
|
||||||
|
})
|
||||||
|
this.page.mouse.move(10, 10)
|
||||||
|
await this.nextFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
async select2Nodes() {
|
||||||
|
// Select 2 CLIP nodes.
|
||||||
|
await this.page.keyboard.down('Control')
|
||||||
|
await this.clickTextEncodeNode1()
|
||||||
|
await this.clickTextEncodeNode2()
|
||||||
|
await this.page.keyboard.up('Control')
|
||||||
|
await this.nextFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
async ctrlSend(keyToPress: string, locator: Locator | null = this.canvas) {
|
||||||
|
const target = locator ?? this.page.keyboard
|
||||||
|
await target.press(`Control+${keyToPress}`)
|
||||||
|
await this.nextFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
async ctrlA(locator?: Locator | null) {
|
||||||
|
await this.ctrlSend('KeyA', locator)
|
||||||
|
}
|
||||||
|
|
||||||
|
async ctrlB(locator?: Locator | null) {
|
||||||
|
await this.ctrlSend('KeyB', locator)
|
||||||
|
}
|
||||||
|
|
||||||
|
async ctrlC(locator?: Locator | null) {
|
||||||
|
await this.ctrlSend('KeyC', locator)
|
||||||
|
}
|
||||||
|
|
||||||
|
async ctrlV(locator?: Locator | null) {
|
||||||
|
await this.ctrlSend('KeyV', locator)
|
||||||
|
}
|
||||||
|
|
||||||
|
async ctrlZ(locator?: Locator | null) {
|
||||||
|
await this.ctrlSend('KeyZ', locator)
|
||||||
|
}
|
||||||
|
|
||||||
|
async ctrlY(locator?: Locator | null) {
|
||||||
|
await this.ctrlSend('KeyY', locator)
|
||||||
|
}
|
||||||
|
|
||||||
|
async ctrlArrowUp(locator?: Locator | null) {
|
||||||
|
await this.ctrlSend('ArrowUp', locator)
|
||||||
|
}
|
||||||
|
|
||||||
|
async ctrlArrowDown(locator?: Locator | null) {
|
||||||
|
await this.ctrlSend('ArrowDown', locator)
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeMenu() {
|
||||||
|
await this.page.click('button.comfy-close-menu-btn')
|
||||||
|
await this.nextFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeDialog() {
|
||||||
|
await this.page.locator('.p-dialog-close-button').click()
|
||||||
|
await expect(this.page.locator('.p-dialog')).toBeHidden()
|
||||||
|
}
|
||||||
|
|
||||||
|
async resizeNode(
|
||||||
|
nodePos: Position,
|
||||||
|
nodeSize: Size,
|
||||||
|
ratioX: number,
|
||||||
|
ratioY: number,
|
||||||
|
revertAfter: boolean = false
|
||||||
|
) {
|
||||||
|
const bottomRight = {
|
||||||
|
x: nodePos.x + nodeSize.width,
|
||||||
|
y: nodePos.y + nodeSize.height
|
||||||
|
}
|
||||||
|
const target = {
|
||||||
|
x: nodePos.x + nodeSize.width * ratioX,
|
||||||
|
y: nodePos.y + nodeSize.height * ratioY
|
||||||
|
}
|
||||||
|
// -1 to be inside the node. -2 because nodes currently get an arbitrary +1 to width.
|
||||||
|
await this.dragAndDrop(
|
||||||
|
{ x: bottomRight.x - 2, y: bottomRight.y - 1 },
|
||||||
|
target
|
||||||
|
)
|
||||||
|
await this.nextFrame()
|
||||||
|
if (revertAfter) {
|
||||||
|
await this.dragAndDrop({ x: target.x - 2, y: target.y - 1 }, bottomRight)
|
||||||
|
await this.nextFrame()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async resizeKsamplerNode(
|
||||||
|
percentX: number,
|
||||||
|
percentY: number,
|
||||||
|
revertAfter: boolean = false
|
||||||
|
) {
|
||||||
|
const ksamplerPos = {
|
||||||
|
x: 863,
|
||||||
|
y: 156
|
||||||
|
}
|
||||||
|
const ksamplerSize = {
|
||||||
|
width: 315,
|
||||||
|
height: 292
|
||||||
|
}
|
||||||
|
return this.resizeNode(
|
||||||
|
ksamplerPos,
|
||||||
|
ksamplerSize,
|
||||||
|
percentX,
|
||||||
|
percentY,
|
||||||
|
revertAfter
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async resizeLoadCheckpointNode(
|
||||||
|
percentX: number,
|
||||||
|
percentY: number,
|
||||||
|
revertAfter: boolean = false
|
||||||
|
) {
|
||||||
|
const loadCheckpointPos = {
|
||||||
|
x: 26,
|
||||||
|
y: 444
|
||||||
|
}
|
||||||
|
const loadCheckpointSize = {
|
||||||
|
width: 315,
|
||||||
|
height: 127
|
||||||
|
}
|
||||||
|
return this.resizeNode(
|
||||||
|
loadCheckpointPos,
|
||||||
|
loadCheckpointSize,
|
||||||
|
percentX,
|
||||||
|
percentY,
|
||||||
|
revertAfter
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async resizeEmptyLatentNode(
|
||||||
|
percentX: number,
|
||||||
|
percentY: number,
|
||||||
|
revertAfter: boolean = false
|
||||||
|
) {
|
||||||
|
const emptyLatentPos = {
|
||||||
|
x: 473,
|
||||||
|
y: 579
|
||||||
|
}
|
||||||
|
const emptyLatentSize = {
|
||||||
|
width: 315,
|
||||||
|
height: 136
|
||||||
|
}
|
||||||
|
return this.resizeNode(
|
||||||
|
emptyLatentPos,
|
||||||
|
emptyLatentSize,
|
||||||
|
percentX,
|
||||||
|
percentY,
|
||||||
|
revertAfter
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async clickDialogButton(prompt: string, buttonText: string = 'Yes') {
|
||||||
|
const modal = this.page.locator(
|
||||||
|
`.comfy-modal-content:has-text("${prompt}")`
|
||||||
|
)
|
||||||
|
await expect(modal).toBeVisible()
|
||||||
|
await modal
|
||||||
|
.locator('.comfyui-button', {
|
||||||
|
hasText: buttonText
|
||||||
|
})
|
||||||
|
.click()
|
||||||
|
await expect(modal).toBeHidden()
|
||||||
|
}
|
||||||
|
|
||||||
|
async convertAllNodesToGroupNode(groupNodeName: string) {
|
||||||
|
await this.canvas.press('Control+a')
|
||||||
|
const node = await this.getFirstNodeRef()
|
||||||
|
await node!.clickContextMenuOption('Convert to Group Node')
|
||||||
|
await this.fillPromptDialog(groupNodeName)
|
||||||
|
await this.nextFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
async convertOffsetToCanvas(pos: [number, number]) {
|
||||||
|
return this.page.evaluate((pos) => {
|
||||||
|
return window['app'].canvas.ds.convertOffsetToCanvas(pos)
|
||||||
|
}, pos)
|
||||||
|
}
|
||||||
|
async getNodeRefById(id: NodeId) {
|
||||||
|
return new NodeReference(id, this)
|
||||||
|
}
|
||||||
|
async getNodes() {
|
||||||
|
return await this.page.evaluate(() => {
|
||||||
|
return window['app'].graph.nodes
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async getNodeRefsByType(type: string): Promise<NodeReference[]> {
|
||||||
|
return Promise.all(
|
||||||
|
(
|
||||||
|
await this.page.evaluate((type) => {
|
||||||
|
return window['app'].graph.nodes
|
||||||
|
.filter((n) => n.type === type)
|
||||||
|
.map((n) => n.id)
|
||||||
|
}, type)
|
||||||
|
).map((id: NodeId) => this.getNodeRefById(id))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
async getFirstNodeRef(): Promise<NodeReference | null> {
|
||||||
|
const id = await this.page.evaluate(() => {
|
||||||
|
return window['app'].graph.nodes[0]?.id
|
||||||
|
})
|
||||||
|
if (!id) return null
|
||||||
|
return this.getNodeRefById(id)
|
||||||
|
}
|
||||||
|
async moveMouseToEmptyArea() {
|
||||||
|
await this.page.mouse.move(10, 10)
|
||||||
|
}
|
||||||
|
async getUndoQueueSize() {
|
||||||
|
return this.page.evaluate(() => {
|
||||||
|
const workflow = (window['app'].extensionManager as WorkspaceStore)
|
||||||
|
.workflow.activeWorkflow
|
||||||
|
return workflow?.changeTracker.undoQueue.length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async getRedoQueueSize() {
|
||||||
|
return this.page.evaluate(() => {
|
||||||
|
const workflow = (window['app'].extensionManager as WorkspaceStore)
|
||||||
|
.workflow.activeWorkflow
|
||||||
|
return workflow?.changeTracker.redoQueue.length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async isCurrentWorkflowModified() {
|
||||||
|
return this.page.evaluate(() => {
|
||||||
|
return (window['app'].extensionManager as WorkspaceStore).workflow
|
||||||
|
.activeWorkflow?.isModified
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async getExportedWorkflow({ api = false }: { api?: boolean } = {}) {
|
||||||
|
return this.page.evaluate(async (api) => {
|
||||||
|
return (await window['app'].graphToPrompt())[api ? 'output' : 'workflow']
|
||||||
|
}, api)
|
||||||
|
}
|
||||||
|
async setFocusMode(focusMode: boolean) {
|
||||||
|
await this.page.evaluate((focusMode) => {
|
||||||
|
window['app'].extensionManager.focusMode = focusMode
|
||||||
|
}, focusMode)
|
||||||
|
await this.nextFrame()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const comfyPageFixture = base.extend<{ comfyPage: ComfyPage }>({
|
||||||
|
comfyPage: async ({ page, request }, use) => {
|
||||||
|
const comfyPage = new ComfyPage(page, request)
|
||||||
|
|
||||||
|
const { parallelIndex } = comfyPageFixture.info()
|
||||||
|
const username = `playwright-test-${parallelIndex}`
|
||||||
|
const userId = await comfyPage.setupUser(username)
|
||||||
|
comfyPage.userIds[parallelIndex] = userId
|
||||||
|
|
||||||
|
try {
|
||||||
|
await comfyPage.setupSettings({
|
||||||
|
'Comfy.UseNewMenu': 'Disabled',
|
||||||
|
// Hide canvas menu/info by default.
|
||||||
|
'Comfy.Graph.CanvasInfo': false,
|
||||||
|
'Comfy.Graph.CanvasMenu': false,
|
||||||
|
// Hide all badges by default.
|
||||||
|
'Comfy.NodeBadge.NodeIdBadgeMode': NodeBadgeMode.None,
|
||||||
|
'Comfy.NodeBadge.NodeSourceBadgeMode': NodeBadgeMode.None,
|
||||||
|
// Disable tooltips by default to avoid flakiness.
|
||||||
|
'Comfy.EnableTooltips': false,
|
||||||
|
'Comfy.userId': userId
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
await comfyPage.setup()
|
||||||
|
await use(comfyPage)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const makeMatcher = function <T>(
|
||||||
|
getValue: (node: NodeReference) => Promise<T> | T,
|
||||||
|
type: string
|
||||||
|
) {
|
||||||
|
return async function (
|
||||||
|
node: NodeReference,
|
||||||
|
options?: { timeout?: number; intervals?: number[] }
|
||||||
|
) {
|
||||||
|
const value = await getValue(node)
|
||||||
|
let assertion = expect(
|
||||||
|
value,
|
||||||
|
'Node is ' + (this.isNot ? '' : 'not ') + type
|
||||||
|
)
|
||||||
|
if (this.isNot) {
|
||||||
|
assertion = assertion.not
|
||||||
|
}
|
||||||
|
await expect(async () => {
|
||||||
|
assertion.toBeTruthy()
|
||||||
|
}).toPass({ timeout: 250, ...options })
|
||||||
|
return {
|
||||||
|
pass: !this.isNot,
|
||||||
|
message: () => 'Node is ' + (this.isNot ? 'not ' : '') + type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const comfyExpect = expect.extend({
|
||||||
|
toBePinned: makeMatcher((n) => n.isPinned(), 'pinned'),
|
||||||
|
toBeBypassed: makeMatcher((n) => n.isBypassed(), 'bypassed'),
|
||||||
|
toBeCollapsed: makeMatcher((n) => n.isCollapsed(), 'collapsed'),
|
||||||
|
async toHaveFocus(locator: Locator, options = { timeout: 256 }) {
|
||||||
|
const isFocused = await locator.evaluate(
|
||||||
|
(el) => el === document.activeElement
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(async () => {
|
||||||
|
expect(isFocused).toBe(!this.isNot)
|
||||||
|
}).toPass(options)
|
||||||
|
|
||||||
|
return {
|
||||||
|
pass: isFocused,
|
||||||
|
message: () => `Expected element to ${isFocused ? 'not ' : ''}be focused.`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
41
browser_tests/fixtures/UserSelectPage.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { test as base } from '@playwright/test'
|
||||||
|
import { Page } from 'playwright'
|
||||||
|
|
||||||
|
export class UserSelectPage {
|
||||||
|
constructor(
|
||||||
|
public readonly url: string,
|
||||||
|
public readonly page: Page
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get selectionUrl() {
|
||||||
|
return this.url + '/user-select'
|
||||||
|
}
|
||||||
|
|
||||||
|
get container() {
|
||||||
|
return this.page.locator('#comfy-user-selection')
|
||||||
|
}
|
||||||
|
|
||||||
|
get newUserInput() {
|
||||||
|
return this.container.locator('#new-user-input')
|
||||||
|
}
|
||||||
|
|
||||||
|
get existingUserSelect() {
|
||||||
|
return this.container.locator('#existing-user-select')
|
||||||
|
}
|
||||||
|
|
||||||
|
get nextButton() {
|
||||||
|
return this.container.getByText('Next')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const userSelectPageFixture = base.extend<{
|
||||||
|
userSelectPage: UserSelectPage
|
||||||
|
}>({
|
||||||
|
userSelectPage: async ({ page }, use) => {
|
||||||
|
const userSelectPage = new UserSelectPage(
|
||||||
|
process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188',
|
||||||
|
page
|
||||||
|
)
|
||||||
|
await use(userSelectPage)
|
||||||
|
}
|
||||||
|
})
|
||||||
86
browser_tests/fixtures/components/ComfyNodeSearchBox.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { Locator, Page } from '@playwright/test'
|
||||||
|
|
||||||
|
export class ComfyNodeSearchFilterSelectionPanel {
|
||||||
|
constructor(public readonly page: Page) {}
|
||||||
|
|
||||||
|
get header() {
|
||||||
|
return this.page
|
||||||
|
.getByRole('dialog')
|
||||||
|
.locator('div')
|
||||||
|
.filter({ hasText: 'Add node filter condition' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectFilterType(filterType: string) {
|
||||||
|
await this.page
|
||||||
|
.locator(
|
||||||
|
`.filter-type-select .p-togglebutton-label:has-text("${filterType}")`
|
||||||
|
)
|
||||||
|
.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectFilterValue(filterValue: string) {
|
||||||
|
await this.page.locator('.filter-value-select .p-select-dropdown').click()
|
||||||
|
await this.page
|
||||||
|
.locator(
|
||||||
|
`.p-select-overlay .p-select-list .p-select-option-label:text-is("${filterValue}")`
|
||||||
|
)
|
||||||
|
.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async addFilter(filterValue: string, filterType: string) {
|
||||||
|
await this.selectFilterType(filterType)
|
||||||
|
await this.selectFilterValue(filterValue)
|
||||||
|
await this.page.locator('.p-button-label:has-text("Add")').click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ComfyNodeSearchBox {
|
||||||
|
public readonly input: Locator
|
||||||
|
public readonly dropdown: Locator
|
||||||
|
public readonly filterSelectionPanel: ComfyNodeSearchFilterSelectionPanel
|
||||||
|
|
||||||
|
constructor(public readonly page: Page) {
|
||||||
|
this.input = page.locator(
|
||||||
|
'.comfy-vue-node-search-container input[type="text"]'
|
||||||
|
)
|
||||||
|
this.dropdown = page.locator(
|
||||||
|
'.comfy-vue-node-search-container .p-autocomplete-list'
|
||||||
|
)
|
||||||
|
this.filterSelectionPanel = new ComfyNodeSearchFilterSelectionPanel(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
get filterButton() {
|
||||||
|
return this.page.locator('.comfy-vue-node-search-container .filter-button')
|
||||||
|
}
|
||||||
|
|
||||||
|
async fillAndSelectFirstNode(
|
||||||
|
nodeName: string,
|
||||||
|
options?: { suggestionIndex: number }
|
||||||
|
) {
|
||||||
|
await this.input.waitFor({ state: 'visible' })
|
||||||
|
await this.input.fill(nodeName)
|
||||||
|
await this.dropdown.waitFor({ state: 'visible' })
|
||||||
|
// Wait for some time for the auto complete list to update.
|
||||||
|
// The auto complete list is debounced and may take some time to update.
|
||||||
|
await this.page.waitForTimeout(500)
|
||||||
|
await this.dropdown
|
||||||
|
.locator('li')
|
||||||
|
.nth(options?.suggestionIndex || 0)
|
||||||
|
.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async addFilter(filterValue: string, filterType: string) {
|
||||||
|
await this.filterButton.click()
|
||||||
|
await this.filterSelectionPanel.addFilter(filterValue, filterType)
|
||||||
|
}
|
||||||
|
|
||||||
|
get filterChips() {
|
||||||
|
return this.page.locator(
|
||||||
|
'.comfy-vue-node-search-container .p-autocomplete-chip-item'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeFilter(index: number) {
|
||||||
|
await this.filterChips.nth(index).locator('.p-chip-remove-icon').click()
|
||||||
|
}
|
||||||
|
}
|
||||||
40
browser_tests/fixtures/components/SettingDialog.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Page } from '@playwright/test'
|
||||||
|
|
||||||
|
export class SettingDialog {
|
||||||
|
constructor(public readonly page: Page) {}
|
||||||
|
|
||||||
|
async open() {
|
||||||
|
const button = this.page.locator('button.comfy-settings-btn:visible')
|
||||||
|
await button.click()
|
||||||
|
await this.page.waitForSelector('div.settings-container')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the value of a text setting
|
||||||
|
* @param id - The id of the setting
|
||||||
|
* @param value - The value to set
|
||||||
|
*/
|
||||||
|
async setStringSetting(id: string, value: string) {
|
||||||
|
const settingInputDiv = this.page.locator(
|
||||||
|
`div.settings-container div[id="${id}"]`
|
||||||
|
)
|
||||||
|
await settingInputDiv.locator('input').fill(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the value of a boolean setting
|
||||||
|
* @param id - The id of the setting
|
||||||
|
*/
|
||||||
|
async toggleBooleanSetting(id: string) {
|
||||||
|
const settingInputDiv = this.page.locator(
|
||||||
|
`div.settings-container div[id="${id}"]`
|
||||||
|
)
|
||||||
|
await settingInputDiv.locator('input').click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async goToAboutPanel() {
|
||||||
|
const aboutButton = this.page.locator('li[aria-label="About"]')
|
||||||
|
await aboutButton.click()
|
||||||
|
await this.page.waitForSelector('div.about-container')
|
||||||
|
}
|
||||||
|
}
|
||||||
283
browser_tests/fixtures/components/SidebarTab.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import { Locator, Page } from '@playwright/test'
|
||||||
|
|
||||||
|
class SidebarTab {
|
||||||
|
constructor(
|
||||||
|
public readonly page: Page,
|
||||||
|
public readonly tabId: string
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get tabButton() {
|
||||||
|
return this.page.locator(`.${this.tabId}-tab-button`)
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedTabButton() {
|
||||||
|
return this.page.locator(
|
||||||
|
`.${this.tabId}-tab-button.side-bar-button-selected`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async open() {
|
||||||
|
if (await this.selectedTabButton.isVisible()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await this.tabButton.click()
|
||||||
|
}
|
||||||
|
async close() {
|
||||||
|
if (!this.tabButton.isVisible()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await this.tabButton.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NodeLibrarySidebarTab extends SidebarTab {
|
||||||
|
constructor(public readonly page: Page) {
|
||||||
|
super(page, 'node-library')
|
||||||
|
}
|
||||||
|
|
||||||
|
get nodeLibrarySearchBoxInput() {
|
||||||
|
return this.page.locator('.node-lib-search-box input[type="text"]')
|
||||||
|
}
|
||||||
|
|
||||||
|
get nodeLibraryTree() {
|
||||||
|
return this.page.locator('.node-lib-tree-explorer')
|
||||||
|
}
|
||||||
|
|
||||||
|
get nodePreview() {
|
||||||
|
return this.page.locator('.node-lib-node-preview')
|
||||||
|
}
|
||||||
|
|
||||||
|
get tabContainer() {
|
||||||
|
return this.page.locator('.sidebar-content-container')
|
||||||
|
}
|
||||||
|
|
||||||
|
get newFolderButton() {
|
||||||
|
return this.tabContainer.locator('.new-folder-button')
|
||||||
|
}
|
||||||
|
|
||||||
|
async open() {
|
||||||
|
await super.open()
|
||||||
|
await this.nodeLibraryTree.waitFor({ state: 'visible' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
if (!this.tabButton.isVisible()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.tabButton.click()
|
||||||
|
await this.nodeLibraryTree.waitFor({ state: 'hidden' })
|
||||||
|
}
|
||||||
|
|
||||||
|
folderSelector(folderName: string) {
|
||||||
|
return `.p-tree-node-content:has(> .tree-explorer-node-label:has(.tree-folder .node-label:has-text("${folderName}")))`
|
||||||
|
}
|
||||||
|
|
||||||
|
getFolder(folderName: string) {
|
||||||
|
return this.page.locator(this.folderSelector(folderName))
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeSelector(nodeName: string) {
|
||||||
|
return `.p-tree-node-content:has(> .tree-explorer-node-label:has(.tree-leaf .node-label:has-text("${nodeName}")))`
|
||||||
|
}
|
||||||
|
|
||||||
|
getNode(nodeName: string) {
|
||||||
|
return this.page.locator(this.nodeSelector(nodeName))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WorkflowsSidebarTab extends SidebarTab {
|
||||||
|
constructor(public readonly page: Page) {
|
||||||
|
super(page, 'workflows')
|
||||||
|
}
|
||||||
|
|
||||||
|
get root() {
|
||||||
|
return this.page.locator('.workflows-sidebar-tab')
|
||||||
|
}
|
||||||
|
|
||||||
|
get browseGalleryButton() {
|
||||||
|
return this.root.locator('.browse-templates-button')
|
||||||
|
}
|
||||||
|
|
||||||
|
get newBlankWorkflowButton() {
|
||||||
|
return this.root.locator('.new-blank-workflow-button')
|
||||||
|
}
|
||||||
|
|
||||||
|
get openWorkflowButton() {
|
||||||
|
return this.root.locator('.open-workflow-button')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOpenedWorkflowNames() {
|
||||||
|
return await this.root
|
||||||
|
.locator('.comfyui-workflows-open .node-label')
|
||||||
|
.allInnerTexts()
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActiveWorkflowName() {
|
||||||
|
return await this.root
|
||||||
|
.locator('.comfyui-workflows-open .p-tree-node-selected .node-label')
|
||||||
|
.innerText()
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTopLevelSavedWorkflowNames() {
|
||||||
|
return await this.root
|
||||||
|
.locator('.comfyui-workflows-browse .node-label')
|
||||||
|
.allInnerTexts()
|
||||||
|
}
|
||||||
|
|
||||||
|
async switchToWorkflow(workflowName: string) {
|
||||||
|
const workflowLocator = this.getOpenedItem(workflowName)
|
||||||
|
await workflowLocator.click()
|
||||||
|
await this.page.waitForTimeout(300)
|
||||||
|
}
|
||||||
|
|
||||||
|
getOpenedItem(name: string) {
|
||||||
|
return this.root.locator('.comfyui-workflows-open .node-label', {
|
||||||
|
hasText: name
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
getPersistedItem(name: string) {
|
||||||
|
return this.root.locator('.comfyui-workflows-browse .node-label', {
|
||||||
|
hasText: name
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async renameWorkflow(locator: Locator, newName: string) {
|
||||||
|
await locator.click({ button: 'right' })
|
||||||
|
await this.page
|
||||||
|
.locator('.p-contextmenu-item-content', { hasText: 'Rename' })
|
||||||
|
.click()
|
||||||
|
await this.page.keyboard.type(newName)
|
||||||
|
await this.page.keyboard.press('Enter')
|
||||||
|
await this.page.waitForTimeout(300)
|
||||||
|
}
|
||||||
|
|
||||||
|
async insertWorkflow(locator: Locator) {
|
||||||
|
await locator.click({ button: 'right' })
|
||||||
|
await this.page
|
||||||
|
.locator('.p-contextmenu-item-content', { hasText: 'Insert' })
|
||||||
|
.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QueueSidebarTab extends SidebarTab {
|
||||||
|
constructor(public readonly page: Page) {
|
||||||
|
super(page, 'queue')
|
||||||
|
}
|
||||||
|
|
||||||
|
get root() {
|
||||||
|
return this.page.locator('.sidebar-content-container', { hasText: 'Queue' })
|
||||||
|
}
|
||||||
|
|
||||||
|
get tasks() {
|
||||||
|
return this.root.locator('[data-virtual-grid-item]')
|
||||||
|
}
|
||||||
|
|
||||||
|
get visibleTasks() {
|
||||||
|
return this.tasks.locator('visible=true')
|
||||||
|
}
|
||||||
|
|
||||||
|
get clearButton() {
|
||||||
|
return this.root.locator('.clear-all-button')
|
||||||
|
}
|
||||||
|
|
||||||
|
get collapseTasksButton() {
|
||||||
|
return this.getToggleExpandButton(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
get expandTasksButton() {
|
||||||
|
return this.getToggleExpandButton(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
get noResultsPlaceholder() {
|
||||||
|
return this.root.locator('.no-results-placeholder')
|
||||||
|
}
|
||||||
|
|
||||||
|
get galleryImage() {
|
||||||
|
return this.page.locator('.galleria-image')
|
||||||
|
}
|
||||||
|
|
||||||
|
private getToggleExpandButton(isExpanded: boolean) {
|
||||||
|
const iconSelector = isExpanded ? '.pi-image' : '.pi-images'
|
||||||
|
return this.root.locator(`.toggle-expanded-button ${iconSelector}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async open() {
|
||||||
|
await super.open()
|
||||||
|
return this.root.waitFor({ state: 'visible' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
await super.close()
|
||||||
|
await this.root.waitFor({ state: 'hidden' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async expandTasks() {
|
||||||
|
await this.expandTasksButton.click()
|
||||||
|
await this.collapseTasksButton.waitFor({ state: 'visible' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async collapseTasks() {
|
||||||
|
await this.collapseTasksButton.click()
|
||||||
|
await this.expandTasksButton.waitFor({ state: 'visible' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForTasks() {
|
||||||
|
return Promise.all([
|
||||||
|
this.tasks.first().waitFor({ state: 'visible' }),
|
||||||
|
this.tasks.last().waitFor({ state: 'visible' })
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
async scrollTasks(direction: 'up' | 'down') {
|
||||||
|
const scrollToEl =
|
||||||
|
direction === 'up' ? this.tasks.last() : this.tasks.first()
|
||||||
|
await scrollToEl.scrollIntoViewIfNeeded()
|
||||||
|
await this.waitForTasks()
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearTasks() {
|
||||||
|
await this.clearButton.click()
|
||||||
|
const confirmButton = this.page.getByLabel('Delete')
|
||||||
|
await confirmButton.click()
|
||||||
|
await this.noResultsPlaceholder.waitFor({ state: 'visible' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set the width of the tab (out of 100). Must call before opening the tab */
|
||||||
|
async setTabWidth(width: number) {
|
||||||
|
if (width < 0 || width > 100) {
|
||||||
|
throw new Error('Width must be between 0 and 100')
|
||||||
|
}
|
||||||
|
return this.page.evaluate((width) => {
|
||||||
|
localStorage.setItem('queue', JSON.stringify([width, 100 - width]))
|
||||||
|
}, width)
|
||||||
|
}
|
||||||
|
|
||||||
|
getTaskPreviewButton(taskIndex: number) {
|
||||||
|
return this.tasks.nth(taskIndex).getByRole('button')
|
||||||
|
}
|
||||||
|
|
||||||
|
async openTaskPreview(taskIndex: number) {
|
||||||
|
const previewButton = this.getTaskPreviewButton(taskIndex)
|
||||||
|
await previewButton.click()
|
||||||
|
return this.galleryImage.waitFor({ state: 'visible' })
|
||||||
|
}
|
||||||
|
|
||||||
|
getGalleryImage(imageFilename: string) {
|
||||||
|
return this.galleryImage.and(this.page.getByAltText(imageFilename))
|
||||||
|
}
|
||||||
|
|
||||||
|
getTaskImage(imageFilename: string) {
|
||||||
|
return this.tasks.getByAltText(imageFilename)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trigger the queue store and tasks to update */
|
||||||
|
async triggerTasksUpdate() {
|
||||||
|
await this.page.evaluate(() => {
|
||||||
|
window['app']['api'].dispatchCustomEvent('status', {
|
||||||
|
exec_info: { queue_remaining: 0 }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
98
browser_tests/fixtures/components/Topbar.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { Locator, Page } from '@playwright/test'
|
||||||
|
|
||||||
|
export class Topbar {
|
||||||
|
constructor(public readonly page: Page) {}
|
||||||
|
|
||||||
|
async getTabNames(): Promise<string[]> {
|
||||||
|
return await this.page
|
||||||
|
.locator('.workflow-tabs .workflow-label')
|
||||||
|
.allInnerTexts()
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActiveTabName(): Promise<string> {
|
||||||
|
return this.page
|
||||||
|
.locator('.workflow-tabs .p-togglebutton-checked')
|
||||||
|
.innerText()
|
||||||
|
}
|
||||||
|
|
||||||
|
async openSubmenuMobile() {
|
||||||
|
await this.page.locator('.p-menubar-mobile .p-menubar-button').click()
|
||||||
|
}
|
||||||
|
|
||||||
|
getMenuItem(itemLabel: string): Locator {
|
||||||
|
return this.page.locator(`.p-menubar-item-label:text-is("${itemLabel}")`)
|
||||||
|
}
|
||||||
|
|
||||||
|
getWorkflowTab(tabName: string): Locator {
|
||||||
|
return this.page
|
||||||
|
.locator(`.workflow-tabs .workflow-label:has-text("${tabName}")`)
|
||||||
|
.locator('..')
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeWorkflowTab(tabName: string) {
|
||||||
|
const tab = this.getWorkflowTab(tabName)
|
||||||
|
await tab.locator('.close-button').click({ force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
getSaveDialog(): Locator {
|
||||||
|
return this.page.locator('.p-dialog-content input')
|
||||||
|
}
|
||||||
|
|
||||||
|
saveWorkflow(workflowName: string): Promise<void> {
|
||||||
|
return this._saveWorkflow(workflowName, 'Save')
|
||||||
|
}
|
||||||
|
|
||||||
|
saveWorkflowAs(workflowName: string): Promise<void> {
|
||||||
|
return this._saveWorkflow(workflowName, 'Save As')
|
||||||
|
}
|
||||||
|
|
||||||
|
exportWorkflow(workflowName: string): Promise<void> {
|
||||||
|
return this._saveWorkflow(workflowName, 'Export')
|
||||||
|
}
|
||||||
|
|
||||||
|
async _saveWorkflow(
|
||||||
|
workflowName: string,
|
||||||
|
command: 'Save' | 'Save As' | 'Export'
|
||||||
|
) {
|
||||||
|
await this.triggerTopbarCommand(['Workflow', command])
|
||||||
|
await this.getSaveDialog().fill(workflowName)
|
||||||
|
await this.page.keyboard.press('Enter')
|
||||||
|
|
||||||
|
// Wait for workflow service to finish saving
|
||||||
|
await this.page.waitForFunction(
|
||||||
|
() => !window['app'].extensionManager.workflow.isBusy,
|
||||||
|
undefined,
|
||||||
|
{ timeout: 3000 }
|
||||||
|
)
|
||||||
|
// Wait for the dialog to close.
|
||||||
|
await this.getSaveDialog().waitFor({ state: 'hidden', timeout: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async triggerTopbarCommand(path: string[]) {
|
||||||
|
if (path.length < 2) {
|
||||||
|
throw new Error('Path is too short')
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabName = path[0]
|
||||||
|
const topLevelMenu = this.page.locator(
|
||||||
|
`.top-menubar .p-menubar-item-label:text-is("${tabName}")`
|
||||||
|
)
|
||||||
|
await topLevelMenu.waitFor({ state: 'visible' })
|
||||||
|
await topLevelMenu.click()
|
||||||
|
|
||||||
|
for (let i = 1; i < path.length; i++) {
|
||||||
|
const commandName = path[i]
|
||||||
|
const menuItem = this.page
|
||||||
|
.locator(
|
||||||
|
`.top-menubar .p-menubar-submenu .p-menubar-item:has-text("${commandName}")`
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
await menuItem.waitFor({ state: 'visible' })
|
||||||
|
await menuItem.hover()
|
||||||
|
|
||||||
|
if (i === path.length - 1) {
|
||||||
|
await menuItem.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
browser_tests/fixtures/types.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export interface Position {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Size {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
256
browser_tests/fixtures/utils/litegraphUtils.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import type { Page } from '@playwright/test'
|
||||||
|
|
||||||
|
import type { NodeId } from '../../../src/types/comfyWorkflow'
|
||||||
|
import { ManageGroupNode } from '../../helpers/manageGroupNode'
|
||||||
|
import type { ComfyPage } from '../ComfyPage'
|
||||||
|
import type { Position, Size } from '../types'
|
||||||
|
|
||||||
|
export class NodeSlotReference {
|
||||||
|
constructor(
|
||||||
|
readonly type: 'input' | 'output',
|
||||||
|
readonly index: number,
|
||||||
|
readonly node: NodeReference
|
||||||
|
) {}
|
||||||
|
async getPosition() {
|
||||||
|
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
|
||||||
|
([type, id, index]) => {
|
||||||
|
const node = window['app'].graph.getNodeById(id)
|
||||||
|
if (!node) throw new Error(`Node ${id} not found.`)
|
||||||
|
return window['app'].canvas.ds.convertOffsetToCanvas(
|
||||||
|
node.getConnectionPos(type === 'input', index)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[this.type, this.node.id, this.index] as const
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
x: pos[0],
|
||||||
|
y: pos[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async getLinkCount() {
|
||||||
|
return await this.node.comfyPage.page.evaluate(
|
||||||
|
([type, id, index]) => {
|
||||||
|
const node = window['app'].graph.getNodeById(id)
|
||||||
|
if (!node) throw new Error(`Node ${id} not found.`)
|
||||||
|
if (type === 'input') {
|
||||||
|
return node.inputs[index].link == null ? 0 : 1
|
||||||
|
}
|
||||||
|
return node.outputs[index].links?.length ?? 0
|
||||||
|
},
|
||||||
|
[this.type, this.node.id, this.index] as const
|
||||||
|
)
|
||||||
|
}
|
||||||
|
async removeLinks() {
|
||||||
|
await this.node.comfyPage.page.evaluate(
|
||||||
|
([type, id, index]) => {
|
||||||
|
const node = window['app'].graph.getNodeById(id)
|
||||||
|
if (!node) throw new Error(`Node ${id} not found.`)
|
||||||
|
if (type === 'input') {
|
||||||
|
node.disconnectInput(index)
|
||||||
|
} else {
|
||||||
|
node.disconnectOutput(index)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[this.type, this.node.id, this.index] as const
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NodeWidgetReference {
|
||||||
|
constructor(
|
||||||
|
readonly index: number,
|
||||||
|
readonly node: NodeReference
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getPosition(): Promise<Position> {
|
||||||
|
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
|
||||||
|
([id, index]) => {
|
||||||
|
const node = window['app'].graph.getNodeById(id)
|
||||||
|
if (!node) throw new Error(`Node ${id} not found.`)
|
||||||
|
const widget = node.widgets[index]
|
||||||
|
if (!widget) throw new Error(`Widget ${index} not found.`)
|
||||||
|
|
||||||
|
const [x, y, w, h] = node.getBounding()
|
||||||
|
return window['app'].canvas.ds.convertOffsetToCanvas([
|
||||||
|
x + w / 2,
|
||||||
|
y + window['LiteGraph']['NODE_TITLE_HEIGHT'] + widget.last_y + 1
|
||||||
|
])
|
||||||
|
},
|
||||||
|
[this.node.id, this.index] as const
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
x: pos[0],
|
||||||
|
y: pos[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NodeReference {
|
||||||
|
constructor(
|
||||||
|
readonly id: NodeId,
|
||||||
|
readonly comfyPage: ComfyPage
|
||||||
|
) {}
|
||||||
|
async exists(): Promise<boolean> {
|
||||||
|
return await this.comfyPage.page.evaluate((id) => {
|
||||||
|
const node = window['app'].graph.getNodeById(id)
|
||||||
|
return !!node
|
||||||
|
}, this.id)
|
||||||
|
}
|
||||||
|
getType(): Promise<string> {
|
||||||
|
return this.getProperty('type')
|
||||||
|
}
|
||||||
|
async getPosition(): Promise<Position> {
|
||||||
|
const pos = await this.comfyPage.convertOffsetToCanvas(
|
||||||
|
await this.getProperty<[number, number]>('pos')
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
x: pos[0],
|
||||||
|
y: pos[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async getBounding(): Promise<Position & Size> {
|
||||||
|
const [x, y, width, height]: [number, number, number, number] =
|
||||||
|
await this.comfyPage.page.evaluate((id) => {
|
||||||
|
const node = window['app'].graph.getNodeById(id)
|
||||||
|
if (!node) throw new Error('Node not found')
|
||||||
|
return node.getBounding()
|
||||||
|
}, this.id)
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async getSize(): Promise<Size> {
|
||||||
|
const size = await this.getProperty<[number, number]>('size')
|
||||||
|
return {
|
||||||
|
width: size[0],
|
||||||
|
height: size[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async getFlags(): Promise<{ collapsed?: boolean; pinned?: boolean }> {
|
||||||
|
return await this.getProperty('flags')
|
||||||
|
}
|
||||||
|
async isPinned() {
|
||||||
|
return !!(await this.getFlags()).pinned
|
||||||
|
}
|
||||||
|
async isCollapsed() {
|
||||||
|
return !!(await this.getFlags()).collapsed
|
||||||
|
}
|
||||||
|
async isBypassed() {
|
||||||
|
return (await this.getProperty<number | null | undefined>('mode')) === 4
|
||||||
|
}
|
||||||
|
async getProperty<T>(prop: string): Promise<T> {
|
||||||
|
return await this.comfyPage.page.evaluate(
|
||||||
|
([id, prop]) => {
|
||||||
|
const node = window['app'].graph.getNodeById(id)
|
||||||
|
if (!node) throw new Error('Node not found')
|
||||||
|
return node[prop]
|
||||||
|
},
|
||||||
|
[this.id, prop] as const
|
||||||
|
)
|
||||||
|
}
|
||||||
|
async getOutput(index: number) {
|
||||||
|
return new NodeSlotReference('output', index, this)
|
||||||
|
}
|
||||||
|
async getInput(index: number) {
|
||||||
|
return new NodeSlotReference('input', index, this)
|
||||||
|
}
|
||||||
|
async getWidget(index: number) {
|
||||||
|
return new NodeWidgetReference(index, this)
|
||||||
|
}
|
||||||
|
async click(
|
||||||
|
position: 'title' | 'collapse',
|
||||||
|
options?: Parameters<Page['click']>[1] & { moveMouseToEmptyArea?: boolean }
|
||||||
|
) {
|
||||||
|
const nodePos = await this.getPosition()
|
||||||
|
const nodeSize = await this.getSize()
|
||||||
|
let clickPos: Position
|
||||||
|
switch (position) {
|
||||||
|
case 'title':
|
||||||
|
clickPos = { x: nodePos.x + nodeSize.width / 2, y: nodePos.y - 15 }
|
||||||
|
break
|
||||||
|
case 'collapse':
|
||||||
|
clickPos = { x: nodePos.x + 5, y: nodePos.y - 10 }
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
throw new Error(`Invalid click position ${position}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const moveMouseToEmptyArea = options?.moveMouseToEmptyArea
|
||||||
|
if (options) {
|
||||||
|
delete options.moveMouseToEmptyArea
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.comfyPage.canvas.click({
|
||||||
|
...options,
|
||||||
|
position: clickPos
|
||||||
|
})
|
||||||
|
await this.comfyPage.nextFrame()
|
||||||
|
if (moveMouseToEmptyArea) {
|
||||||
|
await this.comfyPage.moveMouseToEmptyArea()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async copy() {
|
||||||
|
await this.click('title')
|
||||||
|
await this.comfyPage.ctrlC()
|
||||||
|
await this.comfyPage.nextFrame()
|
||||||
|
}
|
||||||
|
async connectWidget(
|
||||||
|
originSlotIndex: number,
|
||||||
|
targetNode: NodeReference,
|
||||||
|
targetWidgetIndex: number
|
||||||
|
) {
|
||||||
|
const originSlot = await this.getOutput(originSlotIndex)
|
||||||
|
const targetWidget = await targetNode.getWidget(targetWidgetIndex)
|
||||||
|
await this.comfyPage.dragAndDrop(
|
||||||
|
await originSlot.getPosition(),
|
||||||
|
await targetWidget.getPosition()
|
||||||
|
)
|
||||||
|
return originSlot
|
||||||
|
}
|
||||||
|
async connectOutput(
|
||||||
|
originSlotIndex: number,
|
||||||
|
targetNode: NodeReference,
|
||||||
|
targetSlotIndex: number
|
||||||
|
) {
|
||||||
|
const originSlot = await this.getOutput(originSlotIndex)
|
||||||
|
const targetSlot = await targetNode.getInput(targetSlotIndex)
|
||||||
|
await this.comfyPage.dragAndDrop(
|
||||||
|
await originSlot.getPosition(),
|
||||||
|
await targetSlot.getPosition()
|
||||||
|
)
|
||||||
|
return originSlot
|
||||||
|
}
|
||||||
|
async getContextMenuOptionNames() {
|
||||||
|
await this.click('title', { button: 'right' })
|
||||||
|
const ctx = this.comfyPage.page.locator('.litecontextmenu')
|
||||||
|
return await ctx.locator('.litemenu-entry').allInnerTexts()
|
||||||
|
}
|
||||||
|
async clickContextMenuOption(optionText: string) {
|
||||||
|
await this.click('title', { button: 'right' })
|
||||||
|
const ctx = this.comfyPage.page.locator('.litecontextmenu')
|
||||||
|
await ctx.getByText(optionText).click()
|
||||||
|
}
|
||||||
|
async convertToGroupNode(groupNodeName: string = 'GroupNode') {
|
||||||
|
await this.clickContextMenuOption('Convert to Group Node')
|
||||||
|
await this.comfyPage.fillPromptDialog(groupNodeName)
|
||||||
|
await this.comfyPage.nextFrame()
|
||||||
|
const nodes = await this.comfyPage.getNodeRefsByType(
|
||||||
|
`workflow>${groupNodeName}`
|
||||||
|
)
|
||||||
|
if (nodes.length !== 1) {
|
||||||
|
throw new Error(`Did not find single group node (found=${nodes.length})`)
|
||||||
|
}
|
||||||
|
return nodes[0]
|
||||||
|
}
|
||||||
|
async manageGroupNode() {
|
||||||
|
await this.clickContextMenuOption('Manage Group Node')
|
||||||
|
await this.comfyPage.nextFrame()
|
||||||
|
return new ManageGroupNode(
|
||||||
|
this.comfyPage.page,
|
||||||
|
this.comfyPage.page.locator('.comfy-group-manage')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
162
browser_tests/fixtures/utils/taskHistory.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import _ from 'lodash'
|
||||||
|
import path from 'path'
|
||||||
|
import type { Request, Route } from 'playwright'
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
HistoryTaskItem,
|
||||||
|
TaskItem,
|
||||||
|
TaskOutput
|
||||||
|
} from '../../../src/types/apiTypes'
|
||||||
|
import type { ComfyPage } from '../ComfyPage'
|
||||||
|
|
||||||
|
/** keyof TaskOutput[string] */
|
||||||
|
type OutputFileType = 'images' | 'audio' | 'animated'
|
||||||
|
|
||||||
|
const DEFAULT_IMAGE = 'example.webp'
|
||||||
|
|
||||||
|
const getFilenameParam = (request: Request) => {
|
||||||
|
const url = new URL(request.url())
|
||||||
|
return url.searchParams.get('filename') || DEFAULT_IMAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
const getContentType = (filename: string, fileType: OutputFileType) => {
|
||||||
|
const subtype = path.extname(filename).slice(1)
|
||||||
|
switch (fileType) {
|
||||||
|
case 'images':
|
||||||
|
return `image/${subtype}`
|
||||||
|
case 'audio':
|
||||||
|
return `audio/${subtype}`
|
||||||
|
case 'animated':
|
||||||
|
return `video/${subtype}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setQueueIndex = (task: TaskItem) => {
|
||||||
|
task.prompt[0] = TaskHistory.queueIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
const setPromptId = (task: TaskItem) => {
|
||||||
|
task.prompt[1] = uuidv4()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class TaskHistory {
|
||||||
|
static queueIndex = 0
|
||||||
|
static readonly defaultTask: Readonly<HistoryTaskItem> = {
|
||||||
|
prompt: [0, 'prompt-id', {}, { client_id: uuidv4() }, []],
|
||||||
|
outputs: {},
|
||||||
|
status: {
|
||||||
|
status_str: 'success',
|
||||||
|
completed: true,
|
||||||
|
messages: []
|
||||||
|
},
|
||||||
|
taskType: 'History'
|
||||||
|
}
|
||||||
|
private tasks: HistoryTaskItem[] = []
|
||||||
|
private outputContentTypes: Map<string, string> = new Map()
|
||||||
|
|
||||||
|
constructor(readonly comfyPage: ComfyPage) {}
|
||||||
|
|
||||||
|
private loadAsset: (filename: string) => Buffer = _.memoize(
|
||||||
|
(filename: string) => {
|
||||||
|
const filePath = this.comfyPage.assetPath(filename)
|
||||||
|
return fs.readFileSync(filePath)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
private async handleGetHistory(route: Route) {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(this.tasks)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleGetView(route: Route) {
|
||||||
|
const fileName = getFilenameParam(route.request())
|
||||||
|
if (!this.outputContentTypes.has(fileName)) route.continue()
|
||||||
|
|
||||||
|
const asset = this.loadAsset(fileName)
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: this.outputContentTypes.get(fileName),
|
||||||
|
body: asset,
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'public, max-age=31536000',
|
||||||
|
'Content-Length': asset.byteLength.toString()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async setupRoutes() {
|
||||||
|
return this.comfyPage.page.route(
|
||||||
|
/.*\/api\/(view|history)(\?.*)?$/,
|
||||||
|
async (route) => {
|
||||||
|
const request = route.request()
|
||||||
|
const method = request.method()
|
||||||
|
|
||||||
|
const isViewReq = request.url().includes('view') && method === 'GET'
|
||||||
|
if (isViewReq) return this.handleGetView(route)
|
||||||
|
|
||||||
|
const isHistoryPath = request.url().includes('history')
|
||||||
|
const isGetHistoryReq = isHistoryPath && method === 'GET'
|
||||||
|
if (isGetHistoryReq) return this.handleGetHistory(route)
|
||||||
|
|
||||||
|
const isClearReq =
|
||||||
|
method === 'POST' &&
|
||||||
|
isHistoryPath &&
|
||||||
|
request.postDataJSON()?.clear === true
|
||||||
|
if (isClearReq) return this.clearTasks()
|
||||||
|
|
||||||
|
return route.continue()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private createOutputs(
|
||||||
|
filenames: string[],
|
||||||
|
filetype: OutputFileType
|
||||||
|
): TaskOutput {
|
||||||
|
return filenames.reduce((outputs, filename, i) => {
|
||||||
|
const nodeId = `${i + 1}`
|
||||||
|
outputs[nodeId] = {
|
||||||
|
[filetype]: [{ filename, subfolder: '', type: 'output' }]
|
||||||
|
}
|
||||||
|
const contentType = getContentType(filename, filetype)
|
||||||
|
this.outputContentTypes.set(filename, contentType)
|
||||||
|
return outputs
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
private addTask(task: HistoryTaskItem) {
|
||||||
|
setPromptId(task)
|
||||||
|
setQueueIndex(task)
|
||||||
|
this.tasks.unshift(task) // Tasks are added to the front of the queue
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTasks(): this {
|
||||||
|
this.tasks = []
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
withTask(
|
||||||
|
outputFilenames: string[],
|
||||||
|
outputFiletype: OutputFileType = 'images',
|
||||||
|
overrides: Partial<HistoryTaskItem> = {}
|
||||||
|
): this {
|
||||||
|
this.addTask({
|
||||||
|
...TaskHistory.defaultTask,
|
||||||
|
outputs: this.createOutputs(outputFilenames, outputFiletype),
|
||||||
|
...overrides
|
||||||
|
})
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Repeats the last task in the task history a specified number of times. */
|
||||||
|
repeat(n: number): this {
|
||||||
|
for (let i = 0; i < n; i++)
|
||||||
|
this.addTask(structuredClone(this.tasks.at(0)) as HistoryTaskItem)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
}
|
||||||
51
browser_tests/fixtures/ws.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { test as base } from '@playwright/test'
|
||||||
|
|
||||||
|
export const webSocketFixture = base.extend<{
|
||||||
|
ws: { trigger(data: any, url?: string): Promise<void> }
|
||||||
|
}>({
|
||||||
|
ws: [
|
||||||
|
async ({ page }, use) => {
|
||||||
|
// Each time a page loads, to catch navigations
|
||||||
|
page.on('load', async () => {
|
||||||
|
await page.evaluate(function () {
|
||||||
|
// Create a wrapper for WebSocket that stores them globally
|
||||||
|
// so we can look it up to trigger messages
|
||||||
|
const store: Record<string, WebSocket> = ((window as any).__ws__ = {})
|
||||||
|
window.WebSocket = class extends window.WebSocket {
|
||||||
|
constructor() {
|
||||||
|
// @ts-expect-error
|
||||||
|
super(...arguments)
|
||||||
|
store[this.url] = this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await use({
|
||||||
|
async trigger(data, url) {
|
||||||
|
// Trigger a websocket event on the page
|
||||||
|
await page.evaluate(
|
||||||
|
function ([data, url]) {
|
||||||
|
if (!url) {
|
||||||
|
// If no URL specified, use page URL
|
||||||
|
const u = new URL(window.location.toString())
|
||||||
|
u.protocol = 'ws:'
|
||||||
|
u.pathname = '/'
|
||||||
|
url = u.toString() + 'ws'
|
||||||
|
}
|
||||||
|
const ws: WebSocket = (window as any).__ws__[url]
|
||||||
|
ws.dispatchEvent(
|
||||||
|
new MessageEvent('message', {
|
||||||
|
data
|
||||||
|
})
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[JSON.stringify(data), url]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
// We need this to run automatically as the first thing so it adds handlers as soon as the page loads
|
||||||
|
{ auto: true }
|
||||||
|
]
|
||||||
|
})
|
||||||
21
browser_tests/globalSetup.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { FullConfig } from '@playwright/test'
|
||||||
|
import dotenv from 'dotenv'
|
||||||
|
|
||||||
|
import { backupPath } from './utils/backupUtils'
|
||||||
|
|
||||||
|
dotenv.config()
|
||||||
|
|
||||||
|
export default function globalSetup(config: FullConfig) {
|
||||||
|
if (!process.env.CI) {
|
||||||
|
if (process.env.TEST_COMFYUI_DIR) {
|
||||||
|
backupPath([process.env.TEST_COMFYUI_DIR, 'user'])
|
||||||
|
backupPath([process.env.TEST_COMFYUI_DIR, 'models'], {
|
||||||
|
renameAndReplaceWithScaffolding: true
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
'Set TEST_COMFYUI_DIR in .env to prevent user data (settings, workflows, etc.) from being overwritten'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
browser_tests/globalTeardown.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { FullConfig } from '@playwright/test'
|
||||||
|
import dotenv from 'dotenv'
|
||||||
|
|
||||||
|
import { restorePath } from './utils/backupUtils'
|
||||||
|
|
||||||
|
dotenv.config()
|
||||||
|
|
||||||
|
export default function globalTeardown(config: FullConfig) {
|
||||||
|
if (!process.env.CI && process.env.TEST_COMFYUI_DIR) {
|
||||||
|
restorePath([process.env.TEST_COMFYUI_DIR, 'user'])
|
||||||
|
restorePath([process.env.TEST_COMFYUI_DIR, 'models'])
|
||||||
|
}
|
||||||
|
}
|
||||||
39
browser_tests/graphCanvasMenu.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { expect } from '@playwright/test'
|
||||||
|
|
||||||
|
import { comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||||
|
|
||||||
|
test.describe('Graph Canvas Menu', () => {
|
||||||
|
test.beforeEach(async ({ comfyPage }) => {
|
||||||
|
// Set link render mode to spline to make sure it's not affected by other tests'
|
||||||
|
// side effects.
|
||||||
|
await comfyPage.setSetting('Comfy.LinkRenderMode', 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Can toggle link visibility', async ({ comfyPage }) => {
|
||||||
|
// Note: `Comfy.Graph.CanvasMenu` is disabled in comfyPage setup.
|
||||||
|
// so no cleanup is needed.
|
||||||
|
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', true)
|
||||||
|
|
||||||
|
const button = comfyPage.page.getByTestId('toggle-link-visibility-button')
|
||||||
|
await button.click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||||
|
'canvas-with-hidden-links.png'
|
||||||
|
)
|
||||||
|
const hiddenLinkRenderMode = await comfyPage.page.evaluate(() => {
|
||||||
|
return window['LiteGraph'].HIDDEN_LINK
|
||||||
|
})
|
||||||
|
expect(await comfyPage.getSetting('Comfy.LinkRenderMode')).toBe(
|
||||||
|
hiddenLinkRenderMode
|
||||||
|
)
|
||||||
|
|
||||||
|
await button.click()
|
||||||
|
await comfyPage.nextFrame()
|
||||||
|
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||||
|
'canvas-with-visible-links.png'
|
||||||
|
)
|
||||||
|
expect(await comfyPage.getSetting('Comfy.LinkRenderMode')).not.toBe(
|
||||||
|
hiddenLinkRenderMode
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 99 KiB |