Compare commits

...

223 Commits

Author SHA1 Message Date
Chenlei Hu
67ee8726ef 1.3.29 (#1383) 2024-10-30 16:34:49 -04:00
Chenlei Hu
e48c78541c Hide empty folders when searching in model library (#1382) 2024-10-30 16:27:35 -04:00
Chenlei Hu
bf7a9bf5eb Update litegraph (link snap to slot & highlight) (#1378)
* Update litegraph (link snap)

* Add settings

* nit

* Add playwright test

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-10-30 15:23:58 -04:00
filtered
3fb2d423ba Update litegraph API - 237 (#1376) 2024-10-30 13:43:38 -04:00
Chenlei Hu
74f7311585 Fix jest test mock (#1375) 2024-10-30 10:37:54 -04:00
Chenlei Hu
97c38583e9 Rename workspaceStateStore to workspaceStore (#1374) 2024-10-30 09:49:23 -04:00
Chenlei Hu
324eff93fd Update Litegraph API - canvas.state (#1372)
* Update Litegraph API - canvas.state

* Update litegraph

---------

Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2024-10-29 22:07:04 -04:00
Chenlei Hu
4b1104f52c Add node id to execution error report (#1371) 2024-10-29 21:48:45 -04:00
Chenlei Hu
d702fc81a2 1.3.28 (#1370) 2024-10-29 20:30:07 -04:00
Chenlei Hu
2a94ab4423 Fix empty model library folder content (#1369) 2024-10-29 20:28:50 -04:00
Chenlei Hu
37f6c89383 1.3.27 (#1366) 2024-10-29 17:56:28 -04:00
Chenlei Hu
35fab0bef3 Focus mode (#1365)
* Menu hamburger

* Focus

* nit

* nit

* nit

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-10-29 17:25:18 -04:00
filtered
795e932b8f Fix husky pre-commit & type check only staged (#1361)
* Fix husky pre-commit on some Windows clients

https://github.com/typicode/husky/issues/1072#issuecomment-1784006332

* Limit commit type check to staged files

Adds tsc-files package (dev only) and its config

* Remove deprecated git add from lint-staged
2024-10-29 14:15:07 -04:00
Chenlei Hu
8dddffe840 Use browser download in missing model dialog (#1362)
* Remove custom backend download logic

* Add download hooks

* Download button

* Use browser download

* Update test
2024-10-29 14:07:16 -04:00
Chenlei Hu
1f91a88d7b Move linkRenderMode extension to core (#1359) 2024-10-29 11:00:10 -04:00
Chenlei Hu
10f43be911 Remove show model folder checkbox in missing model dialog (#1358)
* Remove show model folder checkbox in missing model dialog

* nit

* nit
2024-10-29 10:15:31 -04:00
Chenlei Hu
87517daf1f Restyle missing model warning dialog (#1354)
* Restyle missing model dialog

* nit

* nit

* nit

* nit
2024-10-29 09:26:02 -04:00
Chenlei Hu
739ebd3d04 Auto-expand model library tree on search (#1357) 2024-10-29 09:24:56 -04:00
Chenlei Hu
4582c71583 [Refactor] Rework modelStore and ModelLibrarySidebarTab (#1350)
* nit

* Rename

* nit

* Move load model folders to app level

* Various fixes

* nit

* nit

* wip

* nit

* nit

* nit

* Split

* nit

* Add back spinner

* nit

* nit

* Add refresh button

* nit

* nit

* Preserve model folder order

* Avoid order change on folder open
2024-10-28 21:23:53 -04:00
Chenlei Hu
757f0ced81 1.3.26 (#1353) 2024-10-28 19:59:43 -04:00
Chenlei Hu
a471a3f302 [skip ci] Add typecheck pre-commit hook (#1352) 2024-10-28 19:56:04 -04:00
Chenlei Hu
5a3a8d32ab Update litegraph 0.8.10 (#1351) 2024-10-28 19:49:48 -04:00
Chenlei Hu
44b109a449 Add type annotation for missingNodeTypes (#1349)
* Add type annotation for missingNodeTypes

* nit

* nit
2024-10-28 17:26:52 -04:00
Chenlei Hu
229896a4b7 Restyle missing node warning dialog (#1348)
* nit

* Restyle missing node warning dialog

* nit

* nit
2024-10-28 16:45:47 -04:00
Chenlei Hu
997b5ee819 Update litegraph (Fix group select) (#1342)
* Update litegraph (Fix group select)

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-10-27 22:05:18 -04:00
Chenlei Hu
ba99eca700 1.3.25 (#1341) 2024-10-27 20:07:47 -04:00
Chenlei Hu
82c369322d Handle invalid node def errors (#1340)
* nit

* Add error handling

* nit

* nit
2024-10-27 20:07:05 -04:00
Chenlei Hu
546c5dabc8 Update litegraph (Slot highlight) (#1339)
* Update litegraph (Slot highlight)

* Disable tooltip setting in playwright tests

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-10-27 19:31:22 -04:00
filtered
7d450adf93 Remove unused param - litegraph update (#1335) 2024-10-27 16:21:14 -04:00
Chenlei Hu
eed92864f2 Enable ts-strict for changeTracker (#1338)
* Fix app getter

* nit

* nit

* nit

* Fix rest of errors

* nit
2024-10-27 16:20:32 -04:00
Chenlei Hu
8fd7852740 Enable ts-strict for nodeBookmarkStore (#1336) 2024-10-27 15:06:37 -04:00
Chenlei Hu
2a927bb9ea [skip ci] Update README (Dev section) (#1334)
* Expand test section

* Add techstack section
2024-10-27 10:12:54 -04:00
Chenlei Hu
c566491ac7 Enable ts-strict for queueStore (#1333) 2024-10-27 10:07:09 -04:00
Zoltán Dócs
7729611a2a Bugfix node widgets wrongly being prepared for qeueing on saving the workflow (#1331)
- calling graphToPrompt() invokes beforeQueued() which should not happen when saving the workflow
2024-10-27 09:59:27 -04:00
Chenlei Hu
8861492655 Enable ts-strict for workflowStore (#1332) 2024-10-27 09:59:17 -04:00
Chenlei Hu
fa9d944b32 Convert pinia stores from options API to composition API (#1330)
* Convert toastStore

* Convert workspaceStateStore

* Convert settingStore

* Convert queueStore

* Convert modelToNodeStore

* Convert modelStore

* Convert dialogStore

* nit

* nit

* nit
2024-10-27 08:47:24 -04:00
Chenlei Hu
880437f3c0 1.3.24 (#1326) 2024-10-26 19:50:05 -04:00
Chenlei Hu
c1e960c83c Update litegraph (Multi-group selection) (#1325) 2024-10-26 18:26:59 -04:00
Chenlei Hu
571386c061 Enable ts-strict for api.ts (#1324) 2024-10-26 18:08:24 -04:00
Chenlei Hu
44d886a18b Enable ts-strict for ui/settings (#1323) 2024-10-26 17:25:15 -04:00
Chenlei Hu
92ac403679 Enable ts-strict for settingStore (#1322) 2024-10-26 17:07:17 -04:00
Chenlei Hu
dc3dab4e1c Enable ts-strict for commandStore (#1321) 2024-10-26 17:05:42 -04:00
Chenlei Hu
386594554e Enable ts-strict for executionStore (#1320)
* Fix listener types

* nit

* nit

* fix type
2024-10-26 17:00:18 -04:00
Chenlei Hu
02a951ad58 Enable ts-strict for nodeDefStore (#1319) 2024-10-26 16:02:07 -04:00
Chenlei Hu
92f0f4a21c Convert nodeDefStore to use composition API (#1318)
* Convert nodeDefStore to use composition API

* nit
2024-10-26 15:48:40 -04:00
Chenlei Hu
645897f8b8 [Refactor] Make node badge a vue component (#1317)
* [Refactor] Make node badge a vue component

* Simplify badge logic

* nit
2024-10-26 15:28:14 -04:00
Chenlei Hu
ef4179a06c Update litegraph to 0.8.6 (#1316)
* Update litegraph

* Remove ts-expect-error
2024-10-26 14:36:45 -04:00
Chenlei Hu
a3e4af40c1 1.3.23 (#1314) 2024-10-25 20:13:42 -04:00
Björn Söderqvist
1dedce5ec6 Enable ts-strict for colorUtil, contextMenuFilter and linkRenderMode (#1313)
* Add types for colorUtil.ts

* Add types for contextMenuFilter

* Add types to linkRenderMode.ts
2024-10-25 15:42:15 -04:00
Chenlei Hu
3a4b36fb31 Disallow node library bookmark folder name with / (#1311) 2024-10-25 12:35:53 -04:00
Chenlei Hu
25457d31d4 Revert "Fix playwright test on settings API (#1303)" (#1310)
This reverts commit 54e833502a.
2024-10-25 12:09:50 -04:00
Chenlei Hu
4f9dc830b6 Setup clean setting state before every playwright test (#1309)
* Use reload

* setting setup

* nit

* Remove setting cleanups

* Wait for frame

* nit

* nit
2024-10-25 11:25:17 -04:00
Chenlei Hu
12d421b42c Update litegraph (TypeScript LiteGraphGlobal) (#1308) 2024-10-25 08:45:04 -04:00
Chenlei Hu
16ebfd6171 Fix import of NodeReference in ComfyPage (#1306) 2024-10-25 08:32:12 -04:00
Chenlei Hu
59c999324e Split ComfyPage fixture (#1305)
* Split down page components

* Move litegraph utils

* nit
2024-10-25 08:29:02 -04:00
Chenlei Hu
624bcc75ab Move ComfyPage to fixtures folder (#1304) 2024-10-25 08:01:50 -04:00
Chenlei Hu
54e833502a Fix playwright test on settings API (#1303) 2024-10-25 07:53:23 -04:00
Chenlei Hu
c3242711c7 Update bug-report.yaml (Add settings section) (#1301) 2024-10-25 07:46:45 -04:00
Chenlei Hu
17391e4aad Handle potential undefined node data in NodeBookmarkTreeExplorer (#1300) 2024-10-25 07:41:43 -04:00
Chenlei Hu
48b840a88d 1.3.22 (#1297) 2024-10-24 20:03:40 -04:00
Chenlei Hu
377fed584f [Extension API] Register custom bottom panel tabs (#1296)
* Bottom panel API

* Update README
2024-10-24 19:58:44 -04:00
Chenlei Hu
14a6687cc9 Integrated terminal (#1295)
* Add terminal tab

* Add basic terminal

* Style terminal

* Add keybinding

* Auto scroll:

* Mock for jest test
2024-10-24 17:31:00 -04:00
Chenlei Hu
d142893244 Add bottom panel support (#1294)
* Add bottom panel

* Bottom panel store

* Extract ExtensionSlot component

* Tab rendering

* Add toggle button on top menu bar

* nit

* Add toggle button tooltip

* Add command
2024-10-24 15:15:19 -04:00
Chenlei Hu
957a767ed0 New settings API (#1292)
* Add settings API

* Add playwright test

* Update README
2024-10-24 10:26:01 -04:00
YANG Zhitao
3553c8e0d4 Enable ts-strict for slotDefaults.ts (#1290) 2024-10-24 07:31:45 -04:00
YANG Zhitao
05221f7961 Enable ts-strict for clipspace.ts (#1291) 2024-10-24 07:31:15 -04:00
Chenlei Hu
d113072a64 1.3.21 (#1289) 2024-10-23 20:38:50 -04:00
Chenlei Hu
afa619b7df Revert "Enable ts-strict for invertMenuScrolling.ts (#1264)" (#1288)
This reverts commit 9388ee0705.
2024-10-23 20:37:40 -04:00
Chenlei Hu
e25bbc19cb Revert "Enable ts-strict for contextMenuFilter.ts (#1263)" (#1287)
This reverts commit b655c5544d.
2024-10-23 20:37:03 -04:00
Chenlei Hu
0251bc9e6c 1.3.20 (#1285) 2024-10-23 19:53:33 -04:00
Chenlei Hu
7a3f20c57d Fix sidebar scrollpanel style (#1283) 2024-10-23 18:27:54 -04:00
Chenlei Hu
269fc7c8c9 Remove misleading console error on paste (#1282) 2024-10-23 14:59:24 -04:00
Chenlei Hu
db08f74d6a Update litegraph (TypeScript LgraphNode) (#1281) 2024-10-23 14:38:09 -04:00
Chenlei Hu
5db757ade2 Enable ts-strict for uploadImage.ts (#1280) 2024-10-23 13:59:47 -04:00
Chenlei Hu
59c03d2de5 [Refactor] Rename ModelStore to ModelFolder (#1244)
* Refactor click

* Rename ModelStore to ModelFolder
2024-10-23 12:04:49 -04:00
YANG Zhitao
b655c5544d Enable ts-strict for contextMenuFilter.ts (#1263)
* Enable ts-strict for contextMenuFilter.ts

* function instead of arrow function

* tackle @ts-expect-error at contextMenuFilter.ts by use class instead of function + prototype
2024-10-23 12:03:50 -04:00
YANG Zhitao
9388ee0705 Enable ts-strict for invertMenuScrolling.ts (#1264) 2024-10-23 12:03:33 -04:00
juju
f228ec29eb Enable ts-strict for editAttention.ts (#1269)
* fix typecheck errors

* revert tsconfig strict true
2024-10-23 12:02:52 -04:00
Yoland Yan
7239e94092 Mini lang (#1245) 2024-10-13 09:56:58 +02:00
Chenlei Hu
e33a5f7736 1.3.19 (#1243) 2024-10-12 17:21:59 -04:00
Chenlei Hu
c1c990e6f3 Properly show empty folder in model sidebar tab (#1242)
* Properly show empty folder in model sidebar tab

* nit

* nit
2024-10-12 17:02:58 -04:00
Chenlei Hu
a890756868 Enable ts-strict for modelStore.ts (#1241) 2024-10-12 16:38:27 -04:00
Chenlei Hu
015ee2df15 Revert "Enable ts-strict for colorUtil.ts (#1239)" (#1240)
This reverts commit c3b2697568.
2024-10-12 15:58:43 -04:00
Chenlei Hu
c3b2697568 Enable ts-strict for colorUtil.ts (#1239) 2024-10-12 14:11:32 -04:00
Chenlei Hu
dfcabd2834 Enable ts-strict for treeUtil.ts (#1238) 2024-10-12 14:10:08 -04:00
Chenlei Hu
634196cbd6 Enable ts-strict for dialogStore.ts (#1236) 2024-10-12 12:17:55 -04:00
Chenlei Hu
5611e90fda Add ts-strict-ignore plugin (#1235)
* Add ts-strict-ignore plugin

* nit

* Add to typecheck script
2024-10-12 11:56:49 -04:00
Chenlei Hu
c23d95f8f9 1.3.18 (#1232) 2024-10-11 16:27:50 -04:00
Chenlei Hu
c2377b62ac Fix sidebar tab bg color (#1229)
* Fix node library sidebar style

* Fix styles

* nit
2024-10-11 15:04:29 -04:00
Chenlei Hu
f328d4cd81 Hide system scrollbar for queue sidebar tab (#1227) 2024-10-11 13:59:51 -04:00
Chenlei Hu
fbc1482b90 Use scrollpanel in sidebar template (#1226)
* Use scrollpanel in sidebar template

* Migrate

* nit
2024-10-11 13:41:41 -04:00
Chenlei Hu
90e07af4f5 Update litegraph (TypeScript LGraphCanvas) (#1225) 2024-10-11 13:39:19 -04:00
AustinMroz
014c3f3172 Fix load workflow with converted widget (#1222)
f74973 introduced a regression where a node which has had an input
added, will clobber a converted input of in a workflow created before
the input was added.

The prior behaviour was also incorrect (the new input would not exist on
the node), but would often be runnable.

Keeping the position of the converted widget and adding the new input to
the end is an unfortunate compromise. Doing it the other way around
breaks primitive nodes
2024-10-11 12:20:03 -04:00
Chenlei Hu
419009424b Handle missing extraPngInfo field in queue (#1223) 2024-10-11 12:16:35 -04:00
Chenlei Hu
f599c9bcb8 Fix format (#1224) 2024-10-11 12:16:17 -04:00
Chenlei Hu
6c696cddb9 Fix dialog header component error (#1221) 2024-10-11 11:55:13 -04:00
Chenlei Hu
d787c21f8b [skip ci] Explicitly specify prettier printWidth (#1220) 2024-10-11 11:53:35 -04:00
Chenlei Hu
f96f08be32 Add ru to locale list (#1218) 2024-10-11 10:43:10 -04:00
Ioan
8ebb51b9a3 Update i18n.ts (#1214)
* Update i18n.ts

Added Russian translation

* Update i18n.ts
2024-10-11 09:14:04 -04:00
Chenlei Hu
60e1b82df6 Update litegraph (TypeScript LLink) (#1213)
* Update litegraph (TypeScript LLink)

* Remove ts-expect-error
2024-10-10 20:54:31 -04:00
Chenlei Hu
459afa158c Restyle dialog with scrollpanel (#1212) 2024-10-10 20:27:14 -04:00
Chenlei Hu
1c3d3b33f6 1.3.17 (#1211) 2024-10-10 16:52:01 -04:00
Chenlei Hu
f64365915b Mark app.showMissingNodeDialog private (#1210) 2024-10-10 16:35:29 -04:00
Chenlei Hu
ec8e6f79b3 Fix create group node command error states (#1209)
* Fix edge cases

* Add playwright test

* nit
2024-10-10 15:56:00 -04:00
Chenlei Hu
b89f467983 Add group node commands/keybindings (#1208)
* Add group node commands/keybindings

* Fix jest tests
2024-10-10 12:50:05 -04:00
Chenlei Hu
009dbcf8c7 Wrap pragmatic dnd API with hooks (#1207) 2024-10-10 10:53:49 -04:00
Chenlei Hu
4413fd248c Remove question mark badge on folders in model library tree (#1205) 2024-10-10 10:13:14 -04:00
Chenlei Hu
8962597e69 Update litegraph (TypeScript LGraph) (#1206) 2024-10-10 10:13:02 -04:00
Chenlei Hu
f4d4111fbd 1.3.16 (#1202) 2024-10-09 22:17:57 -04:00
Chenlei Hu
babac5a4a9 1.3.15 (#1201) 2024-10-09 22:15:06 -04:00
Chenlei Hu
f71595fcc9 Fix node def handling of undefined fields (#1199)
* Fix node def handling

* nit

* Add test
2024-10-09 22:11:27 -04:00
Chenlei Hu
59a5f5f5d0 Add help menu on command menu bar (#1197)
* Add help menu on command menu bar

* nit
2024-10-09 20:35:17 -04:00
Chenlei Hu
2d5faa7f3d Anchor floating actionbar to closest side when resizing window (#1195)
* Anchor floating actionbar to closest side when resizing window

* nit

* nit
2024-10-09 17:41:36 -04:00
Chenlei Hu
32fa950aa1 1.3.14 (#1194) 2024-10-09 16:41:38 -04:00
Chenlei Hu
82112c2c6e Move low priority init to idle task (#1192) 2024-10-09 16:22:58 -04:00
Chenlei Hu
f94bdc358b Disable node def validation by default (#1190)
* Add setting

* Make node def validation optional
2024-10-09 16:02:27 -04:00
Chenlei Hu
f6466d7062 Avoid calling settingStore.set when there is no legacy node bookmark (#1191)
* Avoid calling settingStore.set when there is no legacy node bookmark

* nit
2024-10-09 16:02:14 -04:00
Chenlei Hu
1c5fd2465e Move vitejs/plugin-vue to devDep (#1189) 2024-10-09 15:16:43 -04:00
Chenlei Hu
e99329cff5 Remove class-transformer dependency (#1187)
* nit

* Fix test

* Remove class-transformer and its deps

* nit

* Fix invalid type for dummy node
2024-10-09 15:10:19 -04:00
bymyself
fabcbaec82 Restore backend state when Playwright test finishes (#1168)
* Restore backend state when Playwright test finishes. Resolve #1094

* Add warning when env var not set

* Rename and replace with scaffolding option for models dir

* Rename

* Define another env var [skip ci]

* Fix paths [skip ci]

* Update README.md

---------

Co-authored-by: Chenlei Hu <huchenlei@proton.me>
2024-10-09 15:05:19 -04:00
Chenlei Hu
c8f50509ed Fix VHS advanced preview html video type (#1186)
* Fix VHS advanced preview html video type

* nit
2024-10-09 15:02:02 -04:00
Chenlei Hu
165604bb80 Support VHS advanced preview in queue sidebar tab (#1183)
* Map VHS video type

* Advance preview format

* nit

* View VHS advanced preview

* Disable result gallery vitest

* Proper disable
2024-10-09 12:42:00 -04:00
Chenlei Hu
829bce1c8c Load Keybinding and Extension panel async (#1179) 2024-10-08 22:20:30 -04:00
Chenlei Hu
c3b82165fa 1.3.13 (#1178) 2024-10-08 20:14:56 -04:00
Chenlei Hu
d673a521d8 Add always snap to grid setting (#1177)
* Always snap to grid

* Ban pysssss.SnapToGrid

* nit
2024-10-08 20:12:23 -04:00
Chenlei Hu
ee88a79bc3 1.3.12 (#1175) 2024-10-08 17:12:53 -04:00
Chenlei Hu
5f3afa3776 Supports VHS video outputs in queue sidebar tab (#1174)
* Properly identify gifs

* Detect VHS video

* Basic video support in queue

* Video in lightbox

* Preview button

* nit

* Fix vitest
2024-10-08 17:10:44 -04:00
Chenlei Hu
3cafc10c2b Fix pan mode icon display (#1173) 2024-10-08 14:24:39 -04:00
Chenlei Hu
2cb1cea196 Make LGraphCanvas shallowReactive (#1169)
* Make LGraphCanvas shallowReactive

* Restore canvas options after creation
2024-10-08 14:07:15 -04:00
Chenlei Hu
482da21ba7 Remove state check on continuous keydown (#1171)
* Remove state check on continuous keydown

* nit
2024-10-08 11:01:08 -04:00
bymyself
bf80340310 Add test on settings visibility on mobile (#1164)
* Add test on settings dialog visibility on mobile

* Consolidate settings-dialog tests

* Simplify zoom speed setting tests
2024-10-08 09:43:16 -04:00
bymyself
5ef15c0daf Add aria labels to dialogs (#1167) 2024-10-08 09:41:28 -04:00
bymyself
62be958d47 Add tests on node multi-select (#1163) 2024-10-08 09:11:22 -04:00
Chenlei Hu
1ba236bbce Simplify node tooltip lifecycle (#1162) 2024-10-07 22:13:52 -04:00
Chenlei Hu
a4e08f60fe Extract theme toggle as command (#1161)
* Extract theme toggle as command

* nit
2024-10-07 21:54:58 -04:00
Chenlei Hu
5ba1d1a3f7 Show sidebar toggle keybinding shortcut on sidebar icon tooltip (#1160) 2024-10-07 21:44:38 -04:00
Chenlei Hu
58dd15a662 Fix core sidebar tab toggle command register (#1159) 2024-10-07 21:32:30 -04:00
Chenlei Hu
50a6ee27a0 Refactor core sidebar tab registration (#1158)
* Refactor sidebar tab register

* Register core tabs
2024-10-07 21:23:52 -04:00
Chenlei Hu
23952d9751 Show queue front icon when shift is pressed (#1157)
* Move shiftDown state to workspaceStateStore

* Queue front state
2024-10-07 19:54:00 -04:00
Chenlei Hu
2b26514190 1.3.11 (#1154) 2024-10-07 17:27:25 -04:00
Chenlei Hu
f8343d0f93 Fix flaky playwright test (#1152) 2024-10-07 17:11:20 -04:00
Chenlei Hu
cc17bee945 Manage app.ts litegraph keybindings (#1151)
* Manage app.ts litegraph keybindings

* nit
2024-10-07 16:50:58 -04:00
Alex "mcmonkey" Goodwin
ff1ca268a4 Model Library sidebar: allow searching metadata (#1148)
* Model Library sidebar: allow searching metadata

title, description, etc

* don't use vue stuff inside of vue because vue doesn't support vue

very cool

* remove old import

* and that one
2024-10-07 14:50:45 -04:00
Chenlei Hu
99c948f578 Update README (#1149) 2024-10-07 14:46:43 -04:00
Alex "mcmonkey" Goodwin
d68a1116dc ModelToNodeStore minor fix (#1147) 2024-10-07 13:13:15 -04:00
Chenlei Hu
dee1ec1a2a Update Litegraph (TypeScript conversion) (#1145)
* Fix various type errors

* Fix rest of ts errors

* update litegraph

* nit
2024-10-07 11:31:54 -04:00
bymyself
9cbfc9856b Fix widget/input conversion on text widgets (#1129)
* Support converting dynamically created text widgets to input

* Fix array contains

* Add test wait

* Try to fix test only failing in CI

* Fix test: Disable conversion option nesting in contextmenu
2024-10-07 11:22:28 -04:00
bymyself
a95a6f9b47 Fix saved workflow cleanup in menu tests (#1142)
* Fix saved workflow cleanup in menu tests

* Clear workflow dir before each test
2024-10-07 09:35:15 -04:00
Chenlei Hu
c83ce863d7 Rework command menu extension API (#1144)
* Rework command menu API

* Update test

* Update README

* Prevent register other extension's command
2024-10-06 23:31:57 -04:00
Chenlei Hu
05aa78372b Add keybinding search (#1143) 2024-10-06 22:40:20 -04:00
Chenlei Hu
38e3dcbaeb Add frontend extension management panel (#1141)
* Manage register of extension in pinia

* Add disabled extensions setting

* nit

* Disable extension

* Add virtual divider

* Basic extension panel

* Style cell

* nit

* Fix loading

* inactive rules

* nit

* Calculate changes

* nit

* Experimental setting guard
2024-10-06 22:15:33 -04:00
Chenlei Hu
cfa763962e Update playwright test fixture (#1139)
* Update playwright test fixture

* fix resolve

* nit

* Wait dialog close
2024-10-06 21:21:55 -04:00
Chenlei Hu
8c156cc651 Replace window.dialog with prompt dialog in workflows.ts (#1138) 2024-10-06 19:31:46 -04:00
Chenlei Hu
c7aabecc0e 1.3.10 (#1137) 2024-10-06 17:25:09 -04:00
Chenlei Hu
defacf3398 Remove unused code (#1136) 2024-10-06 16:22:48 -04:00
Chenlei Hu
7f2920644e Revert "Remove model library searchbox (#1133)" (#1135)
This reverts commit 1b3cc4de1a.
2024-10-06 16:02:14 -04:00
Chenlei Hu
c92ff79231 Add workflow tab tooltip to show full path (#1134) 2024-10-06 12:10:54 -04:00
bymyself
3c70c1e463 Show keybinding on topbar dropdown menus (#1127)
* Show keybinding on topbar dropdown menus, resolve #1092

* Add text-muted to tailwind config

* Add Playwright test

* Preserve Primevue classes in menu item template

* Extend MenuItem

* Revert adding undo/redo to core keybindings

* Change test selector

* refactor

* Extract as component

* refactor

* nit

* fix extension API

---------

Co-authored-by: huchenlei <huchenlei@proton.me>
2024-10-06 12:08:16 -04:00
Chenlei Hu
1b3cc4de1a Remove model library searchbox (#1133) 2024-10-06 11:44:38 -04:00
Chenlei Hu
b97331cbab Restyle SettingGroup (#1125)
* Setting group CSS

* nit

* nit

* Dim label color

* nit

* Set width
2024-10-05 20:34:15 -04:00
Chenlei Hu
b7287dbb22 1.3.9 (#1124) 2024-10-05 18:09:51 -04:00
Chenlei Hu
2c90735bb1 Restore top/bottom menu location setting (#1123)
* Rename floating to top

* Adjust teleport target

* Fix dropdown direction for bottom menubar

* Fix z-index
2024-10-05 18:08:48 -04:00
Chenlei Hu
4d5fbeff45 Add eslint-plugin-unused-imports (#1121) 2024-10-05 16:36:02 -04:00
Chenlei Hu
ad55722662 Docking action bar on top menu bar (#1119)
* Teleport when docked

* Docking logic

* Remove unnecessary v-show

* Docked panel style

* Drop zone highlight

* Rename test

* Add playwright test
2024-10-05 14:06:29 -04:00
Chenlei Hu
9c118c8e37 Add '- ComfyUI' suffix on browser title (#1118)
* Add '- ComfyUI' suffix on browser title

* Update test expectations
2024-10-05 11:35:17 -04:00
Chenlei Hu
267660a661 Extract QueueButton as component (#1117) 2024-10-05 10:07:50 -04:00
filtered
f2017291d6 Prevent converted widget being duplicated (#1115) 2024-10-05 08:49:17 -04:00
Chenlei Hu
4cc69544b5 Replace window.alert with toast alert (#1112)
* Replace window.alert with toast alert

* Mock jest
2024-10-04 22:00:44 -04:00
Chenlei Hu
2649d72d3f Refactor sidebarTabStore (#1111) 2024-10-04 21:20:35 -04:00
Chenlei Hu
a852b8e6e1 [skip ci] Add ersionAdded to newly added commands (#1110) 2024-10-04 20:41:05 -04:00
Chenlei Hu
6deb994235 Add command to switch opened workflow tabs (#1109) 2024-10-04 20:33:16 -04:00
Chenlei Hu
b30d285025 Add toggle command for each sidebar tab registered (#1108)
* Add toggle command for each sidebar tab registered

* nit
2024-10-04 20:22:10 -04:00
Chenlei Hu
18476d28dc 1.3.8 (#1107) 2024-10-04 16:32:46 -04:00
Chenlei Hu
57a4cb9036 Add default toast error handling for command execution (#1106)
* Error handling execute command

* Cleanup

* Add playwright test

* Mock i18n in jest test

* Reduce test func timeout
2024-10-04 16:28:08 -04:00
Chenlei Hu
ebc71b0e46 Add PromptDialog to replace window.prompt (#1104)
* Save file prompt dialog

* Don't download if dialog dismissed

* refactor

* style dialog

* nit

* Autofocus
2024-10-04 15:33:27 -04:00
Chenlei Hu
39d68bcdc4 Remove new default workflow button in workflows sidebar (#1100) 2024-10-04 11:37:37 -04:00
Chenlei Hu
e20126a254 Rename AppMenu to Actionbar (#1099)
* Rename AppMenu to Actionbar

* nit

* nit
2024-10-04 06:49:06 -04:00
Chenlei Hu
416fd0aed6 Restyle action bar (#1098) 2024-10-04 06:30:55 -04:00
Zoltán Dócs
661b8081c1 Show in-progress preview of the running task in the queue (#1091) 2024-10-03 18:48:06 -04:00
Chenlei Hu
64b5f4e7d5 1.3.7 (#1090) 2024-10-03 17:04:54 -04:00
Chenlei Hu
1775d43d90 Support keybinding customization (#1081)
* Basic keybinding panel

nit

Make row selectable

Reduce padding

Better key seq render

Show actions on demand

Turn off autocomplete

nit

Persist keybindings

Autofocus

Fix set unsetted keybinding bug

Refactor

Add reset button

Add back default keybinding logic

Report key conflict error

Adjust style

fix bug

Highlight modified keybindings

* Set current editing command's id as dialog header
2024-10-03 16:58:56 -04:00
Chenlei Hu
142882a8ff Move keybinds to coreKeybindings (#1078)
* Refactor core keybinds

* Prevent default

* Add playwright test
2024-10-03 11:25:53 -04:00
Robin Huang
65cad74eba Add model download URL and change to use safetensors. (#1076) 2024-10-03 09:08:26 -04:00
Chenlei Hu
77aaa38a92 [Extension API] Custom commands and keybindings (#1075)
* Add keybinding schema

* nit

* Keybinding store

* nit

* wip

* Bind condition on ComfyCommand

* Add settings

* nit

* Revamp keybinding store

* Add tests

* Add load keybinding

* load extension keybindings

* Load extension commands

* Handle keybindings

* test

* Keybinding playwright test

* Update README

* nit

* Remove log

* Remove system stats fromt logging.ts
2024-10-02 21:38:04 -04:00
Chenlei Hu
ea3d8cf728 1.3.6 (#1073) 2024-10-02 16:02:37 -04:00
Alex "mcmonkey" Goodwin
b3a624a572 Allow dragging model library outputs onto existing nodes (#1004)
* allow multiple compatible node registrations for model type

* allow dragging model library outputs onto existing nodes

* easier registration

* add alt loaders for checkpoint and lora
2024-10-02 15:53:19 -04:00
bymyself
a737be7e16 Fix group node copy paste (#1069)
* Fix group node copy paste

* nit

---------

Co-authored-by: huchenlei <huchenlei@proton.me>
2024-10-02 15:51:33 -04:00
Acly
aca2194892 Emit graph changed event after modifying a widget value via keyboard (#1072) 2024-10-02 15:28:42 -04:00
Chenlei Hu
3a2b2f9e15 Add toggle link visibility button on canvas menu (#1070)
* Basic link visibility toggle

* Icon change

* nit

* Update litegraph

* nit

* Add playwright test

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-10-02 15:23:37 -04:00
Chenlei Hu
a19f713c57 Revert "Hide Comfy.Workflow.WorkflowTabsPosition (#984)" (#1071)
This reverts commit a41f3b1ac6.
2024-10-02 15:14:23 -04:00
Chenlei Hu
8b2ef3c352 Use jpg for template workflow thumbnail (#1066) 2024-10-02 12:11:09 -04:00
pythongosssss
861bcabd66 Add support for multiple changes in a single ChangeTracker state (#1022)
* wip

* Add tests

* Update package

* remove logs

* nit

* nit

---------

Co-authored-by: huchenlei <huchenlei@proton.me>
2024-10-02 11:53:54 -04:00
Alex "mcmonkey" Goodwin
cc2b64df52 add filename in model preview popup (#1005)
* add filename in model preview popup

for #1003

* user setting for model name fomat in the tree

* add a tooltip for the setting to explain what things mean

* more explicit file_name naming

* touch of additional text in the tooltip
2024-10-02 10:59:01 -04:00
Alex "mcmonkey" Goodwin
a7a0035b0e allow custom bypass color (#993)
* allow custom bypass color

* shove the not-litegraph-color-def into litegraph

* prettier just does this to be funny i swear
2024-10-02 10:49:59 -04:00
bymyself
31b1aeeb69 Add test for selecting nodes on mac (#1055)
* Add test for selecting nodes on mac

* Deselect nodes in teardown

* Fix unstable test. Remove test on unimplemented feature
2024-10-02 10:42:57 -04:00
Chenlei Hu
3f10fd53bd 1.3.5 (#1061) 2024-10-01 16:39:36 -04:00
Chenlei Hu
0194d76722 Make max batch count configurable (#1060) 2024-10-01 15:42:12 -04:00
Chenlei Hu
98a0291bbd Remove update-main action (#1058) 2024-10-01 13:14:53 -04:00
Chenlei Hu
f9fc36f0ed Cleanup zip file on release (#1057) 2024-10-01 13:13:18 -04:00
pythongosssss
a2bd2a9bae Implement creating inputs by dragging link to widget (#1021)
* Implement creating inputs by dragging link to widget

* Update litegraph

* Add playwright test

* Update test expectations [skip ci]

---------

Co-authored-by: huchenlei <huchenlei@proton.me>
Co-authored-by: github-actions <github-actions@github.com>
2024-10-01 12:53:38 -04:00
Chenlei Hu
c42222cf0d Update litegraph (Pan when dragging link) (#1056)
* Update litegraph (Pan when dragging link)

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-10-01 10:54:28 -04:00
Chenlei Hu
271a4979b7 [Skip CI] Remove outdated roadmap (#1052) 2024-09-30 20:25:35 -04:00
Chenlei Hu
1c980397b8 1.3.4 (#1051) 2024-09-30 20:21:52 -04:00
Chenlei Hu
5d957a05b9 Replace locking/unlocking of canvas with select/pan mode (#1050)
* Add dragging canvas state

* Change icon

* Add playwright test

* tooltip change

* Enable for legacy menu

* Update litegraph

* Hide canvas menu for test
2024-09-30 20:20:49 -04:00
pythongosssss
f75f774ddb Graph canvas menu (#1023)
* add graph canvas menu

* Move to corner

* Remove action bar reset zoom button

* nit

* Add setting

---------

Co-authored-by: huchenlei <chenlei.hu@mail.utoronto.ca>
2024-09-30 16:06:43 -04:00
bymyself
224c0080ee Fix topbar submenu width on mobile/tablet (#1047)
* Fix topbar submenu width on tablet

* Add Playwright test

* Use better selector

* nit

---------

Co-authored-by: Chenlei Hu <chenlei.hu@mail.utoronto.ca>
2024-09-30 15:21:09 -04:00
bymyself
6ea5fea1a7 Fix menu drag on touch device (#1046) 2024-09-30 15:15:58 -04:00
bymyself
04e1344676 Fix closing saved workflows (#1049)
* Fix workflow save. Resolves #996 Resolves #1048

* Add test on closing tabs

* Add test on closing open workflows in sidebar
2024-09-30 15:09:24 -04:00
Chenlei Hu
0117964ca5 1.3.3 (#1036) 2024-09-28 11:22:19 +09:00
Chenlei Hu
9d110d39b2 Make action bar draggable (#1035)
* Basic draggable

* Nowrap

* Prevent double reset

* Persist position

* nit

* nit

* Window resize adjustment

* Fix playwright test
2024-09-28 11:21:38 +09:00
Chenlei Hu
8d7693e5ad 1.3.2 (#1024) 2024-09-27 15:31:48 +09:00
Yoland Yan
ec9a30d269 Minor: change app-menu bottom spacing (#1020) 2024-09-27 15:10:13 +09:00
Chenlei Hu
56fc2dd753 Update litegraph (Canvas readonly attr change event) (#1019) 2024-09-27 15:00:54 +09:00
Chenlei Hu
9050591ff9 Update litegraph (Drag cursor shape) (#1014) 2024-09-27 10:51:52 +09:00
Alex "mcmonkey" Goodwin
0cf21b190c don't remove invalid dropdown values (#998)
for #963
2024-09-27 09:03:55 +09:00
Alex "mcmonkey" Goodwin
2531ec178e minor css improvement with editable text (#990) 2024-09-27 08:26:24 +09:00
Alex "mcmonkey" Goodwin
81119acaf2 add a hotkey for settings (#991)
* add a hotkey for settings

for #942

* playwright test for settings menu hotkey

* make hotkey intercompatible with both old and new UI
2024-09-27 08:25:56 +09:00
Chenlei Hu
05f999903d 1.3.1 (#989) 2024-09-26 16:23:35 +09:00
Chenlei Hu
66c02d1e3a Revert "Mark Comfy.Workflow.ShowMissingModelsWarning as stable & enabled by d…" (#988)
This reverts commit cdaa0bda5b.
2024-09-26 16:19:09 +09:00
Chenlei Hu
a05df99a8a Update litegraph (Link copy) (#986)
* Update litegraph (Link copy)

* Update readme
2024-09-26 16:06:59 +09:00
Chenlei Hu
e200e2f89c Add model info for flux workflow template (#987) 2024-09-26 16:06:37 +09:00
Chenlei Hu
cdaa0bda5b Mark Comfy.Workflow.ShowMissingModelsWarning as stable & enabled by default (#985) 2024-09-26 15:10:17 +09:00
Chenlei Hu
a41f3b1ac6 Hide Comfy.Workflow.WorkflowTabsPosition (#984) 2024-09-26 15:10:03 +09:00
261 changed files with 9133 additions and 3935 deletions

View File

@@ -12,6 +12,10 @@ DEV_SERVER_COMFYUI_URL=http://127.0.0.1:8188
# to ComfyUI launch script to serve the custom web version.
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.
EXAMPLE_REPO_PATH=tests-ui/ComfyUI_examples

View File

@@ -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.'
validations:
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
id: browsers
attributes:

View File

@@ -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

View File

@@ -1 +1,5 @@
npx lint-staged
if [[ "$OS" == "Windows_NT" ]]; then
npx.cmd lint-staged
else
npx lint-staged
fi

View File

@@ -2,5 +2,6 @@
"singleQuote": true,
"tabWidth": 2,
"semi": false,
"trailingComma": "none"
"trailingComma": "none",
"printWidth": 80
}

226
README.md
View File

@@ -33,11 +33,11 @@
### 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:
```
```bat
--front-end-version Comfy-Org/ComfyUI_frontend@latest
```
@@ -62,6 +62,23 @@ There will be a 2-day feature freeze before each stable release. During this per
### Major features
<details>
<summary>v1.3.7: Keybinding customization</summary>
## Basic UI
![image](https://github.com/user-attachments/assets/c84a1609-3880-48e0-a746-011f36beda68)
## Reset button
![image](https://github.com/user-attachments/assets/4d2922da-bb4f-4f90-8017-a8e4a0db07c7)
## Edit Keybinding
![image](https://github.com/user-attachments/assets/77626b7a-cb46-48f8-9465-e03120aac66a)
![image](https://github.com/user-attachments/assets/79131a4e-75c6-4715-bd11-c6aaed887779)
[rec.webm](https://github.com/user-attachments/assets/a3984ed9-eb28-4d47-86c0-7fc3efc2b5d0)
</details>
<details>
<summary>v1.2.4: Node library sidebar tab</summary>
@@ -90,6 +107,46 @@ https://github.com/user-attachments/assets/4bbca3ee-318f-4cf0-be32-a5a5541066cf
### QoL changes
<details>
<summary>v1.3.6: **Litegraph** Toggle link visibility</summary>
[rec.webm](https://github.com/user-attachments/assets/34e460ac-fbbc-44ef-bfbb-99a84c2ae2be)
</details>
<details>
<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>
<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>
<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>
<summary>v1.2.62: **Litegraph** Show optional input slots as donuts</summary>
![GYEIRidb0AYGO-v](https://github.com/user-attachments/assets/e6cde0b6-654b-4afd-a117-133657a410b1)
</details>
<details>
<summary>v1.2.44: **Litegraph** Double click group title to edit</summary>
@@ -136,7 +193,105 @@ https://github.com/user-attachments/assets/c142c43f-2fe9-4030-8196-b3bfd4c6977d
https://github.com/user-attachments/assets/5696a89d-4a47-4fcc-9e8c-71e1264943f2
</details>
### Node developers API
### Developer APIs
<details>
<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>'
}
}
]
})
```
![image](https://github.com/user-attachments/assets/2114f8b8-2f55-414b-b027-78e61c870b64)
</details>
<details>
<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>
<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>
<summary>v1.3.1: Extension API to register custom topbar menu items</summary>
@@ -144,7 +299,24 @@ https://github.com/user-attachments/assets/c142c43f-2fe9-4030-8196-b3bfd4c6977d
Extensions can call the following API to register custom topbar menu items.
```js
app.extensionManager.menu.registerTopbarCommands(["ext", "ext2"], [{id:"foo", label: "foo", function: () => alert(1)}])
app.registerExtension({
name: 'TestExtension1',
commands: [
{
id: 'foo-id',
label: 'foo',
function: () => {
alert(1)
}
}
],
menuCommands: [
{
path: ['ext', 'ext2'],
commands: ['foo-id']
}
]
})
```
![image](https://github.com/user-attachments/assets/ae7b082f-7ce9-4549-a446-4563567102fe)
@@ -193,33 +365,16 @@ We will support custom icons later.
![image](https://github.com/user-attachments/assets/7bff028a-bf91-4cab-bf97-55c243b3f5e0)
</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
### 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
### Git pre-commit hooks
Run `npm run prepare` to install Git pre-commit hooks. Currently, the pre-commit
@@ -234,7 +389,7 @@ core extensions will be loaded.
- Start local ComfyUI backend at `localhost:8188`
- Run `npm run dev` to start the dev server
### Test
### 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
- `npm i` to install all dependencies
@@ -242,6 +397,16 @@ core extensions will be loaded.
- `npm run test:generate:examples` to extract the example workflows
- `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
This repo is using litegraph package hosted on <https://github.com/Comfy-Org/litegraph.js>. Any changes to litegraph should be submitted in that repo instead.
@@ -249,7 +414,6 @@ This repo is using litegraph package hosted on <https://github.com/Comfy-Org/lit
### Test litegraph changes
- Run `npm link` in the local litegraph repo.
- Run `npm uninstall @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.

View File

@@ -5,7 +5,7 @@ This document outlines the setup and usage of Playwright for testing the ComfyUI
## WARNING
The browser tests will change the ComfyUI backend state, such as user settings and saved workflows.
Please backup your ComfyUI data before running the tests locally.
If `TEST_COMFYUI_DIR` in `.env` isn't set to your `(Comfy Path)/ComfyUI` directory, these changes won't be automatically restored.
## Setup

View File

@@ -1,18 +1,14 @@
import type { Response } from '@playwright/test'
import type { StatusWsMessage } from '../src/types/apiTypes.ts'
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from './ComfyPage'
import { comfyPageFixture } from './fixtures/ComfyPage'
import { webSocketFixture } from './fixtures/ws.ts'
const test = mergeTests(comfyPageFixture, webSocketFixture)
test.describe('AppMenu', () => {
test.describe('Actionbar', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Floating')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
/**
@@ -23,12 +19,12 @@ test.describe('AppMenu', () => {
ws
}) => {
// Enable change auto-queue mode
const queueOpts = await comfyPage.appMenu.queueButton.toggleOptions()
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.appMenu.queueButton.toggleOptions()
await comfyPage.actionbar.queueButton.toggleOptions()
// Intercept the prompt queue endpoint
let promptNumber = 0
@@ -113,4 +109,15 @@ test.describe('AppMenu', () => {
).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)
})
})

View 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
}

View 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
}

View 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
}

View File

@@ -1,14 +1,10 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
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', 'Floating')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Can display workflow name', async ({ comfyPage }) => {
@@ -16,7 +12,7 @@ test.describe('Browser tab title', () => {
return window['app'].workflowManager.activeWorkflow.name
})
// Note: unsaved workflow name is always prepended with "*".
expect(await comfyPage.page.title()).toBe(`*${workflowName}`)
expect(await comfyPage.page.title()).toBe(`*${workflowName} - ComfyUI`)
})
// Broken by https://github.com/Comfy-Org/ComfyUI_frontend/pull/893
@@ -28,15 +24,15 @@ test.describe('Browser tab title', () => {
return window['app'].workflowManager.activeWorkflow.name
})
// Note: unsaved workflow name is always prepended with "*".
expect(await comfyPage.page.title()).toBe(`*${workflowName}`)
expect(await comfyPage.page.title()).toBe(`*${workflowName} - ComfyUI`)
await comfyPage.menu.saveWorkflow('test')
expect(await comfyPage.page.title()).toBe('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`)
expect(await comfyPage.page.title()).toBe(`*test - ComfyUI`)
// Delete the saved workflow for cleanup.
await comfyPage.page.evaluate(async () => {

View File

@@ -0,0 +1,100 @@
import {
ComfyPage,
comfyPageFixture as test,
comfyExpect as expect
} 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('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('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()
// Run again, but within a change transaction
beforeChange(comfyPage)
await node.click('collapse')
await comfyPage.ctrlB()
await expect(node).toBeCollapsed()
await expect(node).toBeBypassed()
// End transaction
afterChange(comfyPage)
// Ensure undo reverts both changes
await comfyPage.ctrlZ()
await expect(node).not.toBeBypassed()
await expect(node).not.toBeCollapsed()
})
test('Can group multiple transaction calls into a single one', 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 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()
})
})

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
const customColorPalettes = {
obsidian: {
@@ -135,12 +135,6 @@ test.describe('Color Palette', () => {
await comfyPage.setSetting('Comfy.CustomColorPalettes', customColorPalettes)
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.CustomColorPalettes', {})
await comfyPage.setSetting('Comfy.ColorPalette', 'dark')
await comfyPage.setSetting('Comfy.Node.Opacity', 1.0)
})
test('Can show custom color palette', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.ColorPalette', 'custom_obsidian_dark')
await comfyPage.nextFrame()
@@ -158,11 +152,6 @@ test.describe('Node Color Adjustments', () => {
await comfyPage.loadWorkflow('every_node_color')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Node.Opacity', 1.0)
await comfyPage.setSetting('Comfy.ColorPalette', 'dark')
})
test('should adjust opacity via node opacity setting', async ({
comfyPage
}) => {

View File

@@ -0,0 +1,49 @@
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()
})
})

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
test.describe('Copy Paste', () => {
test('Can copy and paste node', async ({ comfyPage }) => {
@@ -86,4 +86,23 @@ test.describe('Copy Paste', () => {
await comfyPage.page.keyboard.up('Alt')
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)
})
})

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
test.describe('Load workflow warning', () => {
test('Should display a warning when loading a workflow with missing nodes', async ({
@@ -63,35 +63,36 @@ test.describe('Missing models warning', () => {
const downloadButton = comfyPage.page.getByLabel('Download')
await expect(downloadButton).toBeVisible()
const downloadPromise = comfyPage.page.waitForEvent('download')
await downloadButton.click()
const downloadComplete = comfyPage.page.locator('.download-complete')
await expect(downloadComplete).toBeVisible()
})
test('Can configure download folder', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('missing_models')
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).toBeVisible()
const folderSelectToggle = comfyPage.page.locator(
'.model-path-select-checkbox'
)
const folderSelect = comfyPage.page.locator('.model-path-select')
await expect(folderSelectToggle).toBeVisible()
await expect(folderSelect).not.toBeVisible()
await folderSelectToggle.click() // show the selectors
await expect(folderSelect).toBeVisible()
await folderSelect.click() // open dropdown
await expect(folderSelect).toHaveClass(/p-select-open/)
await folderSelect.click() // close the dropdown
await expect(folderSelect).not.toHaveClass(/p-select-open/)
await folderSelectToggle.click() // hide the selectors
await expect(folderSelect).not.toBeVisible()
const download = await downloadPromise
expect(download.suggestedFilename()).toBe('fake_model.safetensors')
})
})
test.describe('Settings', () => {
test('@mobile Should be visible on mobile', async ({ comfyPage }) => {
await comfyPage.page.keyboard.press('Control+,')
const searchBox = comfyPage.page.locator('.settings-content')
await expect(searchBox).toBeVisible()
})
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)
test.step('Setting should persist', async () => {
expect(await comfyPage.getSetting('Comfy.Graph.ZoomSpeed')).toBe(maxSpeed)
})
})
})

View File

@@ -1,32 +1,101 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { expect, Locator } from '@playwright/test'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
test.describe('Topbar commands', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Floating')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Should allow registering topbar commands', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window['app'].extensionManager.menu.registerTopbarCommands(
['ext'],
[
window['app'].registerExtension({
name: 'TestExtension1',
commands: [
{
id: 'foo',
label: 'foo',
label: 'foo-command',
function: () => {
window['foo'] = true
}
}
],
menuCommands: [
{
path: ['ext'],
commands: ['foo']
}
]
)
})
})
await comfyPage.menu.topbar.triggerTopbarCommand(['ext', '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: Locator = await 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('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!'
}
]
})
})
expect(await comfyPage.getSetting('TestSetting')).toBe('Hello, world!')
await comfyPage.setSetting('TestSetting', 'Hello, universe!')
expect(await comfyPage.getSetting('TestSetting')).toBe('Hello, universe!')
})
})

View File

@@ -1,261 +1,22 @@
import type { Page, Locator, APIRequestContext } from '@playwright/test'
import { expect } from '@playwright/test'
import { test as base } from '@playwright/test'
import { ComfyAppMenu } from './helpers/appMenu'
import { ComfyActionbar } from '../helpers/actionbar'
import dotenv from 'dotenv'
dotenv.config()
import * as fs from 'fs'
import { NodeBadgeMode } from '../src/types/nodeSource'
import { NodeId } from '../src/types/comfyWorkflow'
import { ManageGroupNode } from './helpers/manageGroupNode'
import { ComfyTemplates } from './helpers/templates'
interface Position {
x: number
y: number
}
interface Size {
width: number
height: number
}
class ComfyNodeSearchFilterSelectionPanel {
constructor(public readonly page: Page) {}
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()
}
}
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()
}
}
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()
}
}
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))
}
}
class WorkflowsSidebarTab extends SidebarTab {
constructor(public readonly page: Page) {
super(page, 'workflows')
}
get browseGalleryButton() {
return this.page.locator('.browse-templates-button')
}
get newBlankWorkflowButton() {
return this.page.locator('.new-blank-workflow-button')
}
get browseWorkflowsButton() {
return this.page.locator('.browse-workflows-button')
}
get newDefaultWorkflowButton() {
return this.page.locator('.new-default-workflow-button')
}
async getOpenedWorkflowNames() {
return await this.page
.locator('.comfyui-workflows-open .node-label')
.allInnerTexts()
}
async getTopLevelSavedWorkflowNames() {
return await this.page
.locator('.comfyui-workflows-browse .node-label')
.allInnerTexts()
}
async switchToWorkflow(workflowName: string) {
const workflowLocator = this.page.locator(
'.comfyui-workflows-open .node-label',
{ hasText: workflowName }
)
await workflowLocator.click()
await this.page.waitForTimeout(300)
}
}
class Topbar {
constructor(public readonly page: Page) {}
async getTabNames(): Promise<string[]> {
return await this.page
.locator('.workflow-tabs .workflow-label')
.allInnerTexts()
}
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:has-text("${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}")`
)
await menuItem.waitFor({ state: 'visible' })
await menuItem.hover()
if (i === path.length - 1) {
await menuItem.click()
}
}
}
}
import { NodeBadgeMode } from '../../src/types/nodeSource'
import type { NodeId } from '../../src/types/comfyWorkflow'
import type { KeyCombo } from '../../src/types/keyBindingTypes'
import { ComfyTemplates } from '../helpers/templates'
import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox'
import {
NodeLibrarySidebarTab,
WorkflowsSidebarTab
} from './components/SidebarTab'
import { Topbar } from './components/Topbar'
import { NodeReference } from './utils/litegraphUtils'
import type { Position, Size } from './types'
class ComfyMenu {
public readonly sideToolbar: Locator
@@ -270,21 +31,6 @@ class ComfyMenu {
.nth(0)
}
async saveWorkflow(name: string) {
const acceptDialog = async (dialog) => {
await dialog.accept(name)
}
this.page.on('dialog', acceptDialog)
await this.saveButton.click()
// Wait a moment to ensure the dialog has been handled
await this.page.waitForTimeout(300)
// Remove the dialog listener
this.page.off('dialog', acceptDialog)
}
get nodeLibraryTab() {
return new NodeLibrarySidebarTab(this.page)
}
@@ -341,7 +87,7 @@ export class ComfyPage {
// Components
public readonly searchBox: ComfyNodeSearchBox
public readonly menu: ComfyMenu
public readonly appMenu: ComfyAppMenu
public readonly actionbar: ComfyActionbar
public readonly templates: ComfyTemplates
constructor(
@@ -356,7 +102,7 @@ export class ComfyPage {
this.workflowUploadInput = page.locator('#comfy-file-input')
this.searchBox = new ComfyNodeSearchBox(page)
this.menu = new ComfyMenu(page)
this.appMenu = new ComfyAppMenu(page)
this.actionbar = new ComfyActionbar(page)
this.templates = new ComfyTemplates(page)
}
@@ -381,6 +127,16 @@ export class ComfyPage {
})
}
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`,
@@ -399,7 +155,20 @@ export class ComfyPage {
}
}
async setup({ resetView = true } = {}) {
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()}`)
}
}
async setup() {
await this.goto()
await this.page.evaluate(() => {
localStorage.clear()
@@ -420,34 +189,81 @@ export class ComfyPage {
})
await this.page.waitForFunction(() => document.fonts.ready)
await this.page.waitForFunction(
() => window['app'] !== undefined && window['app'].vueAppReady
() =>
// window['app'] => GraphCanvas ready
// window['app'].extensionManager => GraphView ready
window['app'] && window['app'].extensionManager
)
await this.page.evaluate(() => {
window['app']['canvas'].show_info = false
})
await this.page.waitForSelector('.p-blockui-mask', { state: 'hidden' })
await this.nextFrame()
if (resetView) {
// 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 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'].ui.settings.setSettingValueAsync(id, value)
await window['app'].extensionManager.setting.set(id, value)
},
{ id: settingId, value: settingValue }
)
@@ -455,7 +271,7 @@ export class ComfyPage {
async getSetting(settingId: string) {
return await this.page.evaluate(async (id) => {
return await window['app'].ui.settings.getSettingValue(id)
return await window['app'].extensionManager.setting.get(id)
}, settingId)
}
@@ -494,6 +310,10 @@ export class ComfyPage {
await this.nextFrame()
}
async getVisibleToastCount() {
return await this.page.locator('.p-toast:visible').count()
}
async clickTextEncodeNode1() {
await this.canvas.click({
position: {
@@ -640,9 +460,10 @@ export class ComfyPage {
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')
const dialogInput = page.locator('.graphdialog input[type="text"]')
await dialogInput.click()
await dialogInput.fill('128')
await dialogInput.press('Enter')
await this.nextFrame()
}
@@ -726,46 +547,43 @@ export class ComfyPage {
await this.nextFrame()
}
async ctrlC() {
async ctrlSend(keyToPress: string) {
await this.page.keyboard.down('Control')
await this.page.keyboard.press('KeyC')
await this.page.keyboard.press(keyToPress)
await this.page.keyboard.up('Control')
await this.nextFrame()
}
async ctrlA() {
await this.ctrlSend('KeyA')
}
async ctrlB() {
await this.ctrlSend('KeyB')
}
async ctrlC() {
await this.ctrlSend('KeyC')
}
async ctrlV() {
await this.page.keyboard.down('Control')
await this.page.keyboard.press('KeyV')
await this.page.keyboard.up('Control')
await this.nextFrame()
await this.ctrlSend('KeyV')
}
async ctrlZ() {
await this.page.keyboard.down('Control')
await this.page.keyboard.press('KeyZ')
await this.page.keyboard.up('Control')
await this.nextFrame()
await this.ctrlSend('KeyZ')
}
async ctrlY() {
await this.page.keyboard.down('Control')
await this.page.keyboard.press('KeyY')
await this.page.keyboard.up('Control')
await this.nextFrame()
await this.ctrlSend('KeyY')
}
async ctrlArrowUp() {
await this.page.keyboard.down('Control')
await this.page.keyboard.press('ArrowUp')
await this.page.keyboard.up('Control')
await this.nextFrame()
await this.ctrlSend('ArrowUp')
}
async ctrlArrowDown() {
await this.page.keyboard.down('Control')
await this.page.keyboard.press('ArrowDown')
await this.page.keyboard.up('Control')
await this.nextFrame()
await this.ctrlSend('ArrowDown')
}
async closeMenu() {
@@ -879,13 +697,15 @@ export class ComfyPage {
return new NodeReference(id, this)
}
async getNodeRefsByType(type: string): Promise<NodeReference[]> {
return (
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))
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(() => {
@@ -894,166 +714,57 @@ export class ComfyPage {
if (!id) return null
return this.getNodeRefById(id)
}
}
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
)
}
}
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 getSize(): Promise<Size> {
const size = await this.getProperty<[number, number]>('size')
return {
width: size[0],
height: size[1]
}
}
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 click(position: 'title', options?: Parameters<Page['click']>[1]) {
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
default:
throw new Error(`Invalid click position ${position}`)
}
await this.comfyPage.canvas.click({
...options,
position: clickPos
})
await this.comfyPage.nextFrame()
}
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 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') {
this.comfyPage.page.once('dialog', async (dialog) => {
await dialog.accept(groupNodeName)
})
await this.clickContextMenuOption('Convert to Group Node')
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')
)
async moveMouseToEmptyArea() {
await this.page.mouse.move(10, 10)
}
}
export const comfyPageFixture = base.extend<{ comfyPage: ComfyPage }>({
comfyPage: async ({ page, request }, use) => {
const comfyPage = new ComfyPage(page, request)
await comfyPage.setupSettings({
// 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
})
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')
})

View File

@@ -0,0 +1,79 @@
import { Locator, Page } from '@playwright/test'
export class ComfyNodeSearchFilterSelectionPanel {
constructor(public readonly page: Page) {}
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()
}
}

View File

@@ -0,0 +1,120 @@
import { 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()
}
}
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 browseGalleryButton() {
return this.page.locator('.browse-templates-button')
}
get newBlankWorkflowButton() {
return this.page.locator('.new-blank-workflow-button')
}
get openWorkflowButton() {
return this.page.locator('.open-workflow-button')
}
async getOpenedWorkflowNames() {
return await this.page
.locator('.comfyui-workflows-open .node-label')
.allInnerTexts()
}
async getTopLevelSavedWorkflowNames() {
return await this.page
.locator('.comfyui-workflows-browse .node-label')
.allInnerTexts()
}
async switchToWorkflow(workflowName: string) {
const workflowLocator = this.page.locator(
'.comfyui-workflows-open .node-label',
{ hasText: workflowName }
)
await workflowLocator.click()
await this.page.waitForTimeout(300)
}
}

View File

@@ -0,0 +1,66 @@
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 openSubmenuMobile() {
await this.page.locator('.p-menubar-mobile .p-menubar-button').click()
}
async getMenuItem(itemLabel: string): Promise<Locator> {
return this.page.locator(`.p-menubar-item-label:text-is("${itemLabel}")`)
}
async getWorkflowTab(tabName: string): Promise<Locator> {
return this.page
.locator(`.workflow-tabs .workflow-label:has-text("${tabName}")`)
.locator('..')
}
async closeWorkflowTab(tabName: string) {
const tab = await this.getWorkflowTab(tabName)
await tab.locator('.close-button').click({ force: true })
}
async saveWorkflow(workflowName: string) {
await this.triggerTopbarCommand(['Workflow', 'Save'])
await this.page.locator('.p-dialog-content input').fill(workflowName)
await this.page.keyboard.press('Enter')
// Wait for the dialog to close.
await this.page.waitForTimeout(300)
}
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()
}
}
}
}

View File

@@ -0,0 +1,9 @@
export interface Position {
x: number
y: number
}
export interface Size {
width: number
height: number
}

View File

@@ -0,0 +1,257 @@
import { ManageGroupNode } from '../../helpers/manageGroupNode'
import type { NodeId } from '../../../src/types/comfyWorkflow'
import type { Page } from '@playwright/test'
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') {
this.comfyPage.page.once('dialog', async (dialog) => {
await dialog.accept(groupNodeName)
})
await this.clickContextMenuOption('Convert to Group Node')
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')
)
}
}

View File

@@ -0,0 +1,20 @@
import { FullConfig } from '@playwright/test'
import { backupPath } from './utils/backupUtils'
import dotenv from 'dotenv'
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'
)
}
}
}

View File

@@ -0,0 +1,12 @@
import { FullConfig } from '@playwright/test'
import { restorePath } from './utils/backupUtils'
import dotenv from 'dotenv'
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'])
}
}

View File

@@ -0,0 +1,38 @@
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
)
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -1,11 +1,8 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { ComfyPage, comfyPageFixture as test } from './fixtures/ComfyPage'
import type { NodeReference } from './fixtures/utils/litegraphUtils'
test.describe('Group Node', () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Node library sidebar', () => {
const groupNodeName = 'DefautWorkflowGroupNode'
const groupNodeCategory = 'group nodes>workflow'
@@ -13,17 +10,12 @@ test.describe('Group Node', () => {
let libraryTab
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Floating')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
libraryTab = comfyPage.menu.nodeLibraryTab
await comfyPage.convertAllNodesToGroupNode(groupNodeName)
await libraryTab.open()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [])
await libraryTab.close()
})
test('Is added to node library sidebar', async ({ comfyPage }) => {
expect(await libraryTab.getFolder('group nodes').count()).toBe(1)
})
@@ -98,6 +90,7 @@ test.describe('Group Node', () => {
})
test('Displays tooltip on title hover', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.EnableTooltips', true)
await comfyPage.convertAllNodesToGroupNode('Group Node')
await comfyPage.page.mouse.move(47, 173)
const tooltipTimeout = 500
@@ -148,4 +141,117 @@ test.describe('Group Node', () => {
expect(await comfyPage.getGraphNodesCount()).toBe(1)
expect(comfyPage.page.locator('.comfy-missing-nodes')).not.toBeVisible()
})
test.describe('Copy and paste', () => {
let groupNode: NodeReference | null
const WORKFLOW_NAME = 'group_node_v1.3.3'
const GROUP_NODE_CATEGORY = 'group nodes>workflow'
const GROUP_NODE_PREFIX = 'workflow>'
const GROUP_NODE_NAME = 'group_node' // Node name in given workflow
const GROUP_NODE_TYPE = `${GROUP_NODE_PREFIX}${GROUP_NODE_NAME}`
const isRegisteredLitegraph = async (comfyPage: ComfyPage) => {
return await comfyPage.page.evaluate((nodeType: string) => {
return !!window['LiteGraph'].registered_node_types[nodeType]
}, GROUP_NODE_TYPE)
}
const isRegisteredNodeDefStore = async (comfyPage: ComfyPage) => {
const groupNodesFolderCt = await comfyPage.menu.nodeLibraryTab
.getFolder(GROUP_NODE_CATEGORY)
.count()
return groupNodesFolderCt === 1
}
const verifyNodeLoaded = async (
comfyPage: ComfyPage,
expectedCount: number
) => {
expect(await comfyPage.getNodeRefsByType(GROUP_NODE_TYPE)).toHaveLength(
expectedCount
)
expect(await isRegisteredLitegraph(comfyPage)).toBe(true)
expect(await isRegisteredNodeDefStore(comfyPage)).toBe(true)
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.loadWorkflow(WORKFLOW_NAME)
await comfyPage.menu.nodeLibraryTab.open()
groupNode = await comfyPage.getFirstNodeRef()
if (!groupNode)
throw new Error(`Group node not found in workflow ${WORKFLOW_NAME}`)
await groupNode.copy()
})
test('Copies and pastes group node within the same workflow', async ({
comfyPage
}) => {
await comfyPage.ctrlV()
await verifyNodeLoaded(comfyPage, 2)
})
test('Copies and pastes group node after clearing workflow', async ({
comfyPage
}) => {
await comfyPage.menu.topbar.triggerTopbarCommand([
'Edit',
'Clear Workflow'
])
await comfyPage.ctrlV()
await verifyNodeLoaded(comfyPage, 1)
})
test('Copies and pastes group node into a newly created blank workflow', async ({
comfyPage
}) => {
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
await comfyPage.ctrlV()
await verifyNodeLoaded(comfyPage, 1)
})
test('Copies and pastes group node across different workflows', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('default')
await comfyPage.ctrlV()
await verifyNodeLoaded(comfyPage, 1)
})
test('Serializes group node after copy and paste across workflows', async ({
comfyPage
}) => {
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
await comfyPage.ctrlV()
const currentGraphState = await comfyPage.page.evaluate(() =>
window['app'].graph.serialize()
)
await test.step('Load workflow containing a group node pasted from a different workflow', async () => {
await comfyPage.page.evaluate(
(workflow) => window['app'].loadGraphData(workflow),
currentGraphState
)
await comfyPage.nextFrame()
await verifyNodeLoaded(comfyPage, 1)
})
})
})
test.describe('Keybindings', () => {
test('Convert to group node, no selection', async ({ comfyPage }) => {
expect(await comfyPage.getVisibleToastCount()).toBe(0)
await comfyPage.page.keyboard.press('Alt+g')
await comfyPage.page.waitForTimeout(300)
expect(await comfyPage.getVisibleToastCount()).toBe(1)
})
test('Convert to group node, selected 1 node', async ({ comfyPage }) => {
expect(await comfyPage.getVisibleToastCount()).toBe(0)
await comfyPage.clickTextEncodeNode1()
await comfyPage.page.keyboard.press('Alt+g')
await comfyPage.page.waitForTimeout(300)
expect(await comfyPage.getVisibleToastCount()).toBe(1)
})
})
})

View File

@@ -1,29 +1,34 @@
import type { Page, Locator } from '@playwright/test'
import type { AutoQueueMode } from '../../src/stores/queueStore'
export class ComfyAppMenu {
export class ComfyActionbar {
public readonly root: Locator
public readonly queueButton: ComfyQueueButton
constructor(public readonly page: Page) {
this.root = page.locator('.app-menu')
this.root = page.locator('.actionbar')
this.queueButton = new ComfyQueueButton(this)
}
async isDocked() {
const className = await this.root.getAttribute('class')
return className?.includes('is-docked') ?? false
}
}
class ComfyQueueButton {
public readonly root: Locator
public readonly primaryButton: Locator
public readonly dropdownButton: Locator
constructor(public readonly appMenu: ComfyAppMenu) {
this.root = appMenu.root.getByTestId('queue-button')
constructor(public readonly actionbar: ComfyActionbar) {
this.root = actionbar.root.getByTestId('queue-button')
this.primaryButton = this.root.locator('.p-splitbutton-button')
this.dropdownButton = this.root.locator('.p-splitbutton-dropdown')
}
public async toggleOptions() {
await this.dropdownButton.click()
return new ComfyQueueButtonOptions(this.appMenu.page)
return new ComfyQueueButtonOptions(this.actionbar.page)
}
}

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
test.describe('Node Interaction', () => {
test('Can enter prompt', async ({ comfyPage }) => {
@@ -11,12 +11,51 @@ test.describe('Node Interaction', () => {
await expect(textBox).toHaveValue('Hello World 2')
})
test('Can highlight selected', async ({ comfyPage }) => {
await expect(comfyPage.canvas).toHaveScreenshot('default.png')
await comfyPage.clickTextEncodeNode1()
await expect(comfyPage.canvas).toHaveScreenshot('selected-node1.png')
await comfyPage.clickTextEncodeNode2()
await expect(comfyPage.canvas).toHaveScreenshot('selected-node2.png')
test.describe('Node Selection', () => {
const multiSelectModifiers = ['Control', 'Shift', 'Meta']
multiSelectModifiers.forEach((modifier) => {
test(`Can add multiple nodes to selection using ${modifier}+Click`, async ({
comfyPage
}) => {
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
for (const node of clipNodes) {
await node.click('title', { modifiers: [modifier] })
}
const selectedNodeCount = await comfyPage.getSelectedGraphNodesCount()
expect(selectedNodeCount).toBe(clipNodes.length)
})
})
test('Can highlight selected', async ({ comfyPage }) => {
await expect(comfyPage.canvas).toHaveScreenshot('default.png')
await comfyPage.clickTextEncodeNode1()
await expect(comfyPage.canvas).toHaveScreenshot('selected-node1.png')
await comfyPage.clickTextEncodeNode2()
await expect(comfyPage.canvas).toHaveScreenshot('selected-node2.png')
})
test('Can drag-select nodes with Meta (mac)', async ({ comfyPage }) => {
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
const clipNode1Pos = await clipNodes[0].getPosition()
const clipNode2Pos = await clipNodes[1].getPosition()
const offset = 64
await comfyPage.page.keyboard.down('Meta')
await comfyPage.dragAndDrop(
{
x: Math.min(clipNode1Pos.x, clipNode2Pos.x) - offset,
y: Math.min(clipNode1Pos.y, clipNode2Pos.y) - offset
},
{
x: Math.max(clipNode1Pos.x, clipNode2Pos.x) + offset,
y: Math.max(clipNode1Pos.y, clipNode2Pos.y) + offset
}
)
await comfyPage.page.keyboard.up('Meta')
expect(await comfyPage.getSelectedGraphNodesCount()).toBe(
clipNodes.length
)
})
})
test('Can drag node', async ({ comfyPage }) => {
@@ -34,6 +73,8 @@ test.describe('Node Interaction', () => {
await comfyPage.disconnectEdge()
await expect(comfyPage.canvas).toHaveScreenshot('disconnected-edge.png')
await comfyPage.connectEdge()
// Move mouse to empty area to avoid slot highlight.
await comfyPage.moveMouseToEmptyArea()
// Litegraph renders edge with a slight offset.
await expect(comfyPage.canvas).toHaveScreenshot('default.png', {
maxDiffPixels: 50
@@ -75,6 +116,24 @@ test.describe('Node Interaction', () => {
await comfyPage.page.keyboard.up('Shift')
await expect(comfyPage.canvas).toHaveScreenshot('copied-link.png')
})
test('Auto snap&highlight when dragging link over node', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.Node.AutoSnapLinkToSlot', true)
await comfyPage.setSetting('Comfy.Node.SnapHighlightsNode', true)
await comfyPage.page.mouse.move(
comfyPage.clipTextEncodeNode1InputSlot.x,
comfyPage.clipTextEncodeNode1InputSlot.y
)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(
comfyPage.clipTextEncodeNode2InputSlot.x,
comfyPage.clipTextEncodeNode2InputSlot.y
)
await expect(comfyPage.canvas).toHaveScreenshot('snapped-highlighted.png')
})
})
test('Can adjust widget value', async ({ comfyPage }) => {
@@ -347,6 +406,52 @@ test.describe('Canvas Interaction', () => {
await expect(comfyPage.canvas).toHaveScreenshot('panned.png')
})
test('Cursor style changes when panning', async ({ comfyPage }) => {
const getCursorStyle = async () => {
return await comfyPage.page.evaluate(() => {
return (
document.getElementById('graph-canvas')!.style.cursor || 'default'
)
})
}
await comfyPage.page.mouse.move(10, 10)
expect(await getCursorStyle()).toBe('default')
await comfyPage.page.mouse.down()
expect(await getCursorStyle()).toBe('grabbing')
await comfyPage.page.mouse.up()
expect(await getCursorStyle()).toBe('default')
await comfyPage.page.keyboard.down('Space')
expect(await getCursorStyle()).toBe('grab')
await comfyPage.page.mouse.down()
expect(await getCursorStyle()).toBe('grabbing')
await comfyPage.page.mouse.up()
expect(await getCursorStyle()).toBe('grab')
await comfyPage.page.keyboard.up('Space')
expect(await getCursorStyle()).toBe('default')
})
test('Can pan when dragging a link', async ({ comfyPage }) => {
const posSlot1 = comfyPage.clipTextEncodeNode1InputSlot
await comfyPage.page.mouse.move(posSlot1.x, posSlot1.y)
await comfyPage.page.mouse.down()
const posEmpty = comfyPage.emptySpace
await comfyPage.page.mouse.move(posEmpty.x, posEmpty.y)
await expect(comfyPage.canvas).toHaveScreenshot('dragging-link1.png')
await comfyPage.page.keyboard.down('Space')
await comfyPage.page.mouse.move(posEmpty.x + 100, posEmpty.y + 100)
// Canvas should be panned.
await expect(comfyPage.canvas).toHaveScreenshot(
'panning-when-dragging-link.png'
)
await comfyPage.page.keyboard.up('Space')
await comfyPage.page.mouse.move(posEmpty.x, posEmpty.y)
// Should be back to dragging link mode when space is released.
await expect(comfyPage.canvas).toHaveScreenshot('dragging-link2.png')
await comfyPage.page.mouse.up()
})
test('Can pan very far and back', async ({ comfyPage }) => {
// intentionally slice the edge of where the clip text encode dom widgets are
await comfyPage.pan({ x: -800, y: -300 }, { x: 1000, y: 10 })
@@ -405,11 +510,7 @@ test.describe('Load workflow', () => {
test.describe('Load duplicate workflow', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Floating')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('A workflow can be loaded multiple times in a row', async ({

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -0,0 +1,37 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
test.describe('Keybindings', () => {
test('Should not trigger non-modifier keybinding when typing in input fields', async ({
comfyPage
}) => {
await comfyPage.registerKeybinding({ key: 'k' }, () => {
window['TestCommand'] = true
})
const textBox = comfyPage.widgetTextBox
await textBox.click()
await textBox.fill('k')
await expect(textBox).toHaveValue('k')
expect(await comfyPage.page.evaluate(() => window['TestCommand'])).toBe(
undefined
)
})
test('Should not trigger modifier keybinding when typing in input fields', async ({
comfyPage
}) => {
await comfyPage.registerKeybinding({ key: 'k', ctrl: true }, () => {
window['TestCommand'] = true
})
const textBox = comfyPage.widgetTextBox
await textBox.click()
await textBox.fill('q')
await textBox.press('Control+k')
await expect(textBox).toHaveValue('q')
expect(await comfyPage.page.evaluate(() => window['TestCommand'])).toBe(
true
)
})
})

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
function listenForEvent(): Promise<Event> {
return new Promise<Event>((resolve) => {

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
test.describe('Load Workflow in Media', () => {
;[

View File

@@ -1,17 +1,9 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
test.describe('Menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Floating')
})
test.afterEach(async ({ comfyPage }) => {
const currentThemeId = await comfyPage.menu.getThemeId()
if (currentThemeId !== 'dark') {
await comfyPage.menu.toggleTheme()
}
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
// Skip reason: Flaky.
@@ -25,8 +17,7 @@ test.describe('Menu', () => {
expect(await comfyPage.menu.getThemeId()).toBe('light')
// Theme id should persist after reload.
await comfyPage.page.reload()
await comfyPage.setup()
await comfyPage.reload()
expect(await comfyPage.menu.getThemeId()).toBe('light')
await comfyPage.menu.toggleTheme()
@@ -368,8 +359,7 @@ test.describe('Menu', () => {
'KSampler'
])
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [])
await comfyPage.page.reload()
await comfyPage.setup()
await comfyPage.reload()
expect(await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks')).toEqual(
[]
)
@@ -389,6 +379,8 @@ test.describe('Menu', () => {
// Open the sidebar
const tab = comfyPage.menu.workflowsTab
await tab.open()
await comfyPage.setupWorkflowsDirectory({})
})
test('Can create new blank workflow', async ({ comfyPage }) => {
@@ -410,14 +402,13 @@ test.describe('Menu', () => {
'workflow2.json': 'default.json'
})
// Avoid reset view as the button is not visible in BetaMenu UI.
await comfyPage.setup({ resetView: false })
await comfyPage.setup()
const tab = comfyPage.menu.workflowsTab
await tab.open()
expect(await tab.getTopLevelSavedWorkflowNames()).toEqual([
'workflow1.json',
'workflow2.json'
])
expect(await tab.getTopLevelSavedWorkflowNames()).toEqual(
expect.arrayContaining(['workflow1.json', 'workflow2.json'])
)
})
test('Does not report warning when switching between opened workflows', async ({
@@ -426,9 +417,9 @@ test.describe('Menu', () => {
await comfyPage.loadWorkflow('missing_nodes')
await comfyPage.closeDialog()
// Load default workflow
// Load blank workflow
await comfyPage.menu.workflowsTab.open()
await comfyPage.menu.workflowsTab.newDefaultWorkflowButton.click()
await comfyPage.menu.workflowsTab.newBlankWorkflowButton.click()
// Switch back to the missing_nodes workflow
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')
@@ -437,6 +428,21 @@ test.describe('Menu', () => {
comfyPage.page.locator('.comfy-missing-nodes')
).not.toBeVisible()
})
test('Can close saved-workflows from the open workflows section', async ({
comfyPage
}) => {
await comfyPage.menu.topbar.saveWorkflow(
`tempWorkflow-${test.info().title}`
)
const closeButton = comfyPage.page.locator(
'.comfyui-workflows-open .p-button-icon.pi-times'
)
await closeButton.click()
expect(
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
).toEqual(['*Unsaved Workflow (2).json'])
})
})
test.describe('Workflows topbar tabs', () => {
@@ -445,6 +451,7 @@ test.describe('Menu', () => {
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
await comfyPage.setupWorkflowsDirectory({})
})
test('Can show opened workflows', async ({ comfyPage }) => {
@@ -452,6 +459,41 @@ test.describe('Menu', () => {
'Unsaved Workflow'
])
})
test('Can close saved-workflow tabs', async ({ comfyPage }) => {
const workflowName = `tempWorkflow-${test.info().title}`
await comfyPage.menu.topbar.saveWorkflow(workflowName)
expect(await comfyPage.menu.topbar.getTabNames()).toEqual([workflowName])
await comfyPage.menu.topbar.closeWorkflowTab(workflowName)
expect(await comfyPage.menu.topbar.getTabNames()).toEqual([
'Unsaved Workflow (2)'
])
})
})
test.describe('Topbar submmenus', () => {
test('@mobile Items fully visible on mobile screen width', async ({
comfyPage
}) => {
await comfyPage.menu.topbar.openSubmenuMobile()
const topLevelMenuItem = comfyPage.page
.locator('a.p-menubar-item-link')
.first()
const isTextCutoff = await topLevelMenuItem.evaluate((el) => {
return el.scrollWidth > el.clientWidth
})
expect(isTextCutoff).toBe(false)
})
test('Displays keybinding next to item', async ({ comfyPage }) => {
const workflowMenuItem =
await comfyPage.menu.topbar.getMenuItem('Workflow')
await workflowMenuItem.click()
const exportTag = comfyPage.page.locator('.keybinding-tag', {
hasText: 'Ctrl + s'
})
expect(await exportTag.count()).toBe(1)
})
})
// Only test 'Top' to reduce test time.
@@ -461,7 +503,7 @@ test.describe('Menu', () => {
comfyPage
}) => {
await comfyPage.setSetting('Comfy.UseNewMenu', position)
expect(await comfyPage.getSetting('Comfy.UseNewMenu')).toBe('Floating')
expect(await comfyPage.getSetting('Comfy.UseNewMenu')).toBe('Top')
})
test(`Can migrate deprecated menu positions on initial load (${position})`, async ({
@@ -469,20 +511,7 @@ test.describe('Menu', () => {
}) => {
await comfyPage.setSetting('Comfy.UseNewMenu', position)
await comfyPage.setup()
expect(await comfyPage.getSetting('Comfy.UseNewMenu')).toBe('Floating')
expect(await comfyPage.getSetting('Comfy.UseNewMenu')).toBe('Top')
})
})
test('Can change canvas zoom speed setting', async ({ comfyPage }) => {
const [defaultSpeed, maxSpeed] = [1.1, 2.5]
expect(await comfyPage.getSetting('Comfy.Graph.ZoomSpeed')).toBe(
defaultSpeed
)
await comfyPage.setSetting('Comfy.Graph.ZoomSpeed', maxSpeed)
expect(await comfyPage.getSetting('Comfy.Graph.ZoomSpeed')).toBe(maxSpeed)
await comfyPage.page.reload()
await comfyPage.setup()
expect(await comfyPage.getSetting('Comfy.Graph.ZoomSpeed')).toBe(maxSpeed)
await comfyPage.setSetting('Comfy.Graph.ZoomSpeed', defaultSpeed)
})
})

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
import type { ComfyApp } from '../src/scripts/app'
import { NodeBadgeMode } from '../src/types/nodeSource'

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
// If an input is optional by node definition, it should be shown as
// a hollow circle no matter what shape it was defined in the workflow JSON.
@@ -32,4 +32,16 @@ test.describe('Optional input', () => {
// If the node's multiline text widget is visible, then it was loaded successfully
expect(comfyPage.page.locator('.comfy-multiline-input')).toHaveCount(1)
})
test('Old workflow with converted input', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('old_workflow_converted_input')
const node = await comfyPage.getNodeRefById('1')
const inputs = await node.getProperty('inputs')
const vaeInput = inputs.find((w) => w.name === 'vae')
const convertedInput = inputs.find((w) => w.name === 'strength')
expect(vaeInput).toBeDefined()
expect(convertedInput).toBeDefined()
expect(vaeInput.link).toBeNull()
expect(convertedInput.link).not.toBeNull()
})
})

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
test.describe('Node search box', () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -1,9 +1,23 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
import type { NodeReference } from './fixtures/utils/litegraphUtils'
test.describe('Primitive Node', () => {
test('Can load with correct size', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('primitive_node')
await expect(comfyPage.canvas).toHaveScreenshot('primitive_node.png')
})
// When link is dropped on widget, it should automatically convert the widget
// to input.
test('Can connect to widget', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('primitive_node_unconnected')
const primitiveNode: NodeReference = await comfyPage.getNodeRefById(1)
const ksamplerNode: NodeReference = await comfyPage.getNodeRefById(2)
// Connect the output of the primitive node to the input of first widget of the ksampler node
await primitiveNode.connectWidget(0, ksamplerNode, 0)
await expect(comfyPage.canvas).toHaveScreenshot(
'primitive_node_connected.png'
)
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View File

@@ -1,13 +1,9 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
test.describe('Properties Panel', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Floating')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
// TODO: Update expectation after new menu dropdown is added.

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
import { NodeBadgeMode } from '../src/types/nodeSource'
test.describe('Canvas Right Click Menu', () => {
@@ -93,16 +93,49 @@ test.describe('Node Right Click Menu', () => {
)
})
test('Can convert widget to input', async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.page.getByText('Convert Widget to Input').click()
await comfyPage.nextFrame()
await comfyPage.page.getByText('Convert width to input').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-node-widget-converted.png'
)
test.describe('Widget conversion', () => {
const convertibleWidgetTypes = ['text', 'string', 'number', 'toggle']
test('Can convert widget to input', async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.page.getByText('Convert Widget to Input').click()
await comfyPage.nextFrame()
await comfyPage.page.getByText('Convert width to input').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-node-widget-converted.png'
)
})
convertibleWidgetTypes.forEach((widgetType) => {
test(`Can convert ${widgetType} widget to input`, async ({
comfyPage
}) => {
const nodeType = 'KSampler'
// To avoid needing multiple clicks, disable nesting of conversion options
await comfyPage.setSetting('Comfy.NodeInputConversionSubmenus', false)
// Add the widget using the node's `addWidget` method
await comfyPage.page.evaluate(
([nodeType, widgetType]) => {
const node = window['app'].graph.nodes.find(
(n) => n.type === nodeType
)
node.addWidget(widgetType, widgetType, 'defaultValue', () => {}, {})
},
[nodeType, widgetType]
)
// Verify the context menu includes the conversion option
const node = (await comfyPage.getNodeRefsByType(nodeType))[0]
const menuOptions = await node.getContextMenuOptionNames()
expect(menuOptions.includes(`Convert ${widgetType} to input`)).toBe(
true
)
})
})
})
test('Can pin and unpin', async ({ comfyPage }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -1,13 +1,9 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
test.describe('Templates', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Floating')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Can load template workflows', async ({ comfyPage }) => {

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
test.describe('Combo text widget', () => {
test('Truncates text when resized', async ({ comfyPage }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 89 KiB

View File

@@ -0,0 +1,69 @@
import path from 'path'
import fs from 'fs-extra'
type PathParts = readonly [string, ...string[]]
const getBackupPath = (originalPath: string): string => `${originalPath}.bak`
const resolvePathIfExists = (pathParts: PathParts): string | null => {
const resolvedPath = path.resolve(...pathParts)
if (!fs.pathExistsSync(resolvedPath)) {
console.warn(`Path not found: ${resolvedPath}`)
return null
}
return resolvedPath
}
const createScaffoldingCopy = (srcDir: string, destDir: string) => {
// Get all items (files and directories) in the source directory
const items = fs.readdirSync(srcDir, { withFileTypes: true })
for (const item of items) {
const srcPath = path.join(srcDir, item.name)
const destPath = path.join(destDir, item.name)
if (item.isDirectory()) {
// Create the corresponding directory in the destination
fs.ensureDirSync(destPath)
// Recursively copy the directory structure
createScaffoldingCopy(srcPath, destPath)
}
}
}
export function backupPath(
pathParts: PathParts,
{ renameAndReplaceWithScaffolding = false } = {}
) {
const originalPath = resolvePathIfExists(pathParts)
if (!originalPath) return
const backupPath = getBackupPath(originalPath)
try {
if (renameAndReplaceWithScaffolding) {
// Rename the original path and create scaffolding in its place
fs.moveSync(originalPath, backupPath)
createScaffoldingCopy(backupPath, originalPath)
} else {
// Create a copy of the original path
fs.copySync(originalPath, backupPath)
}
} catch (error) {
console.error(`Failed to backup ${originalPath} from ${backupPath}`, error)
}
}
export function restorePath(pathParts: PathParts) {
const originalPath = resolvePathIfExists(pathParts)
if (!originalPath) return
const backupPath = getBackupPath(originalPath)
if (!fs.pathExistsSync(backupPath)) return
try {
fs.moveSync(backupPath, originalPath, { overwrite: true })
} catch (error) {
console.error(`Failed to restore ${originalPath} from ${backupPath}`, error)
}
}

View File

@@ -2,6 +2,7 @@ import globals from 'globals'
import pluginJs from '@eslint/js'
import tseslint from 'typescript-eslint'
import pluginVue from 'eslint-plugin-vue'
import unusedImports from 'eslint-plugin-unused-imports'
export default [
{
@@ -35,5 +36,16 @@ export default [
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/prefer-as-const': 'off'
}
},
{
plugins: {
'unused-imports': unusedImports
},
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/prefer-as-const': 'off',
'unused-imports/no-unused-imports': 'error'
}
}
]

View File

@@ -36,7 +36,6 @@
</form>
</main>
</div>
<script type="module" src="node_modules/reflect-metadata/Reflect.js"></script>
<script type="module" src="src/main.ts"></script>
</body>
</html>

932
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,14 @@
{
"name": "comfyui-frontend",
"private": true,
"version": "1.3.0",
"version": "1.3.29",
"type": "module",
"scripts": {
"dev": "vite",
"build": "npm run typecheck && vite build",
"deploy": "npm run build && node scripts/deploy.js",
"zipdist": "node scripts/zipdist.js",
"typecheck": "tsc --noEmit",
"typecheck": "tsc --noEmit && tsc-strict",
"format": "prettier --write './**/*.{js,ts,tsx,vue}'",
"test": "npm run build && jest",
"test:generate:examples": "npx tsx tests-ui/extractExamples",
@@ -30,13 +30,15 @@
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.6",
"@types/node": "^20.14.8",
"@vitejs/plugin-vue": "^5.1.4",
"@vue/test-utils": "^2.4.6",
"@vue/vue3-jest": "^29.2.6",
"autoprefixer": "^10.4.19",
"babel-plugin-transform-import-meta": "^2.2.1",
"babel-plugin-transform-rename-import": "^2.3.0",
"chalk": "^5.3.0",
"eslint": "^9.8.0",
"eslint": "^9.12.0",
"eslint-plugin-unused-imports": "^4.1.4",
"eslint-plugin-vue": "^9.27.0",
"fs-extra": "^11.2.0",
"globals": "^15.9.0",
@@ -51,9 +53,11 @@
"tailwindcss": "^3.4.4",
"ts-jest": "^29.1.4",
"ts-node": "^10.9.2",
"tsc-files": "^1.1.4",
"tsx": "^4.15.6",
"typescript": "^5.4.5",
"typescript-eslint": "^8.0.0",
"typescript-strict-plugin": "^2.4.4",
"unplugin-icons": "^0.19.3",
"unplugin-vue-components": "^0.27.4",
"vite": "^5.4.6",
@@ -62,20 +66,17 @@
"zip-dir": "^2.0.0"
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.2.1",
"@comfyorg/litegraph": "^0.7.77",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/litegraph": "^0.8.12",
"@primevue/themes": "^4.0.5",
"@vitejs/plugin-vue": "^5.0.5",
"@vueuse/core": "^11.0.0",
"axios": "^1.7.4",
"class-transformer": "^0.5.1",
"dotenv": "^16.4.5",
"fuse.js": "^7.0.0",
"lodash": "^4.17.21",
"pinia": "^2.1.7",
"primeicons": "^7.0.0",
"primevue": "^4.0.5",
"reflect-metadata": "^0.2.2",
"vue": "^3.4.31",
"vue-i18n": "^9.13.1",
"vue-router": "^4.4.3",
@@ -83,9 +84,7 @@
"zod-validation-error": "^3.3.0"
},
"lint-staged": {
"./**/*.{js,ts,tsx,vue}": [
"prettier --write",
"git add"
]
"./**/*.{js,ts,tsx,vue}": "prettier --write",
"**/*.ts": "tsc-files --noEmit"
}
}

View File

@@ -30,6 +30,10 @@ export default defineConfig({
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry'
},
/* Path to global setup file. Exported function runs once before all the tests */
globalSetup: './browser_tests/globalSetup.ts',
/* Path to global teardown file. Exported function runs once after all the tests */
globalTeardown: './browser_tests/globalTeardown.ts',
/* Configure projects for major browsers */
projects: [

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -266,7 +266,7 @@
],
"properties": {},
"widgets_values": [
"v1-5-pruned-emaonly.ckpt"
"v1-5-pruned-emaonly.safetensors"
]
}
],
@@ -347,5 +347,10 @@
"groups": [],
"config": {},
"extra": {},
"version": 0.4
"version": 0.4,
"models": [{
"name": "v1-5-pruned-emaonly.safetensors",
"url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly.safetensors?download=true",
"directory": "checkpoints"
}]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -409,5 +409,12 @@
]
}
},
"models": [
{
"name": "flux1-schnell-fp8.safetensors",
"url": "https://huggingface.co/Comfy-Org/flux1-schnell/resolve/main/flux1-schnell-fp8.safetensors?download=true",
"directory": "checkpoints"
}
],
"version": 0.4
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -330,7 +330,7 @@
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"v1-5-pruned-emaonly.ckpt"
"v1-5-pruned-emaonly.safetensors"
]
}
],
@@ -438,5 +438,10 @@
],
"config": {},
"extra": {},
"version": 0.4
"version": 0.4,
"models": [{
"name": "v1-5-pruned-emaonly.safetensors",
"url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly.safetensors?download=true",
"directory": "checkpoints"
}]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -429,7 +429,7 @@
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"v2-1_768-ema-pruned.ckpt"
"v2-1_768-ema-pruned.safetensors"
]
},
{
@@ -641,5 +641,12 @@
],
"config": {},
"extra": {},
"version": 0.4
"version": 0.4,
"models": [
{
"name": "v2-1_768-ema-pruned.safetensors",
"url": "https://huggingface.co/stabilityai/stable-diffusion-2-1/resolve/main/v2-1_768-ema-pruned.safetensors?download=true",
"directory": "checkpoints"
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 626 KiB

View File

@@ -18,6 +18,9 @@ def download_and_extract(version, temp_dir):
with zipfile.ZipFile(zip_path, "r") as zip_ref:
zip_ref.extractall(temp_dir)
# Clean up the zip file after extraction
os.remove(zip_path)
else:
raise Exception(
f"Failed to download release asset. Status code: {response.status_code}"

View File

@@ -10,13 +10,20 @@
<script setup lang="ts">
import config from '@/config'
import { computed, onMounted, onBeforeUnmount } from 'vue'
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
import { computed, onMounted } from 'vue'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import BlockUI from 'primevue/blockui'
import ProgressSpinner from 'primevue/progressspinner'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import { useEventListener } from '@vueuse/core'
const isLoading = computed<boolean>(() => useWorkspaceStore().spinner)
const workspaceStore = useWorkspaceStore()
const isLoading = computed<boolean>(() => workspaceStore.spinner)
const handleKey = (e: KeyboardEvent) => {
workspaceStore.shiftDown = e.shiftKey
}
useEventListener(window, 'keydown', handleKey)
useEventListener(window, 'keyup', handleKey)
onMounted(() => {
window['__COMFYUI_FRONTEND_VERSION__'] = config.app_version

View File

@@ -126,7 +126,8 @@ body {
/* Span across all columns */
grid-column: 1/-1;
grid-row: 3;
z-index: 10;
/* Bottom menu bar dropdown needs to be above of graph canvas splitter overlay which is z-index: 999 */
z-index: 1000;
display: flex;
flex-direction: column;
}
@@ -180,25 +181,6 @@ body {
margin: 3px 3px 3px 4px;
}
.comfy-menu-hamburger {
position: fixed;
top: 10px;
z-index: 9999;
right: 10px;
width: 30px;
display: none;
gap: 8px;
flex-direction: column;
cursor: pointer;
}
.comfy-menu-hamburger div {
height: 3px;
width: 100%;
border-radius: 20px;
background-color: white;
}
.comfy-menu {
font-size: 15px;
position: absolute;
@@ -316,6 +298,7 @@ span.drag-handle {
letter-spacing: 2px;
color: var(--drag-text);
text-shadow: 1px 0 1px black;
touch-action: none;
}
span.drag-handle::after {

View File

@@ -12,6 +12,8 @@ import { useTitle } from '@vueuse/core'
import { computed } from 'vue'
const DEFAULT_TITLE = 'ComfyUI'
const TITLE_SUFFIX = ' - ComfyUI'
const executionStore = useExecutionStore()
const executionText = computed(() =>
executionStore.isIdle ? '' : `[${executionStore.executionProgress}%]`
@@ -28,7 +30,9 @@ const isUnsavedText = computed(() =>
)
const workflowNameText = computed(() => {
const workflowName = workflowStore.activeWorkflow?.name
return workflowName ? isUnsavedText.value + workflowName : DEFAULT_TITLE
return workflowName
? isUnsavedText.value + workflowName + TITLE_SUFFIX
: DEFAULT_TITLE
})
const nodeExecutionTitle = computed(() =>

View File

@@ -1,5 +1,8 @@
<template>
<Splitter class="splitter-overlay" :pt:gutter="gutterClass">
<Splitter
class="splitter-overlay-root splitter-overlay"
:pt:gutter="sidebarPanelVisible ? '' : 'hidden'"
>
<SplitterPanel
class="side-bar-panel"
:minSize="10"
@@ -9,9 +12,22 @@
>
<slot name="side-bar-panel"></slot>
</SplitterPanel>
<SplitterPanel class="graph-canvas-panel" :size="100">
<div></div>
<SplitterPanel :size="100">
<Splitter
class="splitter-overlay"
layout="vertical"
:pt:gutter="bottomPanelVisible ? '' : 'hidden'"
>
<SplitterPanel class="graph-canvas-panel relative">
<slot name="graph-canvas-panel"></slot>
</SplitterPanel>
<SplitterPanel class="bottom-panel" v-show="bottomPanelVisible">
<slot name="bottom-panel"></slot>
</SplitterPanel>
</Splitter>
</SplitterPanel>
<SplitterPanel
class="side-bar-panel"
:minSize="10"
@@ -26,7 +42,8 @@
<script setup lang="ts">
import { useSettingStore } from '@/stores/settingStore'
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import Splitter from 'primevue/splitter'
import SplitterPanel from 'primevue/splitterpanel'
import { computed } from 'vue'
@@ -37,42 +54,39 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
)
const sidebarPanelVisible = computed(
() => useWorkspaceStore().activeSidebarTab !== null
() => useSidebarTabStore().activeSidebarTab !== null
)
const bottomPanelVisible = computed(
() => useBottomPanelStore().bottomPanelVisible
)
const gutterClass = computed(() => {
return sidebarPanelVisible.value ? '' : 'gutter-hidden'
})
</script>
<style>
.p-splitter-gutter {
<style scoped>
:deep(.p-splitter-gutter) {
pointer-events: auto;
}
.gutter-hidden {
display: none !important;
}
</style>
<style scoped>
.side-bar-panel {
background-color: var(--bg-color);
pointer-events: auto;
}
.bottom-panel {
background-color: var(--bg-color);
pointer-events: auto;
}
.splitter-overlay {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
background-color: transparent;
pointer-events: none;
@apply bg-transparent pointer-events-none border-none;
}
.splitter-overlay-root {
@apply w-full h-full absolute top-0 left-0;
/* Set it the same as the ComfyUI menu */
/* Note: Lite-graph DOM widgets have the same z-index as the node id, so
999 should be sufficient to make sure splitter overlays on node's DOM
widgets */
z-index: 999;
border: none;
}
</style>

View File

@@ -0,0 +1,46 @@
<template>
<Button
v-show="workspaceState.focusMode"
class="comfy-menu-hamburger"
icon="pi pi-bars"
severity="secondary"
text
size="large"
@click="exitFocusMode"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { watchEffect } from 'vue'
import { app } from '@/scripts/app'
import { useSettingStore } from '@/stores/settingStore'
const workspaceState = useWorkspaceStore()
const settingStore = useSettingStore()
const exitFocusMode = () => {
workspaceState.focusMode = false
}
watchEffect(() => {
if (settingStore.get('Comfy.UseNewMenu') !== 'Disabled') {
return
}
if (workspaceState.focusMode) {
app.ui.menuContainer.style.display = 'none'
} else {
app.ui.menuContainer.style.display = 'block'
}
})
</script>
<style scoped>
.comfy-menu-hamburger {
pointer-events: auto;
position: fixed;
top: 0px;
right: 0px;
z-index: 9999;
}
</style>

View File

@@ -31,8 +31,10 @@
<script lang="ts" setup>
import { useQueueSettingsStore } from '@/stores/queueStore'
import { useSettingStore } from '@/stores/settingStore'
import { storeToRefs } from 'pinia'
import InputNumber from 'primevue/inputnumber'
import { computed } from 'vue'
interface Props {
class?: string
@@ -45,7 +47,11 @@ const props = withDefaults(defineProps<Props>(), {
const queueSettingsStore = useQueueSettingsStore()
const { batchCount } = storeToRefs(queueSettingsStore)
const minQueueCount = 1
const maxQueueCount = 100
const settingStore = useSettingStore()
const maxQueueCount = computed(() =>
settingStore.get('Comfy.QueueButton.BatchCountLimit')
)
const handleClick = (increment: boolean) => {
let newCount: number

View File

@@ -0,0 +1,236 @@
<template>
<Panel
class="actionbar w-fit"
:style="style"
:class="{ 'is-dragging': isDragging, 'is-docked': isDocked }"
>
<div class="actionbar-content flex items-center" ref="panelRef">
<span class="drag-handle cursor-move mr-2 p-0!" ref="dragHandleRef">
</span>
<ComfyQueueButton />
<Divider layout="vertical" class="mx-1" />
<ButtonGroup class="flex flex-nowrap">
<Button
v-tooltip.bottom="$t('menu.refresh')"
icon="pi pi-refresh"
severity="secondary"
text
@click="() => commandStore.execute('Comfy.RefreshNodeDefinitions')"
/>
</ButtonGroup>
</div>
</Panel>
</template>
<script lang="ts" setup>
import { computed, inject, nextTick, onMounted, Ref, ref, watch } from 'vue'
import Panel from 'primevue/panel'
import Divider from 'primevue/divider'
import Button from 'primevue/button'
import ButtonGroup from 'primevue/buttongroup'
import ComfyQueueButton from './ComfyQueueButton.vue'
import { useSettingStore } from '@/stores/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import {
useDraggable,
useElementBounding,
useEventBus,
useEventListener,
useLocalStorage,
watchDebounced
} from '@vueuse/core'
import { clamp } from 'lodash'
const settingsStore = useSettingStore()
const commandStore = useCommandStore()
const visible = computed(
() => settingsStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
const panelRef = ref<HTMLElement | null>(null)
const dragHandleRef = ref<HTMLElement | null>(null)
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', false)
const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
x: 0,
y: 0
})
const {
x,
y,
style: style,
isDragging
} = useDraggable(panelRef, {
initialValue: { x: 0, y: 0 },
handle: dragHandleRef,
containerElement: document.body
})
// Update storedPosition when x or y changes
watchDebounced(
[x, y],
([newX, newY]) => {
storedPosition.value = { x: newX, y: newY }
},
{ debounce: 300 }
)
// Set initial position to bottom center
const setInitialPosition = () => {
if (x.value !== 0 || y.value !== 0) {
return
}
if (storedPosition.value.x !== 0 || storedPosition.value.y !== 0) {
x.value = storedPosition.value.x
y.value = storedPosition.value.y
captureLastDragState()
return
}
if (panelRef.value) {
const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
const menuWidth = panelRef.value.offsetWidth
const menuHeight = panelRef.value.offsetHeight
if (menuWidth === 0 || menuHeight === 0) {
return
}
x.value = (screenWidth - menuWidth) / 2
y.value = screenHeight - menuHeight - 10 // 10px margin from bottom
captureLastDragState()
}
}
onMounted(setInitialPosition)
watch(visible, (newVisible) => {
if (newVisible) {
nextTick(setInitialPosition)
}
})
const lastDragState = ref({
x: x.value,
y: y.value,
windowWidth: window.innerWidth,
windowHeight: window.innerHeight
})
const captureLastDragState = () => {
lastDragState.value = {
x: x.value,
y: y.value,
windowWidth: window.innerWidth,
windowHeight: window.innerHeight
}
}
watch(
isDragging,
(newIsDragging) => {
if (!newIsDragging) {
// Stop dragging
captureLastDragState()
}
},
{ immediate: true }
)
const adjustMenuPosition = () => {
if (panelRef.value) {
const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
const menuWidth = panelRef.value.offsetWidth
const menuHeight = panelRef.value.offsetHeight
// Calculate the distance from each edge
const distanceRight =
lastDragState.value.windowWidth - (lastDragState.value.x + menuWidth)
const distanceBottom =
lastDragState.value.windowHeight - (lastDragState.value.y + menuHeight)
// Determine if the menu is closer to right/bottom or left/top
const anchorRight = distanceRight < lastDragState.value.x
const anchorBottom = distanceBottom < lastDragState.value.y
// Calculate new position
if (anchorRight) {
x.value =
screenWidth - (lastDragState.value.windowWidth - lastDragState.value.x)
} else {
x.value = lastDragState.value.x
}
if (anchorBottom) {
y.value =
screenHeight -
(lastDragState.value.windowHeight - lastDragState.value.y)
} else {
y.value = lastDragState.value.y
}
// Ensure the menu stays within the screen bounds
x.value = clamp(x.value, 0, screenWidth - menuWidth)
y.value = clamp(y.value, 0, screenHeight - menuHeight)
}
}
useEventListener(window, 'resize', adjustMenuPosition)
const topMenuRef = inject<Ref<HTMLDivElement | null>>('topMenuRef')
const topMenuBounds = useElementBounding(topMenuRef)
const overlapThreshold = 20 // pixels
const isOverlappingWithTopMenu = computed(() => {
if (!panelRef.value) {
return false
}
const { height } = panelRef.value.getBoundingClientRect()
const actionbarBottom = y.value + height
const topMenuBottom = topMenuBounds.bottom.value
const overlapPixels =
Math.min(actionbarBottom, topMenuBottom) -
Math.max(y.value, topMenuBounds.top.value)
return overlapPixels > overlapThreshold
})
watch(isDragging, (newIsDragging) => {
if (!newIsDragging) {
// Stop dragging
isDocked.value = isOverlappingWithTopMenu.value
} else {
// Start dragging
isDocked.value = false
}
})
const eventBus = useEventBus<string>('topMenu')
watch([isDragging, isOverlappingWithTopMenu], ([dragging, overlapping]) => {
eventBus.emit('updateHighlight', {
isDragging: dragging,
isOverlapping: overlapping
})
})
</script>
<style scoped>
.actionbar {
pointer-events: all;
position: fixed;
z-index: 1000;
}
.actionbar.is-docked {
position: static;
@apply bg-transparent border-none p-0;
}
.actionbar.is-dragging {
user-select: none;
}
:deep(.p-panel-content) {
@apply p-1;
}
:deep(.p-panel-header) {
display: none;
}
</style>

View File

@@ -0,0 +1,127 @@
<template>
<div class="queue-button-group flex">
<SplitButton
class="comfyui-queue-button"
:label="activeQueueModeMenuItem.label"
severity="primary"
@click="queuePrompt"
:model="queueModeMenuItems"
data-testid="queue-button"
v-tooltip.bottom="
workspaceStore.shiftDown
? $t('menu.queueWorkflowFront')
: $t('menu.queueWorkflow')
"
>
<template #icon>
<i-lucide:list-start v-if="workspaceStore.shiftDown" />
<i v-else :class="activeQueueModeMenuItem.icon" />
</template>
<template #item="{ item }">
<Button
:label="item.label"
:icon="item.icon"
:severity="item.key === queueMode ? 'primary' : 'secondary'"
text
v-tooltip="item.tooltip"
/>
</template>
</SplitButton>
<BatchCountEdit />
<ButtonGroup class="execution-actions flex flex-nowrap">
<Button
v-tooltip.bottom="$t('menu.interrupt')"
icon="pi pi-times"
:severity="executingPrompt ? 'danger' : 'secondary'"
:disabled="!executingPrompt"
text
@click="() => commandStore.execute('Comfy.Interrupt')"
>
</Button>
<Button
v-tooltip.bottom="$t('sideToolbar.queueTab.clearPendingTasks')"
icon="pi pi-stop"
:severity="hasPendingTasks ? 'danger' : 'secondary'"
:disabled="!hasPendingTasks"
text
@click="() => commandStore.execute('Comfy.ClearPendingTasks')"
/>
</ButtonGroup>
</div>
</template>
<script setup lang="ts">
import SplitButton from 'primevue/splitbutton'
import Button from 'primevue/button'
import BatchCountEdit from './BatchCountEdit.vue'
import ButtonGroup from 'primevue/buttongroup'
import { useI18n } from 'vue-i18n'
import {
AutoQueueMode,
useQueuePendingTaskCountStore,
useQueueSettingsStore
} from '@/stores/queueStore'
import type { MenuItem } from 'primevue/menuitem'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
const workspaceStore = useWorkspaceStore()
const queueCountStore = storeToRefs(useQueuePendingTaskCountStore())
const { mode: queueMode } = storeToRefs(useQueueSettingsStore())
const { t } = useI18n()
const queueModeMenuItemLookup: Record<AutoQueueMode, MenuItem> = {
disabled: {
key: 'disabled',
label: 'Queue',
icon: 'pi pi-play',
tooltip: t('menu.disabledTooltip'),
command: () => {
queueMode.value = 'disabled'
}
},
instant: {
key: 'instant',
label: 'Queue (Instant)',
icon: 'pi pi-forward',
tooltip: t('menu.instantTooltip'),
command: () => {
queueMode.value = 'instant'
}
},
change: {
key: 'change',
label: 'Queue (Change)',
icon: 'pi pi-step-forward-alt',
tooltip: t('menu.changeTooltip'),
command: () => {
queueMode.value = 'change'
}
}
}
const activeQueueModeMenuItem = computed(
() => queueModeMenuItemLookup[queueMode.value]
)
const queueModeMenuItems = computed(() =>
Object.values(queueModeMenuItemLookup)
)
const executingPrompt = computed(() => !!queueCountStore.count.value)
const hasPendingTasks = computed(() => queueCountStore.count.value > 1)
const commandStore = useCommandStore()
const queuePrompt = (e: MouseEvent) => {
const commandId = e.shiftKey ? 'Comfy.QueuePromptFront' : 'Comfy.QueuePrompt'
commandStore.execute(commandId)
}
</script>
<style scoped>
.comfyui-queue-button :deep(.p-splitbutton-dropdown) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
</style>

View File

@@ -1,165 +0,0 @@
<template>
<Panel v-if="visible" class="app-menu">
<div class="app-menu-content flex align-center">
<div class="queue-button-group flex">
<SplitButton
class="comfyui-queue-button"
:label="activeQueueModeMenuItem.label"
:icon="activeQueueModeMenuItem.icon"
severity="primary"
@click="queuePrompt"
:model="queueModeMenuItems"
data-testid="queue-button"
v-tooltip.bottom="$t('menu.queueWorkflow')"
>
<template #item="{ item }">
<Button
:label="item.label"
:icon="item.icon"
:severity="item.key === queueMode ? 'primary' : 'secondary'"
text
v-tooltip="item.tooltip"
/>
</template>
</SplitButton>
<BatchCountEdit />
<ButtonGroup class="execution-actions ml-2">
<Button
v-tooltip.bottom="$t('menu.interrupt')"
icon="pi pi-times"
:severity="executingPrompt ? 'danger' : 'secondary'"
:disabled="!executingPrompt"
@click="() => commandStore.getCommandFunction('Comfy.Interrupt')()"
>
</Button>
<Button
v-tooltip.bottom="$t('sideToolbar.queueTab.clearPendingTasks')"
icon="pi pi-stop"
:severity="hasPendingTasks ? 'danger' : 'secondary'"
:disabled="!hasPendingTasks"
@click="
() => commandStore.getCommandFunction('Comfy.ClearPendingTasks')()
"
/>
</ButtonGroup>
</div>
<Divider layout="vertical" class="mx-2" />
<ButtonGroup>
<Button
v-tooltip.bottom="$t('menu.refresh')"
icon="pi pi-refresh"
severity="secondary"
@click="
() =>
commandStore.getCommandFunction('Comfy.RefreshNodeDefinitions')()
"
/>
<Button
v-tooltip.bottom="$t('menu.resetView')"
icon="pi pi-expand"
severity="secondary"
@click="() => commandStore.getCommandFunction('Comfy.ResetView')()"
/>
</ButtonGroup>
</div>
</Panel>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import Panel from 'primevue/panel'
import Divider from 'primevue/divider'
import SplitButton from 'primevue/splitbutton'
import Button from 'primevue/button'
import ButtonGroup from 'primevue/buttongroup'
import BatchCountEdit from './BatchCountEdit.vue'
import {
AutoQueueMode,
useQueuePendingTaskCountStore,
useQueueSettingsStore
} from '@/stores/queueStore'
import { app } from '@/scripts/app'
import { storeToRefs } from 'pinia'
import { useSettingStore } from '@/stores/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { MenuItem } from 'primevue/menuitem'
import { useI18n } from 'vue-i18n'
const settingsStore = useSettingStore()
const commandStore = useCommandStore()
const queueCountStore = storeToRefs(useQueuePendingTaskCountStore())
const { batchCount, mode: queueMode } = storeToRefs(useQueueSettingsStore())
const visible = computed(
() => settingsStore.get('Comfy.UseNewMenu') === 'Floating'
)
const { t } = useI18n()
const queueModeMenuItemLookup: Record<AutoQueueMode, MenuItem> = {
disabled: {
key: 'disabled',
label: 'Queue',
icon: 'pi pi-play',
tooltip: t('menu.disabledTooltip'),
command: () => {
queueMode.value = 'disabled'
}
},
instant: {
key: 'instant',
label: 'Queue (Instant)',
icon: 'pi pi-forward',
tooltip: t('menu.instantTooltip'),
command: () => {
queueMode.value = 'instant'
}
},
change: {
key: 'change',
label: 'Queue (Change)',
icon: 'pi pi-step-forward-alt',
tooltip: t('menu.changeTooltip'),
command: () => {
queueMode.value = 'change'
}
}
}
const activeQueueModeMenuItem = computed(
() => queueModeMenuItemLookup[queueMode.value]
)
const queueModeMenuItems = computed(() =>
Object.values(queueModeMenuItemLookup)
)
const executingPrompt = computed(() => !!queueCountStore.count.value)
const hasPendingTasks = computed(() => queueCountStore.count.value > 1)
const queuePrompt = (e: MouseEvent) => {
app.queuePrompt(e.shiftKey ? -1 : 0, batchCount.value)
}
</script>
<style scoped>
.app-menu {
pointer-events: all;
position: fixed;
bottom: 50px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
}
:deep(.p-panel-content) {
padding: 10px;
}
:deep(.p-panel-header) {
display: none;
}
.comfyui-queue-button :deep(.p-splitbutton-dropdown) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
</style>

View File

@@ -0,0 +1,51 @@
<template>
<div class="flex flex-col h-full">
<Tabs v-model:value="bottomPanelStore.activeBottomPanelTabId">
<TabList pt:tabList="border-none">
<div class="w-full flex justify-between">
<div class="tabs-container">
<Tab
v-for="tab in bottomPanelStore.bottomPanelTabs"
:key="tab.id"
:value="tab.id"
class="p-3 border-none"
>
<span class="font-bold">
{{ tab.title.toUpperCase() }}
</span>
</Tab>
</div>
<Button
class="justify-self-end"
icon="pi pi-times"
severity="secondary"
size="small"
text
@click="bottomPanelStore.bottomPanelVisible = false"
/>
</div>
</TabList>
</Tabs>
<!-- h-0 to force the div to flex-grow -->
<div class="flex-grow h-0">
<ExtensionSlot
v-if="
bottomPanelStore.bottomPanelVisible &&
bottomPanelStore.activeBottomPanelTab
"
:extension="bottomPanelStore.activeBottomPanelTab"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Tabs from 'primevue/tabs'
import TabList from 'primevue/tablist'
import Tab from 'primevue/tab'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
const bottomPanelStore = useBottomPanelStore()
</script>

View File

@@ -0,0 +1,61 @@
<template>
<div class="p-terminal rounded-none h-full w-full">
<ScrollPanel class="h-full w-full" ref="scrollPanelRef">
<pre class="px-4 whitespace-pre-wrap">{{ log }}</pre>
</ScrollPanel>
</div>
</template>
<script setup lang="ts">
import ScrollPanel from 'primevue/scrollpanel'
import { api } from '@/scripts/api'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
const log = ref<string>('')
const scrollPanelRef = ref<InstanceType<typeof ScrollPanel> | null>(null)
/**
* Whether the user has scrolled to the bottom of the terminal.
* This is used to prevent the terminal from scrolling to the bottom
* when new logs are fetched.
*/
const scrolledToBottom = ref(false)
let intervalId: number = 0
onMounted(async () => {
const element = scrollPanelRef.value?.$el
const scrollContainer = element?.querySelector('.p-scrollpanel-content')
if (scrollContainer) {
scrollContainer.addEventListener('scroll', () => {
scrolledToBottom.value =
scrollContainer.scrollTop + scrollContainer.clientHeight ===
scrollContainer.scrollHeight
})
}
const scrollToBottom = () => {
if (scrollContainer) {
scrollContainer.scrollTop = scrollContainer.scrollHeight
}
}
watch(log, () => {
if (scrolledToBottom.value) {
scrollToBottom()
}
})
const fetchLogs = async () => {
log.value = await api.getLogs()
}
await fetchLogs()
scrollToBottom()
intervalId = window.setInterval(fetchLogs, 500)
})
onBeforeUnmount(() => {
window.clearInterval(intervalId)
})
</script>

View File

@@ -9,7 +9,7 @@
<script setup lang="ts">
import type { DeviceStats } from '@/types/apiTypes'
import { formatMemory } from '@/utils/formatUtil'
import { formatSize } from '@/utils/formatUtil'
const props = defineProps<{
device: DeviceStats
@@ -30,7 +30,7 @@ const formatValue = (value: any, field: string) => {
field
)
) {
return formatMemory(value)
return formatSize(value)
}
return value
}

Some files were not shown because too many files have changed in this diff Show More