Compare commits
368 Commits
v1.2.63
...
node-templ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
782d93a7a0 | ||
|
|
7be14c5189 | ||
|
|
ee5c127146 | ||
|
|
acba6097e0 | ||
|
|
82d00a1bcf | ||
|
|
b9224464c0 | ||
|
|
fba9a03df3 | ||
|
|
2fd624cd3d | ||
|
|
095fe2a175 | ||
|
|
d838777e04 | ||
|
|
7e0d1d441d | ||
|
|
ddab149f16 | ||
|
|
a73fdcd3bd | ||
|
|
d6e0c197bd | ||
|
|
3117d0fdc1 | ||
|
|
96fda64b70 | ||
|
|
e3d2c3a814 | ||
|
|
1a8900de1f | ||
|
|
05ba526388 | ||
|
|
4bc79181ae | ||
|
|
feafbf9cbf | ||
|
|
40f9b881f3 | ||
|
|
8236163fea | ||
|
|
59b555b448 | ||
|
|
71eeee6744 | ||
|
|
1ff6e27d9c | ||
|
|
64ef0f18b1 | ||
|
|
73bdbddf90 | ||
|
|
a55833b3a6 | ||
|
|
43012eb1d1 | ||
|
|
d1e019589d | ||
|
|
7bc79edf3d | ||
|
|
58ad01adfe | ||
|
|
5f1a9659e9 | ||
|
|
6c6c356c78 | ||
|
|
893fd498df | ||
|
|
1ca388457d | ||
|
|
69f0da06f8 | ||
|
|
d9a34872c3 | ||
|
|
31fac3873c | ||
|
|
8dc057517f | ||
|
|
4617e0fb1a | ||
|
|
f8ec87ddea | ||
|
|
c12f059940 | ||
|
|
cc320e0f84 | ||
|
|
acbc38ced4 | ||
|
|
777a6d9ce3 | ||
|
|
7e0b87dd32 | ||
|
|
0161a670cf | ||
|
|
0eba49c536 | ||
|
|
1d9c3f00b7 | ||
|
|
904408de01 | ||
|
|
700336fcc7 | ||
|
|
dd192777b7 | ||
|
|
6b6992591b | ||
|
|
45380f7ca0 | ||
|
|
f0b735f3dd | ||
|
|
9568d63820 | ||
|
|
073638672d | ||
|
|
8ae9210298 | ||
|
|
daf94d74d5 | ||
|
|
14b3d4c766 | ||
|
|
40880dbb59 | ||
|
|
aa4742e394 | ||
|
|
0a7000328a | ||
|
|
da7a49bb5c | ||
|
|
5e4439b905 | ||
|
|
ea0883271e | ||
|
|
3d303c7693 | ||
|
|
9f14edaf2b | ||
|
|
d1738b50d2 | ||
|
|
c560628f1f | ||
|
|
c56533bb23 | ||
|
|
1387d7e627 | ||
|
|
16f2e56d8e | ||
|
|
75ffab2160 | ||
|
|
4c19e1ba3a | ||
|
|
2161ae4e5b | ||
|
|
3148c90e28 | ||
|
|
497b2fba8d | ||
|
|
09d5e29f01 | ||
|
|
56b63ebab5 | ||
|
|
3ba776e6ca | ||
|
|
0a784d9236 | ||
|
|
00df7b428f | ||
|
|
27bacc36d4 | ||
|
|
394df49208 | ||
|
|
38847e1079 | ||
|
|
dd86417177 | ||
|
|
1366c8cb44 | ||
|
|
3a910f25e9 | ||
|
|
cc420b70a5 | ||
|
|
caa3ac2068 | ||
|
|
8baaf380dc | ||
|
|
d719a4e0fb | ||
|
|
d254559e20 | ||
|
|
d701758663 | ||
|
|
a11b78d1c3 | ||
|
|
dfb695be72 | ||
|
|
2974b9257a | ||
|
|
d11d07334b | ||
|
|
0c8fe41b84 | ||
|
|
ed0592d6e0 | ||
|
|
94f4147f92 | ||
|
|
67ee8726ef | ||
|
|
e48c78541c | ||
|
|
bf7a9bf5eb | ||
|
|
3fb2d423ba | ||
|
|
74f7311585 | ||
|
|
97c38583e9 | ||
|
|
324eff93fd | ||
|
|
4b1104f52c | ||
|
|
d702fc81a2 | ||
|
|
2a94ab4423 | ||
|
|
37f6c89383 | ||
|
|
35fab0bef3 | ||
|
|
795e932b8f | ||
|
|
8dddffe840 | ||
|
|
1f91a88d7b | ||
|
|
10f43be911 | ||
|
|
87517daf1f | ||
|
|
739ebd3d04 | ||
|
|
4582c71583 | ||
|
|
757f0ced81 | ||
|
|
a471a3f302 | ||
|
|
5a3a8d32ab | ||
|
|
44b109a449 | ||
|
|
229896a4b7 | ||
|
|
997b5ee819 | ||
|
|
ba99eca700 | ||
|
|
82c369322d | ||
|
|
546c5dabc8 | ||
|
|
7d450adf93 | ||
|
|
eed92864f2 | ||
|
|
8fd7852740 | ||
|
|
2a927bb9ea | ||
|
|
c566491ac7 | ||
|
|
7729611a2a | ||
|
|
8861492655 | ||
|
|
fa9d944b32 | ||
|
|
880437f3c0 | ||
|
|
c1e960c83c | ||
|
|
571386c061 | ||
|
|
44d886a18b | ||
|
|
92ac403679 | ||
|
|
dc3dab4e1c | ||
|
|
386594554e | ||
|
|
02a951ad58 | ||
|
|
92f0f4a21c | ||
|
|
645897f8b8 | ||
|
|
ef4179a06c | ||
|
|
a3e4af40c1 | ||
|
|
1dedce5ec6 | ||
|
|
3a4b36fb31 | ||
|
|
25457d31d4 | ||
|
|
4f9dc830b6 | ||
|
|
12d421b42c | ||
|
|
16ebfd6171 | ||
|
|
59c999324e | ||
|
|
624bcc75ab | ||
|
|
54e833502a | ||
|
|
c3242711c7 | ||
|
|
17391e4aad | ||
|
|
48b840a88d | ||
|
|
377fed584f | ||
|
|
14a6687cc9 | ||
|
|
d142893244 | ||
|
|
957a767ed0 | ||
|
|
3553c8e0d4 | ||
|
|
05221f7961 | ||
|
|
d113072a64 | ||
|
|
afa619b7df | ||
|
|
e25bbc19cb | ||
|
|
0251bc9e6c | ||
|
|
7a3f20c57d | ||
|
|
269fc7c8c9 | ||
|
|
db08f74d6a | ||
|
|
5db757ade2 | ||
|
|
59c03d2de5 | ||
|
|
b655c5544d | ||
|
|
9388ee0705 | ||
|
|
f228ec29eb | ||
|
|
7239e94092 | ||
|
|
e33a5f7736 | ||
|
|
c1c990e6f3 | ||
|
|
a890756868 | ||
|
|
015ee2df15 | ||
|
|
c3b2697568 | ||
|
|
dfcabd2834 | ||
|
|
634196cbd6 | ||
|
|
5611e90fda | ||
|
|
c23d95f8f9 | ||
|
|
c2377b62ac | ||
|
|
f328d4cd81 | ||
|
|
fbc1482b90 | ||
|
|
90e07af4f5 | ||
|
|
014c3f3172 | ||
|
|
419009424b | ||
|
|
f599c9bcb8 | ||
|
|
6c696cddb9 | ||
|
|
d787c21f8b | ||
|
|
f96f08be32 | ||
|
|
8ebb51b9a3 | ||
|
|
60e1b82df6 | ||
|
|
459afa158c | ||
|
|
1c3d3b33f6 | ||
|
|
f64365915b | ||
|
|
ec8e6f79b3 | ||
|
|
b89f467983 | ||
|
|
009dbcf8c7 | ||
|
|
4413fd248c | ||
|
|
8962597e69 | ||
|
|
f4d4111fbd | ||
|
|
babac5a4a9 | ||
|
|
f71595fcc9 | ||
|
|
59a5f5f5d0 | ||
|
|
2d5faa7f3d | ||
|
|
32fa950aa1 | ||
|
|
82112c2c6e | ||
|
|
f94bdc358b | ||
|
|
f6466d7062 | ||
|
|
1c5fd2465e | ||
|
|
e99329cff5 | ||
|
|
fabcbaec82 | ||
|
|
c8f50509ed | ||
|
|
165604bb80 | ||
|
|
829bce1c8c | ||
|
|
c3b82165fa | ||
|
|
d673a521d8 | ||
|
|
ee88a79bc3 | ||
|
|
5f3afa3776 | ||
|
|
3cafc10c2b | ||
|
|
2cb1cea196 | ||
|
|
482da21ba7 | ||
|
|
bf80340310 | ||
|
|
5ef15c0daf | ||
|
|
62be958d47 | ||
|
|
1ba236bbce | ||
|
|
a4e08f60fe | ||
|
|
5ba1d1a3f7 | ||
|
|
58dd15a662 | ||
|
|
50a6ee27a0 | ||
|
|
23952d9751 | ||
|
|
2b26514190 | ||
|
|
f8343d0f93 | ||
|
|
cc17bee945 | ||
|
|
ff1ca268a4 | ||
|
|
99c948f578 | ||
|
|
d68a1116dc | ||
|
|
dee1ec1a2a | ||
|
|
9cbfc9856b | ||
|
|
a95a6f9b47 | ||
|
|
c83ce863d7 | ||
|
|
05aa78372b | ||
|
|
38e3dcbaeb | ||
|
|
cfa763962e | ||
|
|
8c156cc651 | ||
|
|
c7aabecc0e | ||
|
|
defacf3398 | ||
|
|
7f2920644e | ||
|
|
c92ff79231 | ||
|
|
3c70c1e463 | ||
|
|
1b3cc4de1a | ||
|
|
b97331cbab | ||
|
|
b7287dbb22 | ||
|
|
2c90735bb1 | ||
|
|
4d5fbeff45 | ||
|
|
ad55722662 | ||
|
|
9c118c8e37 | ||
|
|
267660a661 | ||
|
|
f2017291d6 | ||
|
|
4cc69544b5 | ||
|
|
2649d72d3f | ||
|
|
a852b8e6e1 | ||
|
|
6deb994235 | ||
|
|
b30d285025 | ||
|
|
18476d28dc | ||
|
|
57a4cb9036 | ||
|
|
ebc71b0e46 | ||
|
|
39d68bcdc4 | ||
|
|
e20126a254 | ||
|
|
416fd0aed6 | ||
|
|
661b8081c1 | ||
|
|
64b5f4e7d5 | ||
|
|
1775d43d90 | ||
|
|
142882a8ff | ||
|
|
65cad74eba | ||
|
|
77aaa38a92 | ||
|
|
ea3d8cf728 | ||
|
|
b3a624a572 | ||
|
|
a737be7e16 | ||
|
|
aca2194892 | ||
|
|
3a2b2f9e15 | ||
|
|
a19f713c57 | ||
|
|
8b2ef3c352 | ||
|
|
861bcabd66 | ||
|
|
cc2b64df52 | ||
|
|
a7a0035b0e | ||
|
|
31b1aeeb69 | ||
|
|
3f10fd53bd | ||
|
|
0194d76722 | ||
|
|
98a0291bbd | ||
|
|
f9fc36f0ed | ||
|
|
a2bd2a9bae | ||
|
|
c42222cf0d | ||
|
|
271a4979b7 | ||
|
|
1c980397b8 | ||
|
|
5d957a05b9 | ||
|
|
f75f774ddb | ||
|
|
224c0080ee | ||
|
|
6ea5fea1a7 | ||
|
|
04e1344676 | ||
|
|
0117964ca5 | ||
|
|
9d110d39b2 | ||
|
|
8d7693e5ad | ||
|
|
ec9a30d269 | ||
|
|
56fc2dd753 | ||
|
|
9050591ff9 | ||
|
|
0cf21b190c | ||
|
|
2531ec178e | ||
|
|
81119acaf2 | ||
|
|
05f999903d | ||
|
|
66c02d1e3a | ||
|
|
a05df99a8a | ||
|
|
e200e2f89c | ||
|
|
cdaa0bda5b | ||
|
|
a41f3b1ac6 | ||
|
|
3585cb69f5 | ||
|
|
a53f0ba4db | ||
|
|
7300f6edc2 | ||
|
|
1126eaa346 | ||
|
|
7e5d82d0e8 | ||
|
|
2b2b1cdb85 | ||
|
|
0b7c1609fd | ||
|
|
7760f91a56 | ||
|
|
58d8ab40c4 | ||
|
|
4ab3aa9a39 | ||
|
|
9199639320 | ||
|
|
5ee0fd3519 | ||
|
|
59976ea357 | ||
|
|
62bddded37 | ||
|
|
35a7c81fd8 | ||
|
|
30469a6d88 | ||
|
|
d2d645f74b | ||
|
|
4e5bcd6a3b | ||
|
|
6a8287e192 | ||
|
|
7b344d5629 | ||
|
|
35579e644e | ||
|
|
1bc78a716e | ||
|
|
0d28c108d2 | ||
|
|
6a158d46b8 | ||
|
|
bf7652227a | ||
|
|
2aaee5c331 | ||
|
|
fa2884f9b2 | ||
|
|
9c7ea5bd87 | ||
|
|
5e51ae37cf | ||
|
|
4fa3a38f98 | ||
|
|
d735513e60 | ||
|
|
38c2ec7532 | ||
|
|
f4d4cc3439 | ||
|
|
4ae066c57d | ||
|
|
2d1ff64951 | ||
|
|
73a7f7dae0 | ||
|
|
cf6367b649 | ||
|
|
84fc0e9205 | ||
|
|
941f71faea | ||
|
|
da651eee6f | ||
|
|
eed00f97f9 |
@@ -12,8 +12,12 @@ 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
|
||||
|
||||
# Whether to enable minification of the frontend code.
|
||||
ENABLE_MINIFY=true
|
||||
ENABLE_MINIFY=true
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
@@ -50,6 +50,12 @@ body:
|
||||
description: 'Please copy the output from your browser logs here. You can access this by pressing F12 to toggle the developer tools, then navigating to the Console tab.'
|
||||
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:
|
||||
|
||||
2
.github/workflows/test-browser-exp.yaml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.label.name == 'New Browser Test Expectations'
|
||||
steps:
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v1
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.1
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install chromium --with-deps
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
8
.github/workflows/test-ui.yaml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
jest-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v1
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.1
|
||||
- name: Run Jest tests
|
||||
run: |
|
||||
npm run test:generate
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
playwright-tests-chromium:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v1
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.1
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install chromium --with-deps
|
||||
working-directory: ComfyUI_frontend
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
playwright-tests-chromium-2x:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v1
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.1
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install chromium --with-deps
|
||||
working-directory: ComfyUI_frontend
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
playwright-tests-mobile-chrome:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v1
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v2.1
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install chromium --with-deps
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
53
.github/workflows/update-main.yaml
vendored
@@ -1,53 +0,0 @@
|
||||
name: Update Main Repo from PR
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
update-main-repo:
|
||||
if: github.event.label.name == 'Update Main Repo'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout frontend repo PR
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build project
|
||||
run: npm run build
|
||||
|
||||
- name: Checkout ComfyUI
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: "comfyanonymous/ComfyUI"
|
||||
path: ComfyUI
|
||||
ref: master
|
||||
|
||||
- name: Copy compiled assets
|
||||
run: |
|
||||
rm -rf ./ComfyUI/web/*
|
||||
cp -R dist/* ./ComfyUI/web/
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v5
|
||||
with:
|
||||
token: ${{ secrets.PAT }}
|
||||
commit-message: 'Update frontend assets from PR #${{ github.event.pull_request.number }}'
|
||||
title: 'Update frontend assets from PR #${{ github.event.pull_request.number }}'
|
||||
body: |
|
||||
This PR updates the compiled frontend assets from PR #${{ github.event.pull_request.number }} in the frontend repo.
|
||||
|
||||
Frontend PR: ${{ github.event.pull_request.html_url }}
|
||||
branch: update-frontend-assets-pr-${{ github.event.pull_request.number }}
|
||||
base: main
|
||||
path: ComfyUI
|
||||
@@ -1 +1,5 @@
|
||||
npx lint-staged
|
||||
if [[ "$OS" == "Windows_NT" ]]; then
|
||||
npx.cmd lint-staged
|
||||
else
|
||||
npx lint-staged
|
||||
fi
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"trailingComma": "none"
|
||||
"trailingComma": "none",
|
||||
"printWidth": 80
|
||||
}
|
||||
335
README.md
@@ -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,31 @@ There will be a 2-day feature freeze before each stable release. During this per
|
||||
|
||||
### Major features
|
||||
|
||||
<details>
|
||||
<summary>v1.3.22: Integrated server terminal</summary>
|
||||
|
||||
Press Ctrl + ` to toggle integrated terminal.
|
||||
|
||||
https://github.com/user-attachments/assets/eddedc6a-07a3-4a83-9475-63b3977f6d94
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>v1.3.7: Keybinding customization</summary>
|
||||
|
||||
## Basic UI
|
||||

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

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

|
||||

|
||||
|
||||
[rec.webm](https://github.com/user-attachments/assets/a3984ed9-eb28-4d47-86c0-7fc3efc2b5d0)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>v1.2.4: Node library sidebar tab</summary>
|
||||
|
||||
@@ -90,6 +115,58 @@ https://github.com/user-attachments/assets/4bbca3ee-318f-4cf0-be32-a5a5541066cf
|
||||
|
||||
### QoL changes
|
||||
|
||||
<details>
|
||||
<summary>v1.3.32: **Litegraph** Nested group</summary>
|
||||
|
||||
https://github.com/user-attachments/assets/f51adeb1-028e-40af-81e4-0ac13075198a
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>v1.3.24: **Litegraph** Group selection</summary>
|
||||
|
||||
https://github.com/user-attachments/assets/e6230a94-411e-4fba-90cb-6c694200adaa
|
||||
</details>
|
||||
|
||||
<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>
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>v1.2.44: **Litegraph** Double click group title to edit</summary>
|
||||
|
||||
@@ -136,7 +213,173 @@ 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.34: Register about panel badges</summary>
|
||||
|
||||
```js
|
||||
app.registerExtension({
|
||||
name: 'TestExtension1',
|
||||
aboutPageBadges: [
|
||||
{
|
||||
label: 'Test Badge',
|
||||
url: 'https://example.com',
|
||||
icon: 'pi pi-box'
|
||||
}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||

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

|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<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>
|
||||
|
||||
Extensions can call the following API to register custom topbar menu items.
|
||||
|
||||
```js
|
||||
app.registerExtension({
|
||||
name: 'TestExtension1',
|
||||
commands: [
|
||||
{
|
||||
id: 'foo-id',
|
||||
label: 'foo',
|
||||
function: () => {
|
||||
alert(1)
|
||||
}
|
||||
}
|
||||
],
|
||||
menuCommands: [
|
||||
{
|
||||
path: ['ext', 'ext2'],
|
||||
commands: ['foo-id']
|
||||
}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||

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

|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>v1.2.4: Extension API to register custom sidebar tab</summary>
|
||||
|
||||
@@ -162,51 +405,16 @@ We will support custom icons later.
|
||||

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

|
||||
</details>
|
||||
|
||||
## Road Map
|
||||
|
||||
### What has been done
|
||||
|
||||
- Migrate all code to TypeScript with minimal change modification to the original logic.
|
||||
- Bundle all code with Vite's rollup build.
|
||||
- Added a shim layer to be backward compatible with the existing extension system. <https://github.com/huchenlei/ComfyUI_frontend/pull/15>
|
||||
- Front-end dev server.
|
||||
- Zod schema for input validation on ComfyUI workflow.
|
||||
- Make litegraph a npm dependency. <https://github.com/Comfy-Org/ComfyUI_frontend/pull/89>
|
||||
- Introduce Vue to start managing part of the UI.
|
||||
- Easy install and version management (<https://github.com/comfyanonymous/ComfyUI/pull/3897>).
|
||||
- Better node management. Sherlock <https://github.com/Nuked88/ComfyUI-N-Sidebar>.
|
||||
- Replace the existing ComfyUI front-end implementation. <https://github.com/comfyanonymous/ComfyUI/pull/4379>
|
||||
|
||||
|
||||
### What to be done
|
||||
|
||||
- Remove `@ts-ignore`s.
|
||||
- Turn on `strict` on `tsconfig.json`.
|
||||
- Add more widget types for node developers.
|
||||
- LLM streaming node.
|
||||
- Linear mode (Similar to InvokeAI's linear mode).
|
||||
- Keybinding settings management. Register keybindings API for custom nodes.
|
||||
|
||||
## Development
|
||||
|
||||
### 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
|
||||
@@ -217,11 +425,31 @@ hook is used to auto-format code on commit.
|
||||
Note: The dev server will NOT load any extension from the ComfyUI server. Only
|
||||
core extensions will be loaded.
|
||||
|
||||
- Run `npm install` to install the necessary packages
|
||||
- Start local ComfyUI backend at `localhost:8188`
|
||||
- Run `npm run dev` to start the dev server
|
||||
- Run `npm run dev:electron` to start the dev server with electron API mocked
|
||||
|
||||
### Test
|
||||
#### Access dev server on touch devices
|
||||
|
||||
After you start the dev server, you should see following logs:
|
||||
|
||||
```
|
||||
> comfyui-frontend@1.3.42 dev
|
||||
> vite
|
||||
|
||||
|
||||
VITE v5.4.6 ready in 488 ms
|
||||
|
||||
➜ Local: http://localhost:5173/
|
||||
➜ Network: http://172.21.80.1:5173/
|
||||
➜ Network: http://192.168.2.20:5173/
|
||||
➜ press h + enter to show help
|
||||
```
|
||||
|
||||
Make sure your desktop machine and touch device are on the same network. On your touch device,
|
||||
navigate to `http://<server_ip>:5173` (e.g. `http://192.168.2.20:5173` here), to access the ComfyUI frontend.
|
||||
|
||||
### Unit Test
|
||||
|
||||
- `git clone https://github.com/comfyanonymous/ComfyUI_examples.git` to `tests-ui/ComfyUI_examples` or the EXAMPLE_REPO_PATH location specified in .env
|
||||
- `npm i` to install all dependencies
|
||||
@@ -229,6 +457,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.
|
||||
@@ -236,7 +474,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
125
browser_tests/actionbar.spec.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { Response } from '@playwright/test'
|
||||
import type { StatusWsMessage } from '../src/types/apiTypes.ts'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
import { comfyPageFixture } from './fixtures/ComfyPage'
|
||||
import { webSocketFixture } from './fixtures/ws.ts'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
test.describe('Actionbar', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
/**
|
||||
* This test ensures that the autoqueue change mode can only queue one change at a time
|
||||
*/
|
||||
test('Does not auto-queue multiple changes at a time', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
}) => {
|
||||
// Enable change auto-queue mode
|
||||
const queueOpts = await comfyPage.actionbar.queueButton.toggleOptions()
|
||||
expect(await queueOpts.getMode()).toBe('disabled')
|
||||
await queueOpts.setMode('change')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await queueOpts.getMode()).toBe('change')
|
||||
await comfyPage.actionbar.queueButton.toggleOptions()
|
||||
|
||||
// Intercept the prompt queue endpoint
|
||||
let promptNumber = 0
|
||||
comfyPage.page.route('**/api/prompt', async (route, req) => {
|
||||
await new Promise((r) => setTimeout(r, 100))
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
prompt_id: promptNumber,
|
||||
number: ++promptNumber,
|
||||
node_errors: {},
|
||||
// Include the request data to validate which prompt was queued so we can validate the width
|
||||
__request: req.postDataJSON()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Start watching for a message to prompt
|
||||
const requestPromise = comfyPage.page.waitForResponse('**/api/prompt')
|
||||
|
||||
// Find and set the width on the latent node
|
||||
const triggerChange = async (value: number) => {
|
||||
return await comfyPage.page.evaluate((value) => {
|
||||
const node = window['app'].graph._nodes.find(
|
||||
(n) => n.type === 'EmptyLatentImage'
|
||||
)
|
||||
node.widgets[0].value = value
|
||||
window[
|
||||
'app'
|
||||
].extensionManager.workflow.activeWorkflow.changeTracker.checkState()
|
||||
}, value)
|
||||
}
|
||||
|
||||
// Trigger a status websocket message
|
||||
const triggerStatus = async (queueSize: number) => {
|
||||
await ws.trigger({
|
||||
type: 'status',
|
||||
data: {
|
||||
status: {
|
||||
exec_info: {
|
||||
queue_remaining: queueSize
|
||||
}
|
||||
}
|
||||
}
|
||||
} as StatusWsMessage)
|
||||
}
|
||||
|
||||
// Extract the width from the queue response
|
||||
const getQueuedWidth = async (resp: Promise<Response>) => {
|
||||
const obj = await (await resp).json()
|
||||
return obj['__request']['prompt']['5']['inputs']['width']
|
||||
}
|
||||
|
||||
// Trigger a bunch of changes
|
||||
const START = 32
|
||||
const END = 64
|
||||
for (let i = START; i <= END; i += 8) {
|
||||
await triggerChange(i)
|
||||
}
|
||||
|
||||
// Ensure the queued width is the first value
|
||||
expect(
|
||||
await getQueuedWidth(requestPromise),
|
||||
'the first queued prompt should be the first change width'
|
||||
).toBe(START)
|
||||
|
||||
// Ensure that no other changes are queued
|
||||
await expect(
|
||||
comfyPage.page.waitForResponse('**/api/prompt', { timeout: 250 })
|
||||
).rejects.toThrow()
|
||||
expect(
|
||||
promptNumber,
|
||||
'only 1 prompt should have been queued even though there were multiple changes'
|
||||
).toBe(1)
|
||||
|
||||
// Trigger a status update so auto-queue re-runs
|
||||
await triggerStatus(1)
|
||||
await triggerStatus(0)
|
||||
|
||||
// Ensure the queued width is the last queued value
|
||||
expect(
|
||||
await getQueuedWidth(comfyPage.page.waitForResponse('**/api/prompt')),
|
||||
'last queued prompt width should be the last change'
|
||||
).toBe(END)
|
||||
expect(promptNumber, 'queued prompt count should be 2').toBe(2)
|
||||
})
|
||||
|
||||
test('Can dock actionbar into top menu', async ({ comfyPage }) => {
|
||||
await comfyPage.page.dragAndDrop(
|
||||
'.actionbar .drag-handle',
|
||||
'.comfyui-menu',
|
||||
{
|
||||
targetPosition: { x: 0, y: 0 }
|
||||
}
|
||||
)
|
||||
expect(await comfyPage.actionbar.isDocked()).toBe(true)
|
||||
})
|
||||
})
|
||||
135
browser_tests/assets/default.json
Normal file
@@ -0,0 +1,135 @@
|
||||
{
|
||||
"last_node_id": 9,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [413, 389],
|
||||
"size": [425.27801513671875, 180.6060791015625],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "clip", "type": "CLIP", "link": 5 }],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [6],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["text, watermark"]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [415, 186],
|
||||
"size": [422.84503173828125, 164.31304931640625],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "clip", "type": "CLIP", "link": 3 }],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [4],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [473, 609],
|
||||
"size": [315, 106],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [2], "slot_index": 0 }],
|
||||
"properties": {},
|
||||
"widgets_values": [512, 512, 1]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [863, 186],
|
||||
"size": [315, 262],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": 1 },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": 4 },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": 6 },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": 2 }
|
||||
],
|
||||
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [7], "slot_index": 0 }],
|
||||
"properties": {},
|
||||
"widgets_values": [156680208700286, true, 20, 8, "euler", "normal", 1]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1209, 188],
|
||||
"size": [210, 46],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "samples", "type": "LATENT", "link": 7 },
|
||||
{ "name": "vae", "type": "VAE", "link": 8 }
|
||||
],
|
||||
"outputs": [{ "name": "IMAGE", "type": "IMAGE", "links": [9], "slot_index": 0 }],
|
||||
"properties": {}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [1451, 189],
|
||||
"size": [210, 26],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "images", "type": "IMAGE", "link": 9 }],
|
||||
"properties": {}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [26, 474],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": [1], "slot_index": 0 },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": [3, 5], "slot_index": 1 },
|
||||
{ "name": "VAE", "type": "VAE", "links": [8], "slot_index": 2 }
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["v1-5-pruned-emaonly.ckpt"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 4, 0, 3, 0, "MODEL"],
|
||||
[2, 5, 0, 3, 3, "LATENT"],
|
||||
[3, 4, 1, 6, 0, "CLIP"],
|
||||
[4, 6, 0, 3, 1, "CONDITIONING"],
|
||||
[5, 4, 1, 7, 0, "CLIP"],
|
||||
[6, 7, 0, 3, 2, "CONDITIONING"],
|
||||
[7, 3, 0, 8, 0, "LATENT"],
|
||||
[8, 4, 2, 8, 1, "VAE"],
|
||||
[9, 8, 0, 9, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
404
browser_tests/assets/group_node_v1.3.3.json
Normal file
@@ -0,0 +1,404 @@
|
||||
{
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "workflow>group_node",
|
||||
"pos": {
|
||||
"0": 26,
|
||||
"1": 186
|
||||
},
|
||||
"size": {
|
||||
"0": 400,
|
||||
"1": 390
|
||||
},
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "workflow>group_node"
|
||||
},
|
||||
"widgets_values": [
|
||||
512,
|
||||
512,
|
||||
1,
|
||||
"v1-5-pruned-emaonly.ckpt",
|
||||
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,",
|
||||
"text, watermark",
|
||||
156680208700286,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
1,
|
||||
"ComfyUI"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"groupNodes": {
|
||||
"group_node": {
|
||||
"nodes": [
|
||||
{
|
||||
"id": -1,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": {
|
||||
"0": 473,
|
||||
"1": 609
|
||||
},
|
||||
"size": {
|
||||
"0": 315,
|
||||
"1": 106
|
||||
},
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyLatentImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
512,
|
||||
512,
|
||||
1
|
||||
],
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": {
|
||||
"0": 26,
|
||||
"1": 474
|
||||
},
|
||||
"size": {
|
||||
"0": 315,
|
||||
"1": 98
|
||||
},
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": [],
|
||||
"slot_index": 1
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": [],
|
||||
"slot_index": 2
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": [
|
||||
"v1-5-pruned-emaonly.ckpt"
|
||||
],
|
||||
"index": 1
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": {
|
||||
"0": 415,
|
||||
"1": 186
|
||||
},
|
||||
"size": {
|
||||
"0": 422.84503173828125,
|
||||
"1": 164.31304931640625
|
||||
},
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||
],
|
||||
"index": 2
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": {
|
||||
"0": 413,
|
||||
"1": 389
|
||||
},
|
||||
"size": {
|
||||
"0": 425.27801513671875,
|
||||
"1": 180.6060791015625
|
||||
},
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"text, watermark"
|
||||
],
|
||||
"index": 3
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"type": "KSampler",
|
||||
"pos": {
|
||||
"0": 863,
|
||||
"1": 186
|
||||
},
|
||||
"size": {
|
||||
"0": 315,
|
||||
"1": 262
|
||||
},
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
156680208700286,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
1
|
||||
],
|
||||
"index": 4
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"type": "VAEDecode",
|
||||
"pos": {
|
||||
"0": 1209,
|
||||
"1": 188
|
||||
},
|
||||
"size": {
|
||||
"0": 210,
|
||||
"1": 46
|
||||
},
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
},
|
||||
"index": 5
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"type": "SaveImage",
|
||||
"pos": {
|
||||
"0": 1451,
|
||||
"1": 189
|
||||
},
|
||||
"size": {
|
||||
"0": 210,
|
||||
"1": 58
|
||||
},
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"ComfyUI"
|
||||
],
|
||||
"index": 6
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
4,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
1,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
4,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
1,
|
||||
0,
|
||||
4,
|
||||
0,
|
||||
4,
|
||||
"MODEL"
|
||||
],
|
||||
[
|
||||
2,
|
||||
0,
|
||||
4,
|
||||
1,
|
||||
6,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
3,
|
||||
0,
|
||||
4,
|
||||
2,
|
||||
7,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
3,
|
||||
5,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
4,
|
||||
0,
|
||||
5,
|
||||
0,
|
||||
3,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
1,
|
||||
2,
|
||||
5,
|
||||
1,
|
||||
4,
|
||||
"VAE"
|
||||
],
|
||||
[
|
||||
5,
|
||||
0,
|
||||
6,
|
||||
0,
|
||||
8,
|
||||
"IMAGE"
|
||||
]
|
||||
],
|
||||
"external": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
252
browser_tests/assets/legacy_group_node.json
Normal file
@@ -0,0 +1,252 @@
|
||||
{
|
||||
"last_node_id": 15,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 15,
|
||||
"type": "workflow/hello",
|
||||
"pos": {
|
||||
"0": 566,
|
||||
"1": 316
|
||||
},
|
||||
"size": {
|
||||
"0": 468.5999755859375,
|
||||
"1": 582
|
||||
},
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null,
|
||||
"label": "model"
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null,
|
||||
"label": "positive"
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null,
|
||||
"label": "negative"
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null,
|
||||
"label": "latent_image"
|
||||
},
|
||||
{
|
||||
"name": "KSampler model",
|
||||
"type": "MODEL",
|
||||
"link": null,
|
||||
"label": "KSampler model"
|
||||
},
|
||||
{
|
||||
"name": "KSampler positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null,
|
||||
"label": "KSampler positive"
|
||||
},
|
||||
{
|
||||
"name": "KSampler negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null,
|
||||
"label": "KSampler negative"
|
||||
},
|
||||
{
|
||||
"name": "KSampler latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null,
|
||||
"label": "KSampler latent_image"
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null,
|
||||
"shape": 3,
|
||||
"label": "LATENT"
|
||||
},
|
||||
{
|
||||
"name": "KSampler LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null,
|
||||
"shape": 3,
|
||||
"label": "KSampler LATENT"
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "workflow/hello"
|
||||
},
|
||||
"widgets_values": [
|
||||
"enable",
|
||||
0,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
0,
|
||||
10000,
|
||||
"disable",
|
||||
0,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"groupNodes": {
|
||||
"hello": {
|
||||
"nodes": [
|
||||
{
|
||||
"id": -1,
|
||||
"type": "KSamplerAdvanced",
|
||||
"pos": {
|
||||
"0": 351.3332824707031,
|
||||
"1": 577.3333129882812
|
||||
},
|
||||
"size": {
|
||||
"0": 315,
|
||||
"1": 334
|
||||
},
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null,
|
||||
"label": "model"
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null,
|
||||
"label": "positive"
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null,
|
||||
"label": "negative"
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null,
|
||||
"label": "latent_image"
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null,
|
||||
"shape": 3,
|
||||
"label": "LATENT"
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSamplerAdvanced"
|
||||
},
|
||||
"widgets_values": [
|
||||
"enable",
|
||||
0,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
0,
|
||||
10000,
|
||||
"disable"
|
||||
],
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"type": "KSampler",
|
||||
"pos": {
|
||||
"0": 636,
|
||||
"1": 427
|
||||
},
|
||||
"size": {
|
||||
"0": 315,
|
||||
"1": 262
|
||||
},
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null,
|
||||
"label": "model"
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null,
|
||||
"label": "positive"
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null,
|
||||
"label": "negative"
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null,
|
||||
"label": "latent_image"
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null,
|
||||
"shape": 3,
|
||||
"label": "LATENT"
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
0,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
1
|
||||
],
|
||||
"index": 1
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"external": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
227
browser_tests/assets/mixed_graph_items.json
Normal file
@@ -0,0 +1,227 @@
|
||||
{
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "KSampler",
|
||||
"pos": {
|
||||
"0": 420,
|
||||
"1": 130
|
||||
},
|
||||
"size": {
|
||||
"0": 315,
|
||||
"1": 262
|
||||
},
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
0,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": {
|
||||
"0": 820,
|
||||
"1": 130
|
||||
},
|
||||
"size": {
|
||||
"0": 315,
|
||||
"1": 262
|
||||
},
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
0,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": {
|
||||
"0": 30,
|
||||
"1": 130
|
||||
},
|
||||
"size": {
|
||||
"0": 315,
|
||||
"1": 262
|
||||
},
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
0,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [
|
||||
{
|
||||
"id": 0,
|
||||
"title": "Group",
|
||||
"bounding": [
|
||||
406.9701232910156,
|
||||
59.079444885253906,
|
||||
335,
|
||||
345.6000061035156
|
||||
],
|
||||
"color": "#3f789e",
|
||||
"font_size": 24,
|
||||
"flags": {}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Group Parent",
|
||||
"bounding": [
|
||||
796.9703979492188,
|
||||
14.796443939208984,
|
||||
355,
|
||||
399.20001220703125
|
||||
],
|
||||
"color": "#3f789e",
|
||||
"font_size": 24,
|
||||
"flags": {}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Group Child",
|
||||
"bounding": [
|
||||
806.9703979492188,
|
||||
58.39643096923828,
|
||||
335,
|
||||
345.6000061035156
|
||||
],
|
||||
"color": "#3f789e",
|
||||
"font_size": 24,
|
||||
"flags": {}
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
10
browser_tests/assets/node_template_templates.json
Normal file
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"name": "Three Nodes Template",
|
||||
"data": "{\"nodes\":[{\"id\":7,\"type\":\"CLIPTextEncode\",\"pos\":[413,389],\"size\":[425.27801513671875,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\"]},{\"id\":6,\"type\":\"CLIPTextEncode\",\"pos\":[415,186],\"size\":[422.84503173828125,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,\"]},{\"id\":4,\"type\":\"CheckpointLoaderSimple\",\"pos\":[26,474],\"size\":[315,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\"]}],\"groups\":[],\"reroutes\":[],\"links\":[{\"id\":5,\"origin_id\":4,\"origin_slot\":1,\"target_id\":7,\"target_slot\":0,\"type\":\"CLIP\"},{\"id\":3,\"origin_id\":4,\"origin_slot\":1,\"target_id\":6,\"target_slot\":0,\"type\":\"CLIP\"}]}"
|
||||
},
|
||||
{
|
||||
"name": "Completely empty template",
|
||||
"data": "{\"nodes\":[],\"groups\":[],\"reroutes\":[],\"links\":[]}"
|
||||
}
|
||||
]
|
||||
128
browser_tests/assets/old_workflow_converted_input.json
Normal file
@@ -0,0 +1,128 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "ControlNetApplyAdvanced",
|
||||
"pos": {
|
||||
"0": 449,
|
||||
"1": 204
|
||||
},
|
||||
"size": [
|
||||
340.20001220703125,
|
||||
166
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "control_net",
|
||||
"type": "CONTROL_NET",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "strength",
|
||||
"type": "FLOAT",
|
||||
"link": 1,
|
||||
"widget": {
|
||||
"name": "strength"
|
||||
}
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ControlNetApplyAdvanced"
|
||||
},
|
||||
"widgets_values": [
|
||||
1,
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "PrimitiveNode",
|
||||
"pos": {
|
||||
"0": 177,
|
||||
"1": 265
|
||||
},
|
||||
"size": [
|
||||
210,
|
||||
82
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "FLOAT",
|
||||
"type": "FLOAT",
|
||||
"links": [
|
||||
1
|
||||
],
|
||||
"widget": {
|
||||
"name": "strength"
|
||||
}
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Run widget replace on values": false
|
||||
},
|
||||
"widgets_values": [
|
||||
1,
|
||||
"fixed"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
4,
|
||||
"FLOAT"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": {
|
||||
"0": 47.541666666666515,
|
||||
"1": 186.9375
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
44
browser_tests/assets/only_optional_inputs.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"last_node_id": 11,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 11,
|
||||
"type": "DevToolsNodeWithOnlyOptionalInput",
|
||||
"pos": {
|
||||
"0": 150,
|
||||
"1": 464.2916564941406
|
||||
},
|
||||
"size": {
|
||||
"0": 400,
|
||||
"1": 200
|
||||
},
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": null,
|
||||
"shape": 7,
|
||||
"label": "clip"
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "DevToolsNodeWithOnlyOptionalInput"
|
||||
},
|
||||
"widgets_values": [
|
||||
""
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"groupNodes": {}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
104
browser_tests/assets/primitive_node_unconnected.json
Normal file
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "KSampler",
|
||||
"pos": {
|
||||
"0": 304.3653259277344,
|
||||
"1": 42.15586471557617
|
||||
},
|
||||
"size": [
|
||||
315,
|
||||
262
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null,
|
||||
"shape": 3
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
0,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"type": "PrimitiveNode",
|
||||
"pos": {
|
||||
"0": 14,
|
||||
"1": 43
|
||||
},
|
||||
"size": [
|
||||
203.1999969482422,
|
||||
40.368401303242536
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "connect to widget input",
|
||||
"type": "*",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Run widget replace on values": false
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
42
browser_tests/assets/string_input.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "DevToolsNodeWithStringInput",
|
||||
"pos": [
|
||||
15,
|
||||
48
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
58
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "DevToolsNodeWithStringInput"
|
||||
},
|
||||
"widgets_values": [
|
||||
""
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
6
browser_tests/assets/vintage_clipboard_template.json
Normal file
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{
|
||||
"name": "vintageClipboard Template",
|
||||
"data": "{\"nodes\":[{\"id\":-1,\"type\":\"CheckpointLoaderSimple\",\"pos\":[26,474],\"size\":[315,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\"]},{\"id\":-1,\"type\":\"CLIPTextEncode\",\"pos\":[415,186],\"size\":[422.84503173828125,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,\"]},{\"id\":-1,\"type\":\"CLIPTextEncode\",\"pos\":[413,389],\"size\":[425.27801513671875,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\"]}],\"links\":[[0,1,1,0,4],[0,1,2,0,4]]}"
|
||||
}
|
||||
]
|
||||
@@ -1,5 +1,5 @@
|
||||
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', () => {
|
||||
@@ -7,38 +7,34 @@ test.describe('Browser tab title', () => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test('Can display workflow name', async ({ comfyPage }) => {
|
||||
const workflowName = await comfyPage.page.evaluate(async () => {
|
||||
return window['app'].workflowManager.activeWorkflow.name
|
||||
return window['app'].extensionManager.workflow.activeWorkflow.filename
|
||||
})
|
||||
// Note: unsaved workflow name is always prepended with "*".
|
||||
expect(await comfyPage.page.title()).toBe(`*${workflowName}`)
|
||||
expect(await comfyPage.page.title()).toBe(`${workflowName} - ComfyUI`)
|
||||
})
|
||||
|
||||
test('Can display workflow name with unsaved changes', async ({
|
||||
// Failing on CI
|
||||
// Cannot reproduce locally
|
||||
test.skip('Can display workflow name with unsaved changes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const workflowName = await comfyPage.page.evaluate(async () => {
|
||||
return window['app'].workflowManager.activeWorkflow.name
|
||||
return window['app'].extensionManager.workflow.activeWorkflow.filename
|
||||
})
|
||||
// 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')
|
||||
await comfyPage.menu.topbar.saveWorkflow('test')
|
||||
expect(await comfyPage.page.title()).toBe('test - ComfyUI')
|
||||
|
||||
const textBox = comfyPage.widgetTextBox
|
||||
await textBox.fill('Hello World')
|
||||
await comfyPage.clickEmptySpace()
|
||||
expect(await comfyPage.page.title()).toBe(`*test`)
|
||||
expect(await comfyPage.page.title()).toBe(`*test - ComfyUI`)
|
||||
|
||||
// Delete the saved workflow for cleanup.
|
||||
await comfyPage.page.evaluate(async () => {
|
||||
window['app'].workflowManager.activeWorkflow.delete()
|
||||
return window['app'].extensionManager.workflow.activeWorkflow.delete()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
172
browser_tests/changeTracker.spec.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
ComfyPage,
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from './fixtures/ComfyPage'
|
||||
import type { useWorkspaceStore } from '../src/stores/workspaceStore'
|
||||
|
||||
type WorkspaceStore = ReturnType<typeof useWorkspaceStore>
|
||||
|
||||
async function beforeChange(comfyPage: ComfyPage) {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window['app'].canvas.emitBeforeChange()
|
||||
})
|
||||
}
|
||||
async function afterChange(comfyPage: ComfyPage) {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window['app'].canvas.emitAfterChange()
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Change Tracker', () => {
|
||||
test.describe('Undo/Redo', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setupWorkflowsDirectory({})
|
||||
})
|
||||
|
||||
test('Can undo multiple operations', async ({ comfyPage }) => {
|
||||
function isModified() {
|
||||
return comfyPage.page.evaluate(async () => {
|
||||
return !!(window['app'].extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow?.isModified
|
||||
})
|
||||
}
|
||||
|
||||
function getUndoQueueSize() {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const workflow = (window['app'].extensionManager as WorkspaceStore)
|
||||
.workflow.activeWorkflow
|
||||
return workflow?.changeTracker.undoQueue.length
|
||||
})
|
||||
}
|
||||
|
||||
function getRedoQueueSize() {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const workflow = (window['app'].extensionManager as WorkspaceStore)
|
||||
.workflow.activeWorkflow
|
||||
return workflow?.changeTracker.redoQueue.length
|
||||
})
|
||||
}
|
||||
expect(await getUndoQueueSize()).toBe(0)
|
||||
expect(await getRedoQueueSize()).toBe(0)
|
||||
|
||||
// Save, confirm no errors & workflow modified flag removed
|
||||
await comfyPage.menu.topbar.saveWorkflow('undo-redo-test')
|
||||
expect(await comfyPage.getToastErrorCount()).toBe(0)
|
||||
expect(await isModified()).toBe(false)
|
||||
|
||||
// TODO(huchenlei): Investigate why saving the workflow is causing the
|
||||
// undo queue to be triggered.
|
||||
expect(await getUndoQueueSize()).toBe(1)
|
||||
expect(await getRedoQueueSize()).toBe(0)
|
||||
|
||||
const node = (await comfyPage.getFirstNodeRef())!
|
||||
await node.click('collapse')
|
||||
await expect(node).toBeCollapsed()
|
||||
expect(await isModified()).toBe(true)
|
||||
expect(await getUndoQueueSize()).toBe(2)
|
||||
expect(await getRedoQueueSize()).toBe(0)
|
||||
|
||||
await comfyPage.ctrlB()
|
||||
await expect(node).toBeBypassed()
|
||||
expect(await isModified()).toBe(true)
|
||||
expect(await getUndoQueueSize()).toBe(3)
|
||||
expect(await getRedoQueueSize()).toBe(0)
|
||||
|
||||
await comfyPage.ctrlZ()
|
||||
await expect(node).not.toBeBypassed()
|
||||
expect(await isModified()).toBe(true)
|
||||
expect(await getUndoQueueSize()).toBe(2)
|
||||
expect(await getRedoQueueSize()).toBe(1)
|
||||
|
||||
await comfyPage.ctrlZ()
|
||||
await expect(node).not.toBeCollapsed()
|
||||
expect(await isModified()).toBe(false)
|
||||
expect(await getUndoQueueSize()).toBe(1)
|
||||
expect(await getRedoQueueSize()).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
test('Can group multiple change actions into a single transaction', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = (await comfyPage.getFirstNodeRef())!
|
||||
expect(node).toBeTruthy()
|
||||
await expect(node).not.toBeCollapsed()
|
||||
await expect(node).not.toBeBypassed()
|
||||
|
||||
// Make changes outside set
|
||||
// Bypass + collapse node
|
||||
await node.click('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
|
||||
await beforeChange(comfyPage)
|
||||
|
||||
await node.click('collapse')
|
||||
await comfyPage.ctrlB()
|
||||
await expect(node).toBeCollapsed()
|
||||
await expect(node).toBeBypassed()
|
||||
|
||||
// End transaction
|
||||
await afterChange(comfyPage)
|
||||
|
||||
// Ensure undo reverts both changes
|
||||
await comfyPage.ctrlZ()
|
||||
await expect(node).not.toBeBypassed()
|
||||
await expect(node).not.toBeCollapsed()
|
||||
})
|
||||
|
||||
test('Can nest multiple change transactions without adding undo steps', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = (await comfyPage.getFirstNodeRef())!
|
||||
const bypassAndPin = async () => {
|
||||
await beforeChange(comfyPage)
|
||||
await comfyPage.ctrlB()
|
||||
await expect(node).toBeBypassed()
|
||||
await comfyPage.page.keyboard.press('KeyP')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(node).toBePinned()
|
||||
await afterChange(comfyPage)
|
||||
}
|
||||
|
||||
const collapse = async () => {
|
||||
await beforeChange(comfyPage)
|
||||
await node.click('collapse', { moveMouseToEmptyArea: true })
|
||||
await expect(node).toBeCollapsed()
|
||||
await afterChange(comfyPage)
|
||||
}
|
||||
|
||||
const multipleChanges = async () => {
|
||||
await beforeChange(comfyPage)
|
||||
// Call other actions that uses begin/endChange
|
||||
await 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()
|
||||
})
|
||||
})
|
||||
@@ -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,11 +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')
|
||||
})
|
||||
|
||||
test('Can show custom color palette', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.ColorPalette', 'custom_obsidian_dark')
|
||||
await comfyPage.nextFrame()
|
||||
@@ -157,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
|
||||
}) => {
|
||||
@@ -221,7 +211,7 @@ test.describe('Node Color Adjustments', () => {
|
||||
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
|
||||
await comfyPage.setSetting('Comfy.Node.Opacity', 0.3)
|
||||
const node = await comfyPage.getFirstNodeRef()
|
||||
await node.clickContextMenuOption('Colors')
|
||||
await node?.clickContextMenuOption('Colors')
|
||||
})
|
||||
|
||||
test('should persist color adjustments when changing custom node colors', async ({
|
||||
|
||||
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 134 KiB |
49
browser_tests/commands.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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 }) => {
|
||||
@@ -15,9 +15,9 @@ test.describe('Copy Paste', () => {
|
||||
await textBox.click()
|
||||
const originalString = await textBox.inputValue()
|
||||
await textBox.selectText()
|
||||
await comfyPage.ctrlC()
|
||||
await comfyPage.ctrlV()
|
||||
await comfyPage.ctrlV()
|
||||
await comfyPage.ctrlC(null)
|
||||
await comfyPage.ctrlV(null)
|
||||
await comfyPage.ctrlV(null)
|
||||
const resultString = await textBox.inputValue()
|
||||
expect(resultString).toBe(originalString + originalString)
|
||||
})
|
||||
@@ -31,7 +31,7 @@ test.describe('Copy Paste', () => {
|
||||
y: 643
|
||||
}
|
||||
})
|
||||
await comfyPage.ctrlC()
|
||||
await comfyPage.ctrlC(null)
|
||||
// KSampler's seed
|
||||
await comfyPage.canvas.click({
|
||||
position: {
|
||||
@@ -39,7 +39,7 @@ test.describe('Copy Paste', () => {
|
||||
y: 281
|
||||
}
|
||||
})
|
||||
await comfyPage.ctrlV()
|
||||
await comfyPage.ctrlV(null)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('copied-widget-value.png')
|
||||
})
|
||||
@@ -51,14 +51,14 @@ test.describe('Copy Paste', () => {
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.clickEmptyLatentNode()
|
||||
await comfyPage.ctrlC()
|
||||
await comfyPage.ctrlC(null)
|
||||
const textBox = comfyPage.widgetTextBox
|
||||
await textBox.click()
|
||||
await textBox.inputValue()
|
||||
await textBox.selectText()
|
||||
await comfyPage.ctrlC()
|
||||
await comfyPage.ctrlV()
|
||||
await comfyPage.ctrlV()
|
||||
await comfyPage.ctrlC(null)
|
||||
await comfyPage.ctrlV(null)
|
||||
await comfyPage.ctrlV(null)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'paste-in-text-area-with-node-previously-copied.png'
|
||||
)
|
||||
@@ -69,10 +69,10 @@ test.describe('Copy Paste', () => {
|
||||
await textBox.click()
|
||||
await textBox.inputValue()
|
||||
await textBox.selectText()
|
||||
await comfyPage.ctrlC()
|
||||
await comfyPage.ctrlC(null)
|
||||
// Unfocus textbox.
|
||||
await comfyPage.page.mouse.click(10, 10)
|
||||
await comfyPage.ctrlV()
|
||||
await comfyPage.ctrlV(null)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('no-node-copied.png')
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
@@ -1,5 +1,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 ({
|
||||
@@ -13,38 +13,13 @@ test.describe('Load workflow warning', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('Does not report warning when switching between opened workflows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.loadWorkflow('missing_nodes')
|
||||
await comfyPage.page.locator('.p-dialog-close-button').click()
|
||||
|
||||
// Load default workflow
|
||||
const workflowSelector = comfyPage.page.locator(
|
||||
'button.comfyui-workflows-button'
|
||||
)
|
||||
await workflowSelector.hover()
|
||||
await workflowSelector.click()
|
||||
await comfyPage.page.locator('button[title="Load default workflow"]').click()
|
||||
|
||||
// Switch back to the missing_nodes workflow
|
||||
await workflowSelector.click()
|
||||
await comfyPage.page.locator('span:has-text("missing_nodes")').first().click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.page.locator('.comfy-missing-nodes')).not.toBeVisible()
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test('Does not report warning on undo/redo', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
|
||||
|
||||
await comfyPage.loadWorkflow('missing_nodes')
|
||||
await comfyPage.page.locator('.p-dialog-close-button').click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.closeDialog()
|
||||
|
||||
// Make a change to the graph
|
||||
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
|
||||
await comfyPage.page.waitForTimeout(256)
|
||||
await comfyPage.doubleClickCanvas()
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
|
||||
|
||||
@@ -61,6 +36,7 @@ test.describe('Execution error', () => {
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('execution_error')
|
||||
await comfyPage.queueButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Wait for the element with the .comfy-execution-error selector to be visible
|
||||
const executionError = comfyPage.page.locator('.comfy-error-report')
|
||||
@@ -70,37 +46,56 @@ test.describe('Execution error', () => {
|
||||
|
||||
test.describe('Missing models warning', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.Workflow.ShowMissingModelsWarning', true)
|
||||
await comfyPage.page.evaluate((url: string) => {
|
||||
return fetch(`${url}/api/devtools/cleanup_fake_model`)
|
||||
}, comfyPage.url)
|
||||
await comfyPage.setSetting('Comfy.Workflow.ModelDownload.AllowedSources', [
|
||||
'http://localhost:8188'
|
||||
])
|
||||
await comfyPage.setSetting('Comfy.Workflow.ModelDownload.AllowedSuffixes', [
|
||||
'.safetensors'
|
||||
])
|
||||
})
|
||||
|
||||
test('Should display a warning when missing models are found', async ({
|
||||
// Flaky test after parallelization
|
||||
// https://github.com/Comfy-Org/ComfyUI_frontend/pull/1400
|
||||
test.skip('Should display a warning when missing models are found', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setSetting('Comfy.Workflow.ShowMissingModelsWarning', true)
|
||||
|
||||
// The fake_model.safetensors is served by
|
||||
// https://github.com/Comfy-Org/ComfyUI_devtools/blob/main/__init__.py
|
||||
await comfyPage.loadWorkflow('missing_models')
|
||||
|
||||
// Wait for the element with the .comfy-missing-models selector to be visible
|
||||
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
||||
await expect(missingModelsWarning).toBeVisible()
|
||||
|
||||
// Click the download button
|
||||
const downloadButton = comfyPage.page.getByLabel('Download')
|
||||
await expect(downloadButton).toBeVisible()
|
||||
const downloadPromise = comfyPage.page.waitForEvent('download')
|
||||
await downloadButton.click()
|
||||
|
||||
// Wait for the element with the .download-complete selector to be visible
|
||||
const downloadComplete = comfyPage.page.locator('.download-complete')
|
||||
await expect(downloadComplete).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)
|
||||
await test.step('Setting should persist', async () => {
|
||||
expect(await comfyPage.getSetting('Comfy.Graph.ZoomSpeed')).toBe(maxSpeed)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
160
browser_tests/extensionAPI.spec.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
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', 'Top')
|
||||
})
|
||||
|
||||
test('Should allow registering topbar commands', async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window['app'].registerExtension({
|
||||
name: 'TestExtension1',
|
||||
commands: [
|
||||
{
|
||||
id: 'foo',
|
||||
label: 'foo-command',
|
||||
function: () => {
|
||||
window['foo'] = true
|
||||
}
|
||||
}
|
||||
],
|
||||
menuCommands: [
|
||||
{
|
||||
path: ['ext'],
|
||||
commands: ['foo']
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['ext', 'foo-command'])
|
||||
expect(await comfyPage.page.evaluate(() => window['foo'])).toBe(true)
|
||||
})
|
||||
|
||||
test('Should not allow register command defined in other extension', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.registerCommand('foo', () => alert(1))
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window['app'].registerExtension({
|
||||
name: 'TestExtension1',
|
||||
menuCommands: [
|
||||
{
|
||||
path: ['ext'],
|
||||
commands: ['foo']
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
const menuItem = comfyPage.menu.topbar.getMenuItem('ext')
|
||||
expect(await menuItem.count()).toBe(0)
|
||||
})
|
||||
|
||||
test('Should allow registering keybindings', async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const app = window['app']
|
||||
app.registerExtension({
|
||||
name: 'TestExtension1',
|
||||
commands: [
|
||||
{
|
||||
id: 'TestCommand',
|
||||
function: () => {
|
||||
window['TestCommand'] = true
|
||||
}
|
||||
}
|
||||
],
|
||||
keybindings: [
|
||||
{
|
||||
combo: { key: 'k' },
|
||||
commandId: 'TestCommand'
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.page.keyboard.press('k')
|
||||
expect(await comfyPage.page.evaluate(() => window['TestCommand'])).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Settings', () => {
|
||||
test('Should allow adding settings', async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window['app'].registerExtension({
|
||||
name: 'TestExtension1',
|
||||
settings: [
|
||||
{
|
||||
id: 'TestSetting',
|
||||
name: 'Test Setting',
|
||||
type: 'text',
|
||||
defaultValue: 'Hello, world!',
|
||||
onChange: () => {
|
||||
window['changeCount'] = (window['changeCount'] ?? 0) + 1
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
// onChange is called when the setting is first added
|
||||
expect(await comfyPage.page.evaluate(() => window['changeCount'])).toBe(1)
|
||||
expect(await comfyPage.getSetting('TestSetting')).toBe('Hello, world!')
|
||||
|
||||
await comfyPage.setSetting('TestSetting', 'Hello, universe!')
|
||||
expect(await comfyPage.getSetting('TestSetting')).toBe('Hello, universe!')
|
||||
expect(await comfyPage.page.evaluate(() => window['changeCount'])).toBe(2)
|
||||
})
|
||||
|
||||
test('Should allow setting boolean settings', async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window['app'].registerExtension({
|
||||
name: 'TestExtension1',
|
||||
settings: [
|
||||
{
|
||||
id: 'Comfy.TestSetting',
|
||||
name: 'Test Setting',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
onChange: () => {
|
||||
window['changeCount'] = (window['changeCount'] ?? 0) + 1
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
expect(await comfyPage.getSetting('Comfy.TestSetting')).toBe(false)
|
||||
expect(await comfyPage.page.evaluate(() => window['changeCount'])).toBe(1)
|
||||
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.settingDialog.toggleBooleanSetting('Comfy.TestSetting')
|
||||
expect(await comfyPage.getSetting('Comfy.TestSetting')).toBe(true)
|
||||
expect(await comfyPage.page.evaluate(() => window['changeCount'])).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('About panel', () => {
|
||||
test('Should allow adding badges', async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window['app'].registerExtension({
|
||||
name: 'TestExtension1',
|
||||
aboutPageBadges: [
|
||||
{
|
||||
label: 'Test Badge',
|
||||
url: 'https://example.com',
|
||||
icon: 'pi pi-box'
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.settingDialog.open()
|
||||
await comfyPage.settingDialog.goToAboutPanel()
|
||||
const badge = comfyPage.page.locator('.about-badge').last()
|
||||
expect(badge).toBeDefined()
|
||||
expect(await badge.textContent()).toContain('Test Badge')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,160 +1,23 @@
|
||||
import type { Page, Locator } from '@playwright/test'
|
||||
import { test as base } from '@playwright/test'
|
||||
import type { Page, Locator, APIRequestContext } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
import { test as base } from '@playwright/test'
|
||||
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'
|
||||
|
||||
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 NodeLibrarySidebarTab {
|
||||
public readonly tabId: string = 'node-library'
|
||||
constructor(public readonly page: Page) {}
|
||||
|
||||
get tabButton() {
|
||||
return this.page.locator(`.${this.tabId}-tab-button`)
|
||||
}
|
||||
|
||||
get selectedTabButton() {
|
||||
return this.page.locator(
|
||||
`.${this.tabId}-tab-button.side-bar-button-selected`
|
||||
)
|
||||
}
|
||||
|
||||
get nodeLibrarySearchBoxInput() {
|
||||
return this.page.locator('.node-lib-search-box input[type="text"]')
|
||||
}
|
||||
|
||||
get nodeLibraryTree() {
|
||||
return this.page.locator('.node-lib-tree-explorer')
|
||||
}
|
||||
|
||||
get nodePreview() {
|
||||
return this.page.locator('.node-lib-node-preview')
|
||||
}
|
||||
|
||||
get tabContainer() {
|
||||
return this.page.locator('.sidebar-content-container')
|
||||
}
|
||||
|
||||
get newFolderButton() {
|
||||
return this.tabContainer.locator('.new-folder-button')
|
||||
}
|
||||
|
||||
async open() {
|
||||
if (await this.selectedTabButton.isVisible()) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.tabButton.click()
|
||||
await this.nodeLibraryTree.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
folderSelector(folderName: string) {
|
||||
return `.p-tree-node-content:has(> .tree-explorer-node-label:has(.tree-folder .node-label:has-text("${folderName}")))`
|
||||
}
|
||||
|
||||
getFolder(folderName: string) {
|
||||
return this.page.locator(this.folderSelector(folderName))
|
||||
}
|
||||
|
||||
nodeSelector(nodeName: string) {
|
||||
return `.p-tree-node-content:has(> .tree-explorer-node-label:has(.tree-leaf .node-label:has-text("${nodeName}")))`
|
||||
}
|
||||
|
||||
getNode(nodeName: string) {
|
||||
return this.page.locator(this.nodeSelector(nodeName))
|
||||
}
|
||||
}
|
||||
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'
|
||||
import { SettingDialog } from './components/SettingDialog'
|
||||
|
||||
class ComfyMenu {
|
||||
public readonly sideToolbar: Locator
|
||||
@@ -169,25 +32,18 @@ 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)
|
||||
}
|
||||
|
||||
get workflowsTab() {
|
||||
return new WorkflowsSidebarTab(this.page)
|
||||
}
|
||||
|
||||
get topbar() {
|
||||
return new Topbar(this.page)
|
||||
}
|
||||
|
||||
async toggleTheme() {
|
||||
await this.themeToggleButton.click()
|
||||
await this.page.evaluate(() => {
|
||||
@@ -212,11 +68,16 @@ class ComfyMenu {
|
||||
}
|
||||
}
|
||||
|
||||
type FolderStructure = {
|
||||
[key: string]: FolderStructure | string
|
||||
}
|
||||
|
||||
export class ComfyPage {
|
||||
public readonly url: string
|
||||
// All canvas position operations are based on default view of canvas.
|
||||
public readonly canvas: Locator
|
||||
public readonly widgetTextBox: Locator
|
||||
public readonly contextMenu: Locator
|
||||
|
||||
// Buttons
|
||||
public readonly resetViewButton: Locator
|
||||
@@ -228,16 +89,49 @@ export class ComfyPage {
|
||||
// Components
|
||||
public readonly searchBox: ComfyNodeSearchBox
|
||||
public readonly menu: ComfyMenu
|
||||
public readonly actionbar: ComfyActionbar
|
||||
public readonly templates: ComfyTemplates
|
||||
public readonly settingDialog: SettingDialog
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
/** Worker index to test user ID */
|
||||
public readonly userIds: string[] = []
|
||||
|
||||
/** Test user ID for the current context */
|
||||
get id() {
|
||||
return this.userIds[comfyPageFixture.info().parallelIndex]
|
||||
}
|
||||
|
||||
constructor(
|
||||
public readonly page: Page,
|
||||
public readonly request: APIRequestContext
|
||||
) {
|
||||
this.url = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
this.canvas = page.locator('#graph-canvas')
|
||||
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
|
||||
this.contextMenu = page.locator('.litegraph.litecontextmenu')
|
||||
this.resetViewButton = page.getByRole('button', { name: 'Reset View' })
|
||||
this.queueButton = page.getByRole('button', { name: 'Queue Prompt' })
|
||||
this.workflowUploadInput = page.locator('#comfy-file-input')
|
||||
this.searchBox = new ComfyNodeSearchBox(page)
|
||||
this.menu = new ComfyMenu(page)
|
||||
this.actionbar = new ComfyActionbar(page)
|
||||
this.templates = new ComfyTemplates(page)
|
||||
this.settingDialog = new SettingDialog(page)
|
||||
}
|
||||
|
||||
convertLeafToContent(structure: FolderStructure): FolderStructure {
|
||||
const result: FolderStructure = {}
|
||||
|
||||
for (const [key, value] of Object.entries(structure)) {
|
||||
if (typeof value === 'string') {
|
||||
const filePath = this.assetPath(value)
|
||||
result[key] = fs.readFileSync(filePath, 'utf-8')
|
||||
} else {
|
||||
result[key] = this.convertLeafToContent(value)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async getGraphNodesCount(): Promise<number> {
|
||||
@@ -246,12 +140,120 @@ 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 getGraphSelectedItemsCount(): Promise<number | undefined> {
|
||||
return await this.page.evaluate(() => {
|
||||
return window['app']?.canvas?.selectedItems?.size
|
||||
})
|
||||
}
|
||||
|
||||
async setupWorkflowsDirectory(structure: FolderStructure) {
|
||||
const resp = await this.request.post(
|
||||
`${this.url}/api/devtools/setup_folder_structure`,
|
||||
{
|
||||
data: {
|
||||
tree_structure: this.convertLeafToContent(structure),
|
||||
base_path: `user/${this.id}/workflows`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (resp.status() !== 200) {
|
||||
throw new Error(
|
||||
`Failed to setup workflows directory: ${await resp.text()}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async setupUser(username: string) {
|
||||
const res = await this.request.get(`${this.url}/api/users`)
|
||||
if (res.status() !== 200)
|
||||
throw new Error(`Failed to retrieve users: ${await res.text()}`)
|
||||
|
||||
const apiRes = await res.json()
|
||||
const user = Object.entries(apiRes?.users ?? {}).find(
|
||||
([, name]) => name === username
|
||||
)
|
||||
const id = user?.[0]
|
||||
|
||||
return id ? id : await this.createUser(username)
|
||||
}
|
||||
|
||||
async createUser(username: string) {
|
||||
const resp = await this.request.post(`${this.url}/api/users`, {
|
||||
data: { username }
|
||||
})
|
||||
|
||||
if (resp.status() !== 200)
|
||||
throw new Error(`Failed to create user: ${await resp.text()}`)
|
||||
|
||||
return await resp.json()
|
||||
}
|
||||
|
||||
async clearNodeTemplates() {
|
||||
const resp = await this.request.delete(
|
||||
`${this.url}/api/userdata/comfy.templates.json`,
|
||||
{
|
||||
headers: { 'Comfy-User': this.id }
|
||||
}
|
||||
)
|
||||
|
||||
const status = resp.status()
|
||||
if (status !== 204 && status !== 404)
|
||||
throw new Error(`Failed to delete node templates: ${await resp.text()}`)
|
||||
}
|
||||
|
||||
async setNodeTemplates(fileName: string) {
|
||||
const path = this.assetPath(fileName)
|
||||
const data = fs.readFileSync(path, 'utf-8')
|
||||
|
||||
const resp = await this.request.post(
|
||||
`${this.url}/api/userdata/comfy.templates.json`,
|
||||
{
|
||||
headers: {
|
||||
'Comfy-User': this.id,
|
||||
overwrite: 'true',
|
||||
full_info: 'true'
|
||||
},
|
||||
data
|
||||
}
|
||||
)
|
||||
|
||||
if (resp.status() !== 200)
|
||||
throw new Error(`Failed to upload node templates: ${await resp.text()}`)
|
||||
}
|
||||
|
||||
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(() => {
|
||||
await this.page.evaluate((id) => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
localStorage.setItem('Comfy.userId', id)
|
||||
}, this.id)
|
||||
await this.goto()
|
||||
|
||||
// Unify font for consistent screenshots.
|
||||
await this.page.addStyleTag({
|
||||
url: 'https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap'
|
||||
@@ -267,32 +269,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()
|
||||
// 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 }
|
||||
)
|
||||
@@ -300,7 +351,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)
|
||||
}
|
||||
|
||||
@@ -339,6 +390,16 @@ export class ComfyPage {
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async getToastErrorCount() {
|
||||
return await this.page
|
||||
.locator('.p-toast-message.p-toast-message-error')
|
||||
.count()
|
||||
}
|
||||
|
||||
async getVisibleToastCount() {
|
||||
return await this.page.locator('.p-toast:visible').count()
|
||||
}
|
||||
|
||||
async clickTextEncodeNode1() {
|
||||
await this.canvas.click({
|
||||
position: {
|
||||
@@ -379,11 +440,17 @@ export class ComfyPage {
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async dragAndDrop(source: Position, target: Position) {
|
||||
async dragAndDrop(
|
||||
source: Position,
|
||||
target: Position,
|
||||
modifierKey?: 'ControlOrMeta' | 'Control' | 'Alt' | 'Shift'
|
||||
) {
|
||||
if (modifierKey) await this.page.keyboard.down(modifierKey)
|
||||
await this.page.mouse.move(source.x, source.y)
|
||||
await this.page.mouse.down()
|
||||
await this.page.mouse.move(target.x, target.y)
|
||||
await this.page.mouse.up()
|
||||
if (modifierKey) await this.page.keyboard.up(modifierKey)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
@@ -485,9 +552,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()
|
||||
}
|
||||
|
||||
@@ -530,12 +598,15 @@ export class ComfyPage {
|
||||
}
|
||||
|
||||
async rightClickCanvas() {
|
||||
await this.page.mouse.click(10, 10, { button: 'right' })
|
||||
await this.nextFrame()
|
||||
await this.canvas.click({
|
||||
position: { x: 10, y: 10 },
|
||||
button: 'right'
|
||||
})
|
||||
await expect(this.contextMenu).toBeVisible()
|
||||
}
|
||||
|
||||
async doubleClickCanvas() {
|
||||
await this.page.mouse.dblclick(10, 10)
|
||||
await this.page.mouse.dblclick(10, 10, { delay: 5 })
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
@@ -546,7 +617,7 @@ export class ComfyPage {
|
||||
y: 625
|
||||
}
|
||||
})
|
||||
this.page.mouse.move(10, 10)
|
||||
await this.page.mouse.move(10, 10)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
@@ -558,10 +629,14 @@ export class ComfyPage {
|
||||
},
|
||||
button: 'right'
|
||||
})
|
||||
this.page.mouse.move(10, 10)
|
||||
await this.page.mouse.move(10, 10)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async clickContextMenuItem(name: string): Promise<void> {
|
||||
await this.page.getByRole('menuitem', { name }).click()
|
||||
}
|
||||
|
||||
async select2Nodes() {
|
||||
// Select 2 CLIP nodes.
|
||||
await this.page.keyboard.down('Control')
|
||||
@@ -571,46 +646,42 @@ export class ComfyPage {
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async ctrlC() {
|
||||
await this.page.keyboard.down('Control')
|
||||
await this.page.keyboard.press('KeyC')
|
||||
await this.page.keyboard.up('Control')
|
||||
async ctrlSend(keyToPress: string, locator: Locator | null = this.canvas) {
|
||||
const target = locator ?? this.page.keyboard
|
||||
await target.press(`Control+${keyToPress}`)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async ctrlV() {
|
||||
await this.page.keyboard.down('Control')
|
||||
await this.page.keyboard.press('KeyV')
|
||||
await this.page.keyboard.up('Control')
|
||||
await this.nextFrame()
|
||||
async ctrlA(locator?: Locator | null) {
|
||||
await this.ctrlSend('KeyA', locator)
|
||||
}
|
||||
|
||||
async ctrlZ() {
|
||||
await this.page.keyboard.down('Control')
|
||||
await this.page.keyboard.press('KeyZ')
|
||||
await this.page.keyboard.up('Control')
|
||||
await this.nextFrame()
|
||||
async ctrlB(locator?: Locator | null) {
|
||||
await this.ctrlSend('KeyB', locator)
|
||||
}
|
||||
|
||||
async ctrlY() {
|
||||
await this.page.keyboard.down('Control')
|
||||
await this.page.keyboard.press('KeyY')
|
||||
await this.page.keyboard.up('Control')
|
||||
await this.nextFrame()
|
||||
async ctrlC(locator?: Locator | null) {
|
||||
await this.ctrlSend('KeyC', locator)
|
||||
}
|
||||
|
||||
async ctrlArrowUp() {
|
||||
await this.page.keyboard.down('Control')
|
||||
await this.page.keyboard.press('ArrowUp')
|
||||
await this.page.keyboard.up('Control')
|
||||
await this.nextFrame()
|
||||
async ctrlV(locator?: Locator | null) {
|
||||
await this.ctrlSend('KeyV', locator)
|
||||
}
|
||||
|
||||
async ctrlArrowDown() {
|
||||
await this.page.keyboard.down('Control')
|
||||
await this.page.keyboard.press('ArrowDown')
|
||||
await this.page.keyboard.up('Control')
|
||||
await this.nextFrame()
|
||||
async ctrlZ(locator?: Locator | null) {
|
||||
await this.ctrlSend('KeyZ', locator)
|
||||
}
|
||||
|
||||
async ctrlY(locator?: Locator | null) {
|
||||
await this.ctrlSend('KeyY', locator)
|
||||
}
|
||||
|
||||
async ctrlArrowUp(locator?: Locator | null) {
|
||||
await this.ctrlSend('ArrowUp', locator)
|
||||
}
|
||||
|
||||
async ctrlArrowDown(locator?: Locator | null) {
|
||||
await this.ctrlSend('ArrowDown', locator)
|
||||
}
|
||||
|
||||
async closeMenu() {
|
||||
@@ -618,6 +689,11 @@ export class ComfyPage {
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async closeDialog() {
|
||||
await this.page.locator('.p-dialog-close-button').click()
|
||||
await expect(this.page.locator('.p-dialog')).toBeHidden()
|
||||
}
|
||||
|
||||
async resizeNode(
|
||||
nodePos: Position,
|
||||
nodeSize: Size,
|
||||
@@ -633,10 +709,14 @@ export class ComfyPage {
|
||||
x: nodePos.x + nodeSize.width * ratioX,
|
||||
y: nodePos.y + nodeSize.height * ratioY
|
||||
}
|
||||
await this.dragAndDrop(bottomRight, target)
|
||||
// -1 to be inside the node. -2 because nodes currently get an arbitrary +1 to width.
|
||||
await this.dragAndDrop(
|
||||
{ x: bottomRight.x - 2, y: bottomRight.y - 1 },
|
||||
target
|
||||
)
|
||||
await this.nextFrame()
|
||||
if (revertAfter) {
|
||||
await this.dragAndDrop(target, bottomRight)
|
||||
await this.dragAndDrop({ x: target.x - 2, y: target.y - 1 }, bottomRight)
|
||||
await this.nextFrame()
|
||||
}
|
||||
}
|
||||
@@ -647,14 +727,20 @@ export class ComfyPage {
|
||||
revertAfter: boolean = false
|
||||
) {
|
||||
const ksamplerPos = {
|
||||
x: 864,
|
||||
y: 157
|
||||
x: 863,
|
||||
y: 156
|
||||
}
|
||||
const ksamplerSize = {
|
||||
width: 315,
|
||||
height: 292
|
||||
}
|
||||
this.resizeNode(ksamplerPos, ksamplerSize, percentX, percentY, revertAfter)
|
||||
return this.resizeNode(
|
||||
ksamplerPos,
|
||||
ksamplerSize,
|
||||
percentX,
|
||||
percentY,
|
||||
revertAfter
|
||||
)
|
||||
}
|
||||
|
||||
async resizeLoadCheckpointNode(
|
||||
@@ -663,14 +749,14 @@ export class ComfyPage {
|
||||
revertAfter: boolean = false
|
||||
) {
|
||||
const loadCheckpointPos = {
|
||||
x: 25,
|
||||
y: 440
|
||||
x: 26,
|
||||
y: 444
|
||||
}
|
||||
const loadCheckpointSize = {
|
||||
width: 320,
|
||||
height: 120
|
||||
width: 315,
|
||||
height: 127
|
||||
}
|
||||
this.resizeNode(
|
||||
return this.resizeNode(
|
||||
loadCheckpointPos,
|
||||
loadCheckpointSize,
|
||||
percentX,
|
||||
@@ -685,14 +771,14 @@ export class ComfyPage {
|
||||
revertAfter: boolean = false
|
||||
) {
|
||||
const emptyLatentPos = {
|
||||
x: 475,
|
||||
y: 580
|
||||
x: 473,
|
||||
y: 579
|
||||
}
|
||||
const emptyLatentSize = {
|
||||
width: 303,
|
||||
height: 132
|
||||
width: 315,
|
||||
height: 136
|
||||
}
|
||||
this.resizeNode(
|
||||
return this.resizeNode(
|
||||
emptyLatentPos,
|
||||
emptyLatentSize,
|
||||
percentX,
|
||||
@@ -719,13 +805,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(() => {
|
||||
@@ -734,166 +822,65 @@ 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 }, use) => {
|
||||
const comfyPage = new ComfyPage(page)
|
||||
comfyPage: async ({ page, request }, use) => {
|
||||
const comfyPage = new ComfyPage(page, request)
|
||||
|
||||
const { parallelIndex } = comfyPageFixture.info()
|
||||
const username = `playwright-test-${parallelIndex}`
|
||||
const userId = await comfyPage.setupUser(username)
|
||||
comfyPage.userIds[parallelIndex] = userId
|
||||
|
||||
await comfyPage.setupSettings({
|
||||
'Comfy.UseNewMenu': 'Disabled',
|
||||
// Hide canvas menu/info by default.
|
||||
'Comfy.Graph.CanvasInfo': false,
|
||||
'Comfy.Graph.CanvasMenu': false,
|
||||
// Hide all badges by default.
|
||||
'Comfy.NodeBadge.NodeIdBadgeMode': NodeBadgeMode.None,
|
||||
'Comfy.NodeBadge.NodeSourceBadgeMode': NodeBadgeMode.None,
|
||||
// Disable tooltips by default to avoid flakiness.
|
||||
'Comfy.EnableTooltips': false,
|
||||
'Comfy.userId': userId
|
||||
})
|
||||
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')
|
||||
})
|
||||
79
browser_tests/fixtures/components/ComfyNodeSearchBox.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
40
browser_tests/fixtures/components/SettingDialog.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Page } from '@playwright/test'
|
||||
|
||||
export class SettingDialog {
|
||||
constructor(public readonly page: Page) {}
|
||||
|
||||
async open() {
|
||||
const button = this.page.locator('button.comfy-settings-btn:visible')
|
||||
await button.click()
|
||||
await this.page.waitForSelector('div.settings-container')
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the value of a text setting
|
||||
* @param id - The id of the setting
|
||||
* @param value - The value to set
|
||||
*/
|
||||
async setStringSetting(id: string, value: string) {
|
||||
const settingInputDiv = this.page.locator(
|
||||
`div.settings-container div[id="${id}"]`
|
||||
)
|
||||
await settingInputDiv.locator('input').fill(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the value of a boolean setting
|
||||
* @param id - The id of the setting
|
||||
*/
|
||||
async toggleBooleanSetting(id: string) {
|
||||
const settingInputDiv = this.page.locator(
|
||||
`div.settings-container div[id="${id}"]`
|
||||
)
|
||||
await settingInputDiv.locator('input').click()
|
||||
}
|
||||
|
||||
async goToAboutPanel() {
|
||||
const aboutButton = this.page.locator('li[aria-label="About"]')
|
||||
await aboutButton.click()
|
||||
await this.page.waitForSelector('div.about-container')
|
||||
}
|
||||
}
|
||||
139
browser_tests/fixtures/components/SidebarTab.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
|
||||
class SidebarTab {
|
||||
constructor(
|
||||
public readonly page: Page,
|
||||
public readonly tabId: string
|
||||
) {}
|
||||
|
||||
get tabButton() {
|
||||
return this.page.locator(`.${this.tabId}-tab-button`)
|
||||
}
|
||||
|
||||
get selectedTabButton() {
|
||||
return this.page.locator(
|
||||
`.${this.tabId}-tab-button.side-bar-button-selected`
|
||||
)
|
||||
}
|
||||
|
||||
async open() {
|
||||
if (await this.selectedTabButton.isVisible()) {
|
||||
return
|
||||
}
|
||||
await this.tabButton.click()
|
||||
}
|
||||
}
|
||||
|
||||
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.getOpenedItem(workflowName)
|
||||
await workflowLocator.click()
|
||||
await this.page.waitForTimeout(300)
|
||||
}
|
||||
|
||||
getOpenedItem(name: string) {
|
||||
return this.page.locator('.comfyui-workflows-open .node-label', {
|
||||
hasText: name
|
||||
})
|
||||
}
|
||||
|
||||
getPersistedItem(name: string) {
|
||||
return this.page.locator('.comfyui-workflows-browse .node-label', {
|
||||
hasText: name
|
||||
})
|
||||
}
|
||||
|
||||
async renameWorkflow(locator: Locator, newName: string) {
|
||||
await locator.click({ button: 'right' })
|
||||
await this.page
|
||||
.locator('.p-contextmenu-item-content', { hasText: 'Rename' })
|
||||
.click()
|
||||
await this.page.keyboard.type(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await this.page.waitForTimeout(300)
|
||||
}
|
||||
}
|
||||
85
browser_tests/fixtures/components/Topbar.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
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()
|
||||
}
|
||||
|
||||
getMenuItem(itemLabel: string): Locator {
|
||||
return this.page.locator(`.p-menubar-item-label:text-is("${itemLabel}")`)
|
||||
}
|
||||
|
||||
getWorkflowTab(tabName: string): Locator {
|
||||
return this.page
|
||||
.locator(`.workflow-tabs .workflow-label:has-text("${tabName}")`)
|
||||
.locator('..')
|
||||
}
|
||||
|
||||
async closeWorkflowTab(tabName: string) {
|
||||
const tab = this.getWorkflowTab(tabName)
|
||||
await tab.locator('.close-button').click({ force: true })
|
||||
}
|
||||
|
||||
getSaveDialog(): Locator {
|
||||
return this.page.locator('.p-dialog-content input')
|
||||
}
|
||||
|
||||
saveWorkflow(workflowName: string): Promise<void> {
|
||||
return this._saveWorkflow(workflowName, 'Save')
|
||||
}
|
||||
|
||||
saveWorkflowAs(workflowName: string): Promise<void> {
|
||||
return this._saveWorkflow(workflowName, 'Save As')
|
||||
}
|
||||
|
||||
async _saveWorkflow(workflowName: string, command: 'Save' | 'Save As') {
|
||||
await this.triggerTopbarCommand(['Workflow', command])
|
||||
await this.getSaveDialog().fill(workflowName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
|
||||
// Wait for workflow service to finish saving
|
||||
await this.page.waitForFunction(
|
||||
() => !window['app'].extensionManager.workflow.isBusy,
|
||||
undefined,
|
||||
{ timeout: 3000 }
|
||||
)
|
||||
// Wait for the dialog to close.
|
||||
await this.getSaveDialog().waitFor({ state: 'hidden', timeout: 500 })
|
||||
}
|
||||
|
||||
async triggerTopbarCommand(path: string[]) {
|
||||
if (path.length < 2) {
|
||||
throw new Error('Path is too short')
|
||||
}
|
||||
|
||||
const tabName = path[0]
|
||||
const topLevelMenu = this.page.locator(
|
||||
`.top-menubar .p-menubar-item-label:text-is("${tabName}")`
|
||||
)
|
||||
await topLevelMenu.waitFor({ state: 'visible' })
|
||||
await topLevelMenu.click()
|
||||
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
const commandName = path[i]
|
||||
const menuItem = this.page
|
||||
.locator(
|
||||
`.top-menubar .p-menubar-submenu .p-menubar-item:has-text("${commandName}")`
|
||||
)
|
||||
.first()
|
||||
await menuItem.waitFor({ state: 'visible' })
|
||||
await menuItem.hover()
|
||||
|
||||
if (i === path.length - 1) {
|
||||
await menuItem.click()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
browser_tests/fixtures/types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface Position {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface Size {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
257
browser_tests/fixtures/utils/litegraphUtils.ts
Normal 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')
|
||||
)
|
||||
}
|
||||
}
|
||||
51
browser_tests/fixtures/ws.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
export const webSocketFixture = base.extend<{
|
||||
ws: { trigger(data: any, url?: string): Promise<void> }
|
||||
}>({
|
||||
ws: [
|
||||
async ({ page }, use) => {
|
||||
// Each time a page loads, to catch navigations
|
||||
page.on('load', async () => {
|
||||
await page.evaluate(function () {
|
||||
// Create a wrapper for WebSocket that stores them globally
|
||||
// so we can look it up to trigger messages
|
||||
const store: Record<string, WebSocket> = ((window as any).__ws__ = {})
|
||||
window.WebSocket = class extends window.WebSocket {
|
||||
constructor() {
|
||||
// @ts-expect-error
|
||||
super(...arguments)
|
||||
store[this.url] = this
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await use({
|
||||
async trigger(data, url) {
|
||||
// Trigger a websocket event on the page
|
||||
await page.evaluate(
|
||||
function ([data, url]) {
|
||||
if (!url) {
|
||||
// If no URL specified, use page URL
|
||||
const u = new URL(window.location.toString())
|
||||
u.protocol = 'ws:'
|
||||
u.pathname = '/'
|
||||
url = u.toString() + 'ws'
|
||||
}
|
||||
const ws: WebSocket = (window as any).__ws__[url]
|
||||
ws.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data
|
||||
})
|
||||
)
|
||||
},
|
||||
[JSON.stringify(data), url]
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
// We need this to run automatically as the first thing so it adds handlers as soon as the page loads
|
||||
{ auto: true }
|
||||
]
|
||||
})
|
||||
20
browser_tests/globalSetup.ts
Normal 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'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
12
browser_tests/globalTeardown.ts
Normal 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'])
|
||||
}
|
||||
}
|
||||
38
browser_tests/graphCanvasMenu.spec.ts
Normal 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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 99 KiB |
@@ -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'
|
||||
@@ -29,8 +26,8 @@ test.describe('Group Node', () => {
|
||||
const initialNodeCount = await comfyPage.getGraphNodesCount()
|
||||
|
||||
// Add group node from node library sidebar
|
||||
await libraryTab.getFolder(groupNodeCategory).first().click()
|
||||
await libraryTab.getNode(groupNodeName).first().click()
|
||||
await libraryTab.getFolder(groupNodeCategory).click()
|
||||
await libraryTab.getNode(groupNodeName).click()
|
||||
|
||||
// Verify the node is added to the canvas
|
||||
expect(await comfyPage.getGraphNodesCount()).toBe(initialNodeCount + 1)
|
||||
@@ -41,7 +38,6 @@ test.describe('Group Node', () => {
|
||||
await libraryTab
|
||||
.getNode(groupNodeName)
|
||||
.locator('.bookmark-button')
|
||||
.first()
|
||||
.click()
|
||||
|
||||
// Verify the node is added to the bookmarks tab
|
||||
@@ -62,7 +58,6 @@ test.describe('Group Node', () => {
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
).toHaveLength(0)
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [])
|
||||
})
|
||||
|
||||
test('Displays preview on bookmark hover', async ({ comfyPage }) => {
|
||||
@@ -70,7 +65,6 @@ test.describe('Group Node', () => {
|
||||
await libraryTab
|
||||
.getNode(groupNodeName)
|
||||
.locator('.bookmark-button')
|
||||
.first()
|
||||
.click()
|
||||
await comfyPage.page.hover('.p-tree-node-label.tree-explorer-node-label')
|
||||
expect(await comfyPage.page.isVisible('.node-lib-node-preview')).toBe(
|
||||
@@ -81,7 +75,6 @@ test.describe('Group Node', () => {
|
||||
.locator('.bookmark-button')
|
||||
.first()
|
||||
.click()
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -97,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
|
||||
@@ -139,4 +133,127 @@ test.describe('Group Node', () => {
|
||||
// Ensure the link is still present
|
||||
expect(await input.getLinkCount()).toBe(1)
|
||||
})
|
||||
|
||||
test('Loads from a workflow using the legacy path separator ("/")', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('legacy_group_node')
|
||||
expect(await comfyPage.getGraphNodesCount()).toBe(1)
|
||||
await 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 78 KiB |
49
browser_tests/helpers/actionbar.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { Page, Locator } from '@playwright/test'
|
||||
import type { AutoQueueMode } from '../../src/stores/queueStore'
|
||||
|
||||
export class ComfyActionbar {
|
||||
public readonly root: Locator
|
||||
public readonly queueButton: ComfyQueueButton
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
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 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.actionbar.page)
|
||||
}
|
||||
}
|
||||
|
||||
class ComfyQueueButtonOptions {
|
||||
constructor(public readonly page: Page) {}
|
||||
|
||||
public async setMode(mode: AutoQueueMode) {
|
||||
await this.page.evaluate((mode) => {
|
||||
window['app'].extensionManager.queueSettings.mode = mode
|
||||
}, mode)
|
||||
}
|
||||
|
||||
public async getMode() {
|
||||
return await this.page.evaluate(() => {
|
||||
return window['app'].extensionManager.queueSettings.mode
|
||||
})
|
||||
}
|
||||
}
|
||||
12
browser_tests/helpers/templates.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
export class ComfyTemplates {
|
||||
readonly content: Locator
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
this.content = page.getByTestId('template-workflows-content')
|
||||
}
|
||||
|
||||
async loadTemplate(id: string) {
|
||||
await this.content.getByTestId(`template-workflow-${id}`).click()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,26 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from './ComfyPage'
|
||||
import { comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||
|
||||
test.describe('Item Interaction', () => {
|
||||
test('Can select/delete all items', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('mixed_graph_items')
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('selected-all.png')
|
||||
await comfyPage.canvas.press('Delete')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('deleted-all.png')
|
||||
})
|
||||
|
||||
test('Can pin/unpin items with keyboard shortcut', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('mixed_graph_items')
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
await comfyPage.canvas.press('KeyP')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('pinned-all.png')
|
||||
await comfyPage.canvas.press('KeyP')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('unpinned-all.png')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Node Interaction', () => {
|
||||
test('Can enter prompt', async ({ comfyPage }) => {
|
||||
@@ -11,12 +32,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'] as const
|
||||
|
||||
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 +94,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 +137,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 }) => {
|
||||
@@ -205,7 +285,8 @@ test.describe('Node Interaction', () => {
|
||||
position: {
|
||||
x: 50,
|
||||
y: 10
|
||||
}
|
||||
},
|
||||
delay: 5
|
||||
})
|
||||
await comfyPage.page.keyboard.type('Hello World')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
@@ -220,7 +301,8 @@ test.describe('Node Interaction', () => {
|
||||
position: {
|
||||
x: 50,
|
||||
y: 50
|
||||
}
|
||||
},
|
||||
delay: 5
|
||||
})
|
||||
expect(await comfyPage.page.locator('.node-title-editor').count()).toBe(0)
|
||||
})
|
||||
@@ -251,6 +333,18 @@ test.describe('Node Interaction', () => {
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('nodes-unpinned.png')
|
||||
})
|
||||
|
||||
test('Can bypass/unbypass nodes with keyboard shortcut', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.select2Nodes()
|
||||
await comfyPage.canvas.press('Control+b')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('nodes-bypassed.png')
|
||||
await comfyPage.canvas.press('Control+b')
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('nodes-unbypassed.png')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Group Interaction', () => {
|
||||
@@ -260,7 +354,8 @@ test.describe('Group Interaction', () => {
|
||||
position: {
|
||||
x: 50,
|
||||
y: 10
|
||||
}
|
||||
},
|
||||
delay: 5
|
||||
})
|
||||
await comfyPage.page.keyboard.type('Hello World')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
@@ -335,6 +430,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 })
|
||||
@@ -366,7 +507,7 @@ test.describe('Widget Interaction', () => {
|
||||
await expect(textBox).toHaveValue('')
|
||||
await textBox.fill('Hello World')
|
||||
await expect(textBox).toHaveValue('Hello World')
|
||||
await comfyPage.ctrlZ()
|
||||
await comfyPage.ctrlZ(null)
|
||||
await expect(textBox).toHaveValue('')
|
||||
})
|
||||
|
||||
@@ -377,9 +518,9 @@ test.describe('Widget Interaction', () => {
|
||||
await textBox.fill('1girl')
|
||||
await expect(textBox).toHaveValue('1girl')
|
||||
await textBox.selectText()
|
||||
await comfyPage.ctrlArrowUp()
|
||||
await comfyPage.ctrlArrowUp(null)
|
||||
await expect(textBox).toHaveValue('(1girl:1.05)')
|
||||
await comfyPage.ctrlZ()
|
||||
await comfyPage.ctrlZ(null)
|
||||
await expect(textBox).toHaveValue('1girl')
|
||||
})
|
||||
})
|
||||
@@ -389,4 +530,27 @@ test.describe('Load workflow', () => {
|
||||
await comfyPage.loadWorkflow('string_node_id')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('string_node_id.png')
|
||||
})
|
||||
|
||||
test('Can load workflow with ("STRING",) input node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('string_input')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('string_input.png')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Load duplicate workflow', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('A workflow can be loaded multiple times in a row', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('single_ksampler')
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
await comfyPage.menu.workflowsTab.newBlankWorkflowButton.click()
|
||||
await comfyPage.loadWorkflow('single_ksampler')
|
||||
expect(await comfyPage.getGraphNodesCount()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |