mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-15 09:57:33 +00:00
Compare commits
14 Commits
devtools/r
...
js/async_n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ada4f1d533 | ||
|
|
aae20d9417 | ||
|
|
9f4abbc3af | ||
|
|
0887bb6654 | ||
|
|
868e047272 | ||
|
|
abb82e5fd1 | ||
|
|
db70265e16 | ||
|
|
c8137f7f98 | ||
|
|
132a9dbb5f | ||
|
|
f076a1c422 | ||
|
|
f6c65d3fe7 | ||
|
|
c8371d6089 | ||
|
|
a70a81c9ae | ||
|
|
aa5fa81824 |
4
.github/workflows/test-ui.yaml
vendored
4
.github/workflows/test-ui.yaml
vendored
@@ -88,7 +88,7 @@ jobs:
|
||||
|
||||
- name: Start ComfyUI server
|
||||
run: |
|
||||
python main.py --cpu --multi-user --front-end-root ../ComfyUI_frontend/dist &
|
||||
python main.py --cpu --multi-user --cache-none --front-end-root ../ComfyUI_frontend/dist &
|
||||
wait-for-it --service 127.0.0.1:8188 -t 600
|
||||
working-directory: ComfyUI
|
||||
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
- name: Run Playwright tests (${{ matrix.browser }})
|
||||
run: npx playwright test --project=${{ matrix.browser }}
|
||||
run: npx playwright test --project=${{ matrix.browser }} --workers=1
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -26,6 +26,12 @@ The `.env` file will not exist until you create it yourself.
|
||||
|
||||
A template with helpful information can be found in `.env_example`.
|
||||
|
||||
### Running ComfyUI Backend
|
||||
When running browser tests, ComfyUI must be started with the `--cache-none` argument to disable execution caching:
|
||||
```bash
|
||||
python main.py --cache-none
|
||||
```
|
||||
|
||||
### Multiple Tests
|
||||
If you are running Playwright tests in parallel or running the same test multiple times, the flag `--multi-user` must be added to the main ComfyUI process.
|
||||
|
||||
|
||||
293
browser_tests/assets/execution/nested-subgraph-test.json
Normal file
293
browser_tests/assets/execution/nested-subgraph-test.json
Normal file
@@ -0,0 +1,293 @@
|
||||
{
|
||||
"id": "test-subgraph-workflow",
|
||||
"revision": 0,
|
||||
"last_node_id": 12,
|
||||
"last_link_id": 10,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 200],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": [1], "slot_index": 0 },
|
||||
{ "name": "MASK", "type": "MASK", "links": null, "slot_index": 1 }
|
||||
],
|
||||
"properties": { "Node name for S&R": "LoadImage" },
|
||||
"widgets_values": ["example.png", "image"]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "test-subgraph-1",
|
||||
"pos": [400, 200],
|
||||
"size": [200, 80],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "link": 1 }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": [9] }
|
||||
],
|
||||
"title": "Test Subgraph",
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "SaveImage",
|
||||
"pos": [700, 200],
|
||||
"size": [315, 270],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "images", "type": "IMAGE", "link": 9 }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 0, 10, 0, "IMAGE"],
|
||||
[9, 10, 0, 11, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "test-subgraph-1",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 7,
|
||||
"lastLinkId": 6,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Test Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [-154, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [800, 200, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "input-1",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [1],
|
||||
"localized_name": "IMAGE",
|
||||
"pos": {
|
||||
"0": -134,
|
||||
"1": 220
|
||||
}
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "output-1",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [5],
|
||||
"localized_name": "IMAGE",
|
||||
"pos": {
|
||||
"0": 820,
|
||||
"1": 220
|
||||
}
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 3,
|
||||
"type": "TestSleep",
|
||||
"pos": [100, 200],
|
||||
"size": [210, 86],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "value", "type": "IMAGE", "link": 1 }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": [2], "slot_index": 0 }
|
||||
],
|
||||
"properties": { "Node name for S&R": "TestSleep" },
|
||||
"widgets_values": [2.0]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "test-subgraph-2",
|
||||
"pos": [350, 200],
|
||||
"size": [200, 80],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "link": 2 }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": [5] }
|
||||
],
|
||||
"title": "Nested Test Subgraph",
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 3,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 3,
|
||||
"origin_slot": 0,
|
||||
"target_id": 5,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"origin_id": 5,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
},
|
||||
{
|
||||
"id": "test-subgraph-2",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 7,
|
||||
"lastLinkId": 4,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Nested Test Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [-154, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [600, 200, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "input-1",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [1],
|
||||
"localized_name": "IMAGE",
|
||||
"pos": {
|
||||
"0": -134,
|
||||
"1": 220
|
||||
}
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "output-1",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [4],
|
||||
"localized_name": "IMAGE",
|
||||
"pos": {
|
||||
"0": 620,
|
||||
"1": 220
|
||||
}
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 6,
|
||||
"type": "TestSleep",
|
||||
"pos": [100, 150],
|
||||
"size": [210, 86],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "value", "type": "IMAGE", "link": 1 }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": [2], "slot_index": 0 }
|
||||
],
|
||||
"properties": { "Node name for S&R": "TestSleep" },
|
||||
"widgets_values": [2.0]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "TestAsyncProgressNode",
|
||||
"pos": [350, 150],
|
||||
"size": [210, 126],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "value", "type": "IMAGE", "link": 2 }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": [4], "slot_index": 0 }
|
||||
],
|
||||
"properties": { "Node name for S&R": "TestAsyncProgressNode" },
|
||||
"widgets_values": [3.0, 10]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 6,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 6,
|
||||
"origin_slot": 0,
|
||||
"target_id": 7,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": 7,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
139
browser_tests/assets/execution/parallel_async_nodes.json
Normal file
139
browser_tests/assets/execution/parallel_async_nodes.json
Normal file
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"last_node_id": 12,
|
||||
"last_link_id": 10,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "LoadImage",
|
||||
"pos": [26, 200],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": [1, 2, 3], "slot_index": 0 },
|
||||
{ "name": "MASK", "type": "MASK", "links": null, "slot_index": 1 }
|
||||
],
|
||||
"properties": { "Node name for S&R": "LoadImage" },
|
||||
"widgets_values": ["example.png", "image"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "TestSleep",
|
||||
"pos": [400, 100],
|
||||
"size": [210, 86],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "value", "type": "IMAGE", "link": 1 }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": [4], "slot_index": 0 }
|
||||
],
|
||||
"properties": { "Node name for S&R": "TestSleep" },
|
||||
"widgets_values": [2.0]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "TestSleep",
|
||||
"pos": [400, 250],
|
||||
"size": [210, 86],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "value", "type": "IMAGE", "link": 2 }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": [5], "slot_index": 0 }
|
||||
],
|
||||
"properties": { "Node name for S&R": "TestSleep" },
|
||||
"widgets_values": [2.5]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "TestAsyncProgressNode",
|
||||
"pos": [400, 400],
|
||||
"size": [210, 126],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "value", "type": "IMAGE", "link": 3 }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": [6], "slot_index": 0 }
|
||||
],
|
||||
"properties": { "Node name for S&R": "TestAsyncProgressNode" },
|
||||
"widgets_values": [3.0, 10]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "TestVariadicAverage",
|
||||
"pos": [700, 200],
|
||||
"size": [210, 106],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "input1", "type": "IMAGE", "link": 4 },
|
||||
{ "name": "input2", "type": "IMAGE", "link": 5 },
|
||||
{ "name": "input3", "type": "IMAGE", "link": 6 }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": [7], "slot_index": 0 }
|
||||
],
|
||||
"properties": { "Node name for S&R": "TestVariadicAverage" }
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "SaveImage",
|
||||
"pos": [1000, 200],
|
||||
"size": [315, 270],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "images", "type": "IMAGE", "link": 7 }
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [700, 400],
|
||||
"size": [210, 54],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"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": ["test"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 0, 2, 0, "IMAGE"],
|
||||
[2, 1, 0, 3, 0, "IMAGE"],
|
||||
[3, 1, 0, 4, 0, "IMAGE"],
|
||||
[4, 2, 0, 5, 0, "IMAGE"],
|
||||
[5, 3, 0, 5, 1, "IMAGE"],
|
||||
[6, 4, 0, 5, 2, "IMAGE"],
|
||||
[7, 5, 0, 6, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
193
browser_tests/assets/execution/parallel_ksamplers.json
Normal file
193
browser_tests/assets/execution/parallel_ksamplers.json
Normal file
@@ -0,0 +1,193 @@
|
||||
{
|
||||
"last_node_id": 12,
|
||||
"last_link_id": 13,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [26, 474],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": [1, 10], "slot_index": 0 },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": [2, 3], "slot_index": 1 },
|
||||
{ "name": "VAE", "type": "VAE", "links": [8, 11], "slot_index": 2 }
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [415, 186],
|
||||
"size": [422.84503173828125, 164.31304931640625],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "clip", "type": "CLIP", "link": 2 }],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [4, 12],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [413, 389],
|
||||
"size": [425.27801513671875, 180.6060791015625],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "clip", "type": "CLIP", "link": 3 }],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [5, 13],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["text, watermark"]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [473, 609],
|
||||
"size": [315, 106],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [6, 14], "slot_index": 0 }],
|
||||
"properties": {},
|
||||
"widgets_values": [512, 512, 1]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"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": 5 },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": 6 }
|
||||
],
|
||||
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [7], "slot_index": 0 }],
|
||||
"properties": {},
|
||||
"widgets_values": [156680208700286, true, 2, 8, "euler", "normal", 1]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"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": 7,
|
||||
"type": "SaveImage",
|
||||
"pos": [1451, 189],
|
||||
"size": [210, 26],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "images", "type": "IMAGE", "link": 9 }],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "KSampler",
|
||||
"pos": [863, 486],
|
||||
"size": [315, 262],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": 10 },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": 12 },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": 13 },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": 14 }
|
||||
],
|
||||
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [15], "slot_index": 0 }],
|
||||
"properties": {},
|
||||
"widgets_values": [156680208700287, true, 3, 8, "euler", "normal", 1]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1209, 488],
|
||||
"size": [210, 46],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "samples", "type": "LATENT", "link": 15 },
|
||||
{ "name": "vae", "type": "VAE", "link": 11 }
|
||||
],
|
||||
"outputs": [{ "name": "IMAGE", "type": "IMAGE", "links": [16], "slot_index": 0 }],
|
||||
"properties": {}
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "SaveImage",
|
||||
"pos": [1451, 489],
|
||||
"size": [210, 26],
|
||||
"flags": {},
|
||||
"order": 9,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "images", "type": "IMAGE", "link": 16 }],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 0, 5, 0, "MODEL"],
|
||||
[2, 1, 1, 2, 0, "CLIP"],
|
||||
[3, 1, 1, 3, 0, "CLIP"],
|
||||
[4, 2, 0, 5, 1, "CONDITIONING"],
|
||||
[5, 3, 0, 5, 2, "CONDITIONING"],
|
||||
[6, 4, 0, 5, 3, "LATENT"],
|
||||
[7, 5, 0, 6, 0, "LATENT"],
|
||||
[8, 1, 2, 6, 1, "VAE"],
|
||||
[9, 6, 0, 7, 0, "IMAGE"],
|
||||
[10, 1, 0, 8, 0, "MODEL"],
|
||||
[11, 1, 2, 9, 1, "VAE"],
|
||||
[12, 2, 0, 8, 1, "CONDITIONING"],
|
||||
[13, 3, 0, 8, 2, "CONDITIONING"],
|
||||
[14, 4, 0, 8, 3, "LATENT"],
|
||||
[15, 8, 0, 9, 0, "LATENT"],
|
||||
[16, 9, 0, 10, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
507
browser_tests/helpers/ExecutionTestHelper.ts
Normal file
507
browser_tests/helpers/ExecutionTestHelper.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
import { Page } from '@playwright/test'
|
||||
|
||||
export interface ExecutionEventTracker {
|
||||
progressStates: any[]
|
||||
executionStarted: boolean
|
||||
executionFinished: boolean
|
||||
executionError: any | null
|
||||
executingNodeId: string | null
|
||||
}
|
||||
|
||||
export interface ProgressState {
|
||||
prompt_id: string
|
||||
nodes: Record<
|
||||
string,
|
||||
{
|
||||
state: 'running' | 'finished' | 'waiting'
|
||||
node_id: string
|
||||
display_node_id: string
|
||||
prompt_id: string
|
||||
value?: number
|
||||
max?: number
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
export class ExecutionTestHelper {
|
||||
private testId: string
|
||||
|
||||
constructor(private page: Page) {
|
||||
// Generate unique ID for this test instance to avoid conflicts
|
||||
this.testId = `test_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up common event tracking for execution tests
|
||||
*/
|
||||
async setupEventTracking(): Promise<void> {
|
||||
await this.page.evaluate((testId) => {
|
||||
// Use unique property names for this test instance
|
||||
const progressKey = `__progressStates_${testId}`
|
||||
const startedKey = `__executionStarted_${testId}`
|
||||
const finishedKey = `__executionFinished_${testId}`
|
||||
const errorKey = `__executionError_${testId}`
|
||||
const nodeIdKey = `__executingNodeId_${testId}`
|
||||
|
||||
window[progressKey] = []
|
||||
window[startedKey] = false
|
||||
window[finishedKey] = false
|
||||
window[errorKey] = null
|
||||
window[nodeIdKey] = null
|
||||
|
||||
const api = window['app'].api
|
||||
|
||||
// Store listeners so we can clean them up later
|
||||
if (!window['__testListeners']) {
|
||||
window['__testListeners'] = {}
|
||||
}
|
||||
|
||||
// Remove old listeners if they exist
|
||||
if (window['__testListeners'][testId]) {
|
||||
const oldListeners = window['__testListeners'][testId]
|
||||
api.removeEventListener('progress_state', oldListeners.progress)
|
||||
api.removeEventListener('executing', oldListeners.executing)
|
||||
api.removeEventListener('execution_error', oldListeners.error)
|
||||
}
|
||||
|
||||
// Create new listeners
|
||||
const progressListener = (event) => {
|
||||
window[progressKey].push(event.detail)
|
||||
}
|
||||
|
||||
const executingListener = (event) => {
|
||||
window[nodeIdKey] = event.detail
|
||||
if (event.detail !== null) {
|
||||
window[startedKey] = true
|
||||
} else {
|
||||
window[finishedKey] = true
|
||||
}
|
||||
}
|
||||
|
||||
const errorListener = (event) => {
|
||||
window[errorKey] = event.detail
|
||||
}
|
||||
|
||||
// Add listeners
|
||||
api.addEventListener('progress_state', progressListener)
|
||||
api.addEventListener('executing', executingListener)
|
||||
api.addEventListener('execution_error', errorListener)
|
||||
|
||||
// Store listeners for cleanup
|
||||
window['__testListeners'][testId] = {
|
||||
progress: progressListener,
|
||||
executing: executingListener,
|
||||
error: errorListener
|
||||
}
|
||||
}, this.testId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current event tracking state
|
||||
*/
|
||||
async getEventState(): Promise<ExecutionEventTracker> {
|
||||
return await this.page.evaluate(
|
||||
(testId) => ({
|
||||
progressStates: window[`__progressStates_${testId}`] || [],
|
||||
executionStarted: window[`__executionStarted_${testId}`] || false,
|
||||
executionFinished: window[`__executionFinished_${testId}`] || false,
|
||||
executionError: window[`__executionError_${testId}`] || null,
|
||||
executingNodeId: window[`__executingNodeId_${testId}`] || null
|
||||
}),
|
||||
this.testId
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for execution to start
|
||||
*/
|
||||
async waitForExecutionStart(timeout: number = 10000): Promise<void> {
|
||||
await this.page.waitForFunction(
|
||||
(testId) => window[`__executionStarted_${testId}`] === true,
|
||||
this.testId,
|
||||
{ timeout }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for execution to finish
|
||||
*/
|
||||
async waitForExecutionFinish(timeout: number = 30000): Promise<void> {
|
||||
await this.page.waitForFunction(
|
||||
(testId) => window[`__executionFinished_${testId}`] === true,
|
||||
this.testId,
|
||||
{ timeout }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for a specific number of nodes to be running
|
||||
*/
|
||||
async waitForRunningNodes(
|
||||
count: number,
|
||||
timeout: number = 10000
|
||||
): Promise<void> {
|
||||
await this.page.waitForFunction(
|
||||
({ expectedCount, testId }) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return false
|
||||
|
||||
const latestState = states[states.length - 1]
|
||||
if (!latestState.nodes) return false
|
||||
|
||||
const runningNodes = Object.values(latestState.nodes).filter(
|
||||
(node: any) => node.state === 'running'
|
||||
).length
|
||||
|
||||
return runningNodes >= expectedCount
|
||||
},
|
||||
{ expectedCount: count, testId: this.testId },
|
||||
{ timeout }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for at least one node to finish
|
||||
*/
|
||||
async waitForNodeFinish(timeout: number = 15000): Promise<void> {
|
||||
await this.page.waitForFunction(
|
||||
(testId) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return false
|
||||
|
||||
const latestState = states[states.length - 1]
|
||||
if (!latestState.nodes) return false
|
||||
|
||||
return Object.values(latestState.nodes).some(
|
||||
(node: any) => node.state === 'finished'
|
||||
)
|
||||
},
|
||||
this.testId,
|
||||
{ timeout }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the latest progress state
|
||||
*/
|
||||
async getLatestProgressState(): Promise<ProgressState | null> {
|
||||
return await this.page.evaluate((testId) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return null
|
||||
return states[states.length - 1]
|
||||
}, this.testId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for node progress to be applied to the graph
|
||||
*/
|
||||
async waitForGraphNodeProgress(
|
||||
nodeIds: number[],
|
||||
timeout: number = 5000
|
||||
): Promise<void> {
|
||||
await this.page.waitForFunction(
|
||||
(ids) => {
|
||||
return ids.some((id) => {
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
return node?.progress !== undefined && node.progress >= 0
|
||||
})
|
||||
},
|
||||
nodeIds,
|
||||
{ timeout }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets node progress from the graph
|
||||
*/
|
||||
async getGraphNodeProgress(nodeId: number): Promise<number | undefined> {
|
||||
return await this.page.evaluate((id) => {
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
return node?.progress
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if execution had errors
|
||||
*/
|
||||
async hasExecutionError(): Promise<boolean> {
|
||||
const error = await this.page.evaluate(
|
||||
(testId) => window[`__executionError_${testId}`],
|
||||
this.testId
|
||||
)
|
||||
return error !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets execution error details
|
||||
*/
|
||||
async getExecutionError(): Promise<any> {
|
||||
return await this.page.evaluate(
|
||||
(testId) => window[`__executionError_${testId}`],
|
||||
this.testId
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup event listeners when test is done
|
||||
*/
|
||||
async cleanup(): Promise<void> {
|
||||
await this.page.evaluate((testId) => {
|
||||
if (window['__testListeners'] && window['__testListeners'][testId]) {
|
||||
const api = window['app'].api
|
||||
const listeners = window['__testListeners'][testId]
|
||||
api.removeEventListener('progress_state', listeners.progress)
|
||||
api.removeEventListener('executing', listeners.executing)
|
||||
api.removeEventListener('execution_error', listeners.error)
|
||||
delete window['__testListeners'][testId]
|
||||
}
|
||||
// Clean up test-specific properties
|
||||
delete window[`__progressStates_${testId}`]
|
||||
delete window[`__executionStarted_${testId}`]
|
||||
delete window[`__executionFinished_${testId}`]
|
||||
delete window[`__executionError_${testId}`]
|
||||
delete window[`__executingNodeId_${testId}`]
|
||||
}, this.testId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the testId for direct window access in evaluate functions
|
||||
*/
|
||||
getTestId(): string {
|
||||
return this.testId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for browser title monitoring
|
||||
*/
|
||||
export class BrowserTitleMonitor {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
/**
|
||||
* Waits for title to not show execution state
|
||||
*/
|
||||
async waitForIdleTitle(timeout: number = 10000): Promise<void> {
|
||||
await this.page.waitForFunction(
|
||||
() => {
|
||||
const title = document.title
|
||||
return !title.match(/\[\d+%\]/) && !title.match(/\[\d+ nodes running\]/)
|
||||
},
|
||||
{ timeout }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for title to show execution state
|
||||
*/
|
||||
async waitForExecutionTitle(timeout: number = 5000): Promise<void> {
|
||||
await this.page.waitForFunction(
|
||||
() => {
|
||||
const title = document.title
|
||||
return title.match(/\[\d+%\]/) || title.match(/\[\d+ nodes running\]/)
|
||||
},
|
||||
{ timeout }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up title change monitoring
|
||||
*/
|
||||
async setupTitleMonitoring(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
window['__titleUpdateLog'] = []
|
||||
window['__lastTitle'] = document.title
|
||||
|
||||
window['__titleInterval'] = setInterval(() => {
|
||||
const newTitle = document.title
|
||||
if (newTitle !== window['__lastTitle']) {
|
||||
window['__titleUpdateLog'].push({
|
||||
time: Date.now(),
|
||||
title: newTitle,
|
||||
hasProgress: !!newTitle.match(/\[\d+%\]/),
|
||||
hasMultiNode: !!newTitle.match(/\[\d+ nodes running\]/)
|
||||
})
|
||||
window['__lastTitle'] = newTitle
|
||||
}
|
||||
}, 50)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops title monitoring and returns the log
|
||||
*/
|
||||
async stopTitleMonitoring(): Promise<any[]> {
|
||||
const log = await this.page.evaluate(() => {
|
||||
if (window['__titleInterval']) {
|
||||
clearInterval(window['__titleInterval'])
|
||||
}
|
||||
return window['__titleUpdateLog'] || []
|
||||
})
|
||||
return log
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for preview event handling
|
||||
*/
|
||||
export class PreviewTestHelper {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
/**
|
||||
* Sets up preview event tracking
|
||||
*/
|
||||
async setupPreviewTracking(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
window['__previewEvents'] = []
|
||||
window['__revokedNodes'] = []
|
||||
window['__revokedUrls'] = []
|
||||
|
||||
// Track preview events
|
||||
const api = window['app'].api
|
||||
api.addEventListener('b_preview_with_metadata', (event) => {
|
||||
window['__previewEvents'].push({
|
||||
nodeId: event.detail.nodeId,
|
||||
displayNodeId: event.detail.displayNodeId,
|
||||
parentNodeId: event.detail.parentNodeId,
|
||||
realNodeId: event.detail.realNodeId,
|
||||
promptId: event.detail.promptId
|
||||
})
|
||||
})
|
||||
|
||||
// Mock revokePreviews to track calls
|
||||
const originalRevoke = window['app'].revokePreviews
|
||||
window['app'].revokePreviews = function (nodeId) {
|
||||
window['__revokedNodes'].push(nodeId)
|
||||
originalRevoke.call(this, nodeId)
|
||||
}
|
||||
|
||||
// Mock URL.revokeObjectURL to track URL revocations
|
||||
const originalRevokeURL = URL.revokeObjectURL
|
||||
URL.revokeObjectURL = (url: string) => {
|
||||
window['__revokedUrls'].push(url)
|
||||
originalRevokeURL.call(URL, url)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets tracked preview events
|
||||
*/
|
||||
async getPreviewEvents(): Promise<any[]> {
|
||||
return await this.page.evaluate(() => window['__previewEvents'] || [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets revoked node IDs
|
||||
*/
|
||||
async getRevokedNodes(): Promise<string[]> {
|
||||
return await this.page.evaluate(() => window['__revokedNodes'] || [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets revoked URLs
|
||||
*/
|
||||
async getRevokedUrls(): Promise<string[]> {
|
||||
return await this.page.evaluate(() => window['__revokedUrls'] || [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets fake preview for a node
|
||||
*/
|
||||
async setNodePreview(nodeId: string, previewUrl: string): Promise<void> {
|
||||
await this.page.evaluate(
|
||||
({ id, url }) => {
|
||||
window['app'].nodePreviewImages[id] = [url]
|
||||
},
|
||||
{ id: nodeId, url: previewUrl }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets node preview URLs
|
||||
*/
|
||||
async getNodePreviews(nodeId: string): Promise<string[] | undefined> {
|
||||
return await this.page.evaluate(
|
||||
(id) => window['app'].nodePreviewImages[id],
|
||||
nodeId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for checking subgraph execution
|
||||
*/
|
||||
export class SubgraphTestHelper {
|
||||
private testId: string
|
||||
|
||||
constructor(private page: Page) {
|
||||
// Generate unique ID for this test instance
|
||||
this.testId = `test_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the test ID to match ExecutionTestHelper
|
||||
*/
|
||||
setTestId(testId: string): void {
|
||||
this.testId = testId
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for nested node progress (nodes with ':' in their ID)
|
||||
*/
|
||||
async waitForNestedNodeProgress(
|
||||
minNestingLevel: number = 1,
|
||||
timeout: number = 15000
|
||||
): Promise<void> {
|
||||
await this.page.waitForFunction(
|
||||
({ minLevel, testId }) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return false
|
||||
|
||||
return states.some((state: any) => {
|
||||
if (!state.nodes) return false
|
||||
return Object.keys(state.nodes).some((nodeId) => {
|
||||
const colonCount = (nodeId.match(/:/g) || []).length
|
||||
return colonCount >= minLevel
|
||||
})
|
||||
})
|
||||
},
|
||||
{ minLevel: minNestingLevel, testId: this.testId },
|
||||
{ timeout }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all nested node IDs from progress states
|
||||
*/
|
||||
async getNestedNodeIds(): Promise<string[]> {
|
||||
return await this.page.evaluate((testId) => {
|
||||
const states = window[`__progressStates_${testId}`] || []
|
||||
const nestedIds = new Set<string>()
|
||||
|
||||
states.forEach((state: any) => {
|
||||
if (state.nodes) {
|
||||
Object.keys(state.nodes).forEach((nodeId) => {
|
||||
if (nodeId.includes(':')) {
|
||||
nestedIds.add(nodeId)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(nestedIds)
|
||||
}, this.testId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a node has running stroke style
|
||||
*/
|
||||
async hasRunningStrokeStyle(nodeId: number): Promise<boolean> {
|
||||
return await this.page.evaluate((id) => {
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
if (!node?.strokeStyles?.['running']) return false
|
||||
const style = node.strokeStyles['running'].call(node)
|
||||
return style?.color === '#0f0'
|
||||
}, nodeId)
|
||||
}
|
||||
}
|
||||
316
browser_tests/tests/browserTabTitleMultiNode.spec.ts
Normal file
316
browser_tests/tests/browserTabTitleMultiNode.spec.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '../fixtures/ComfyPage'
|
||||
import { webSocketFixture } from '../fixtures/ws'
|
||||
import {
|
||||
BrowserTitleMonitor,
|
||||
ExecutionTestHelper
|
||||
} from '../helpers/ExecutionTestHelper'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
test.describe('Browser Tab Title - Multi-node Execution', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let executionHelper: ExecutionTestHelper
|
||||
let titleMonitor: BrowserTitleMonitor
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
executionHelper = new ExecutionTestHelper(comfyPage.page)
|
||||
titleMonitor = new BrowserTitleMonitor(comfyPage.page)
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
// Clean up event listeners to avoid conflicts
|
||||
if (executionHelper) {
|
||||
await executionHelper.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('Shows multiple nodes running in tab title', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
|
||||
|
||||
// Wait for any existing execution to complete
|
||||
await titleMonitor.waitForIdleTitle()
|
||||
|
||||
// Get initial title
|
||||
const initialTitle = await comfyPage.page.title()
|
||||
// Title might show execution state if other tests are running
|
||||
// Just ensure we have a baseline to compare against
|
||||
|
||||
// Wait for the UI to be ready
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Check if workflow is valid and nodes are available
|
||||
const workflowStatus = await comfyPage.page.evaluate(() => {
|
||||
const graph = window['app'].graph
|
||||
const missingNodeTypes: string[] = []
|
||||
const nodeCount = graph.nodes.length
|
||||
|
||||
// Check for missing node types
|
||||
graph.nodes.forEach((node: any) => {
|
||||
if (node.type && !LiteGraph.registered_node_types[node.type]) {
|
||||
missingNodeTypes.push(node.type)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
nodeCount,
|
||||
missingNodeTypes,
|
||||
hasErrors: missingNodeTypes.length > 0
|
||||
}
|
||||
})
|
||||
|
||||
if (workflowStatus.hasErrors) {
|
||||
console.log('Missing node types:', workflowStatus.missingNodeTypes)
|
||||
// Skip test if nodes are missing
|
||||
test.skip()
|
||||
return
|
||||
}
|
||||
|
||||
// Set up tracking for progress events and errors
|
||||
await executionHelper.setupEventTracking()
|
||||
|
||||
// Queue the workflow for real execution using the command
|
||||
await comfyPage.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
// Wait a moment to see if there's an error
|
||||
await comfyPage.page.waitForTimeout(1000)
|
||||
|
||||
// Check for execution errors
|
||||
if (await executionHelper.hasExecutionError()) {
|
||||
const error = await executionHelper.getExecutionError()
|
||||
console.log('Execution error:', error)
|
||||
}
|
||||
|
||||
// Wait for multiple nodes to be running (TestSleep nodes 2, 3 and TestAsyncProgressNode 4)
|
||||
await executionHelper.waitForRunningNodes(2)
|
||||
|
||||
// Check title while we know multiple nodes are running
|
||||
const testId = executionHelper.getTestId()
|
||||
const titleDuringExecution = await comfyPage.page.evaluate((testId) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return null
|
||||
|
||||
const latestState = states[states.length - 1]
|
||||
if (!latestState.nodes) return null
|
||||
|
||||
const runningNodes = Object.values(latestState.nodes).filter(
|
||||
(node: any) => node.state === 'running'
|
||||
).length
|
||||
|
||||
return {
|
||||
title: document.title,
|
||||
runningCount: runningNodes
|
||||
}
|
||||
}, testId)
|
||||
|
||||
// Verify we captured the state with multiple nodes running
|
||||
expect(titleDuringExecution).not.toBeNull()
|
||||
expect(titleDuringExecution.runningCount).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// The title should show multiple nodes running when we have 2+ nodes executing
|
||||
if (titleDuringExecution.runningCount >= 2) {
|
||||
expect(titleDuringExecution.title).toMatch(/\[\d+ nodes running\]/)
|
||||
}
|
||||
|
||||
// Wait for some nodes to finish, leaving only one running
|
||||
await executionHelper.waitForRunningNodes(1, 15000)
|
||||
|
||||
// Wait for title to show single node progress
|
||||
await comfyPage.page.waitForFunction(
|
||||
() => {
|
||||
const title = document.title
|
||||
return title.match(/\[\d+%\]/) && !title.match(/\[\d+ nodes running\]/)
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
|
||||
// Check that title shows single node with progress
|
||||
const titleWithSingleNode = await comfyPage.page.title()
|
||||
expect(titleWithSingleNode).toMatch(/\[\d+%\]/)
|
||||
expect(titleWithSingleNode).not.toMatch(/\[\d+ nodes running\]/)
|
||||
})
|
||||
|
||||
test('Shows progress updates in title during execution', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
|
||||
|
||||
// Wait for the UI to be ready
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Set up tracking for progress events and title changes
|
||||
await executionHelper.setupEventTracking()
|
||||
await titleMonitor.setupTitleMonitoring()
|
||||
|
||||
// Queue the workflow for real execution using the command
|
||||
await comfyPage.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
// Wait for TestAsyncProgressNode (node 4) to start showing progress
|
||||
// This node reports progress from 0 to 10 with steps of 1
|
||||
const testId2 = executionHelper.getTestId()
|
||||
await comfyPage.page.waitForFunction(
|
||||
(testId) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return false
|
||||
|
||||
const latestState = states[states.length - 1]
|
||||
if (!latestState.nodes || !latestState.nodes['4']) return false
|
||||
|
||||
const node4 = latestState.nodes['4']
|
||||
if (node4.state === 'running' && node4.value > 0) {
|
||||
window['__lastProgress'] = Math.round((node4.value / node4.max) * 100)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
testId2,
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
|
||||
// Wait for title to show progress percentage
|
||||
await comfyPage.page.waitForFunction(
|
||||
() => {
|
||||
const title = document.title
|
||||
console.log('Title check 1:', title)
|
||||
return title.match(/\[\d+%\]/)
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
|
||||
// Check that title shows a progress percentage
|
||||
const titleWithProgress = await comfyPage.page.title()
|
||||
expect(titleWithProgress).toMatch(/\[\d+%\]/)
|
||||
|
||||
// Wait for progress to update to a different value
|
||||
const firstProgress = await comfyPage.page.evaluate(
|
||||
() => window['__lastProgress']
|
||||
)
|
||||
|
||||
const testId3 = executionHelper.getTestId()
|
||||
await comfyPage.page.waitForFunction(
|
||||
({ initialProgress, testId }) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return false
|
||||
|
||||
const latestState = states[states.length - 1]
|
||||
if (!latestState.nodes || !latestState.nodes['4']) return false
|
||||
|
||||
const node4 = latestState.nodes['4']
|
||||
if (node4.state === 'running') {
|
||||
const currentProgress = Math.round((node4.value / node4.max) * 100)
|
||||
window['__lastProgress'] = currentProgress
|
||||
return currentProgress > initialProgress
|
||||
}
|
||||
return false
|
||||
},
|
||||
{ initialProgress: firstProgress, testId: testId3 },
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
|
||||
// Store the first progress for comparison
|
||||
await comfyPage.page.evaluate((progress) => {
|
||||
window['__firstProgress'] = progress
|
||||
}, firstProgress)
|
||||
|
||||
// Check the title history to verify we captured progress updates
|
||||
const finalCheck = await comfyPage.page.evaluate(() => {
|
||||
const titleLog = window['__titleUpdateLog'] || []
|
||||
const firstProgress = window['__firstProgress'] || 0
|
||||
|
||||
// Find titles with progress information
|
||||
const titlesWithProgress = titleLog.filter((entry) => entry.hasProgress)
|
||||
|
||||
// Check if we saw different progress values or multi-node running state
|
||||
const progressValues = new Set()
|
||||
const hadMultiNodeRunning = titleLog.some((entry) =>
|
||||
entry.title.includes('nodes running')
|
||||
)
|
||||
|
||||
titleLog.forEach((entry) => {
|
||||
const match = entry.title.match(/\[(\d+)%\]/)
|
||||
if (match) {
|
||||
progressValues.add(parseInt(match[1]))
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
sawProgressUpdates: titlesWithProgress.length > 0,
|
||||
uniqueProgressValues: Array.from(progressValues),
|
||||
hadMultiNodeRunning,
|
||||
firstProgress,
|
||||
lastProgress: window['__lastProgress'],
|
||||
totalTitleUpdates: titleLog.length,
|
||||
sampleTitles: titleLog.slice(0, 5)
|
||||
}
|
||||
})
|
||||
|
||||
console.log('Title update check:', JSON.stringify(finalCheck, null, 2))
|
||||
|
||||
// Verify that we captured title updates showing execution progress
|
||||
expect(finalCheck.sawProgressUpdates).toBe(true)
|
||||
expect(finalCheck.totalTitleUpdates).toBeGreaterThan(0)
|
||||
|
||||
// We should have seen either:
|
||||
// 1. Multiple unique progress values, OR
|
||||
// 2. Multi-node running state, OR
|
||||
// 3. Progress different from initial
|
||||
const sawProgressChange =
|
||||
finalCheck.uniqueProgressValues.length > 1 ||
|
||||
finalCheck.hadMultiNodeRunning ||
|
||||
finalCheck.lastProgress !== firstProgress
|
||||
|
||||
expect(sawProgressChange).toBe(true)
|
||||
|
||||
// Clean up interval
|
||||
await titleMonitor.stopTitleMonitoring()
|
||||
})
|
||||
|
||||
test('Clears execution status from title when all nodes finish', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
|
||||
|
||||
// Wait for any existing execution to complete
|
||||
await titleMonitor.waitForIdleTitle()
|
||||
|
||||
// Wait for the UI to be ready
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Set up tracking for events
|
||||
await executionHelper.setupEventTracking()
|
||||
|
||||
// Queue the workflow for real execution using the command
|
||||
await comfyPage.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
// Wait for execution to show progress in title
|
||||
await titleMonitor.waitForExecutionTitle()
|
||||
|
||||
// Verify execution shows in title
|
||||
const executingTitle = await comfyPage.page.title()
|
||||
expect(executingTitle).toMatch(/\[[\d%\s\w]+\]/)
|
||||
|
||||
// Wait for execution to complete (all nodes finished)
|
||||
await executionHelper.waitForExecutionFinish()
|
||||
|
||||
// Give a moment for title to update after execution completes
|
||||
await comfyPage.page.waitForTimeout(500)
|
||||
|
||||
// Wait for title to clear execution status
|
||||
await titleMonitor.waitForIdleTitle()
|
||||
|
||||
// Check that execution status is cleared
|
||||
const finishedTitle = await comfyPage.page.title()
|
||||
expect(finishedTitle).toContain('ComfyUI')
|
||||
expect(finishedTitle).not.toMatch(/\[\d+%\]/) // No percentage
|
||||
expect(finishedTitle).not.toMatch(/\[\d+ nodes running\]/) // No running nodes
|
||||
expect(finishedTitle).not.toContain('Executing')
|
||||
})
|
||||
})
|
||||
85
browser_tests/tests/browserTabTitleSimple.spec.ts
Normal file
85
browser_tests/tests/browserTabTitleSimple.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import {
|
||||
BrowserTitleMonitor,
|
||||
ExecutionTestHelper
|
||||
} from '../helpers/ExecutionTestHelper'
|
||||
|
||||
test.describe('Browser Tab Title - Multi-node Simple', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let executionHelper: ExecutionTestHelper
|
||||
let titleMonitor: BrowserTitleMonitor
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
executionHelper = new ExecutionTestHelper(comfyPage.page)
|
||||
titleMonitor = new BrowserTitleMonitor(comfyPage.page)
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (executionHelper) {
|
||||
await executionHelper.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('Title updates based on execution state', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
|
||||
|
||||
// Wait for any existing execution to complete
|
||||
await titleMonitor.waitForIdleTitle().catch(() => {
|
||||
// If timeout, cancel any running execution
|
||||
return comfyPage.page.keyboard.press('Escape')
|
||||
})
|
||||
|
||||
// Get initial title
|
||||
const initialTitle = await comfyPage.page.title()
|
||||
// Title might show execution state if other tests are running
|
||||
// Just ensure we can detect when it changes
|
||||
const hasExecutionState =
|
||||
initialTitle.match(/\[\d+%\]/) ||
|
||||
initialTitle.match(/\[\d+ nodes running\]/)
|
||||
|
||||
// Set up tracking for execution events
|
||||
await executionHelper.setupEventTracking()
|
||||
|
||||
// Start real execution using command instead of button
|
||||
await comfyPage.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
// Wait for execution to start
|
||||
await executionHelper.waitForExecutionStart()
|
||||
|
||||
// Wait for title to update with execution state
|
||||
await titleMonitor.waitForExecutionTitle()
|
||||
|
||||
const executingTitle = await comfyPage.page.title()
|
||||
// If initial title didn't have execution state, it should be different now
|
||||
if (!hasExecutionState) {
|
||||
expect(executingTitle).not.toBe(initialTitle)
|
||||
}
|
||||
expect(executingTitle).toMatch(/\[[\d%\s\w]+\]/)
|
||||
})
|
||||
|
||||
test('Can read workflow name from title', async ({ comfyPage }) => {
|
||||
// Wait for any existing execution to complete
|
||||
await titleMonitor.waitForIdleTitle(5000).catch(async () => {
|
||||
// Cancel any running execution
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await comfyPage.page.waitForTimeout(1000)
|
||||
})
|
||||
|
||||
// Set a workflow name
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window['app'].extensionManager.workflow.activeWorkflow.filename =
|
||||
'test-workflow'
|
||||
})
|
||||
|
||||
// Wait for title to update
|
||||
await comfyPage.page.waitForTimeout(100)
|
||||
|
||||
const title = await comfyPage.page.title()
|
||||
expect(title).toContain('test-workflow')
|
||||
// Title should contain workflow name regardless of execution state
|
||||
})
|
||||
})
|
||||
385
browser_tests/tests/multiNodeExecution.spec.ts
Normal file
385
browser_tests/tests/multiNodeExecution.spec.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '../fixtures/ComfyPage'
|
||||
import { webSocketFixture } from '../fixtures/ws'
|
||||
import {
|
||||
ExecutionTestHelper,
|
||||
PreviewTestHelper
|
||||
} from '../helpers/ExecutionTestHelper'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
test.describe('Multi-node Execution Progress', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let executionHelper: ExecutionTestHelper
|
||||
let previewHelper: PreviewTestHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
executionHelper = new ExecutionTestHelper(comfyPage.page)
|
||||
previewHelper = new PreviewTestHelper(comfyPage.page)
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (executionHelper) {
|
||||
await executionHelper.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('Can track progress of multiple async nodes executing in parallel', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
|
||||
|
||||
// Get references to the async nodes
|
||||
const sleepNode1 = await comfyPage.getNodeRefById(2)
|
||||
const sleepNode2 = await comfyPage.getNodeRefById(3)
|
||||
const progressNode = await comfyPage.getNodeRefById(4)
|
||||
|
||||
// Verify nodes are present
|
||||
expect(sleepNode1).toBeDefined()
|
||||
expect(sleepNode2).toBeDefined()
|
||||
expect(progressNode).toBeDefined()
|
||||
|
||||
// Set up tracking for progress events
|
||||
await executionHelper.setupEventTracking()
|
||||
|
||||
// Start execution
|
||||
await comfyPage.queueButton.click()
|
||||
|
||||
// Wait for all three nodes (2, 3, 4) to show progress from real execution
|
||||
const testId = executionHelper.getTestId()
|
||||
await comfyPage.page.waitForFunction(
|
||||
(testId) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return false
|
||||
|
||||
const latestState = states[states.length - 1]
|
||||
if (!latestState.nodes) return false
|
||||
|
||||
const node2 = latestState.nodes['2']
|
||||
const node3 = latestState.nodes['3']
|
||||
const node4 = latestState.nodes['4']
|
||||
|
||||
// Check that all nodes have started executing
|
||||
return (
|
||||
node2 &&
|
||||
node2.state === 'running' &&
|
||||
node3 &&
|
||||
node3.state === 'running' &&
|
||||
node4 &&
|
||||
node4.state === 'running'
|
||||
)
|
||||
},
|
||||
testId,
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
|
||||
// Wait for progress to be applied to all nodes in the graph
|
||||
await executionHelper.waitForGraphNodeProgress([2, 3, 4])
|
||||
|
||||
// Check that all nodes show progress
|
||||
const nodeProgress1 = await sleepNode1.getProperty('progress')
|
||||
const nodeProgress2 = await sleepNode2.getProperty('progress')
|
||||
const nodeProgress3 = await progressNode.getProperty('progress')
|
||||
|
||||
// Progress values should now be defined (exact values depend on timing)
|
||||
expect(nodeProgress1).toBeDefined()
|
||||
expect(nodeProgress2).toBeDefined()
|
||||
expect(nodeProgress3).toBeDefined()
|
||||
expect(nodeProgress1).toBeGreaterThanOrEqual(0)
|
||||
expect(nodeProgress1).toBeLessThanOrEqual(1)
|
||||
expect(nodeProgress2).toBeGreaterThanOrEqual(0)
|
||||
expect(nodeProgress2).toBeLessThanOrEqual(1)
|
||||
expect(nodeProgress3).toBeGreaterThanOrEqual(0)
|
||||
expect(nodeProgress3).toBeLessThanOrEqual(1)
|
||||
|
||||
// Wait for at least one node to finish
|
||||
await executionHelper.waitForNodeFinish()
|
||||
|
||||
// Wait for the finished node's progress to be cleared
|
||||
const testId2 = executionHelper.getTestId()
|
||||
await comfyPage.page.waitForFunction(
|
||||
(testId) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return false
|
||||
|
||||
const latestState = states[states.length - 1]
|
||||
if (!latestState.nodes) return false
|
||||
|
||||
// Find which nodes are finished
|
||||
const finishedNodeIds = Object.entries(latestState.nodes)
|
||||
.filter(([_, node]: [string, any]) => node.state === 'finished')
|
||||
.map(([id, _]) => id)
|
||||
|
||||
// Check that finished nodes have no progress in the graph
|
||||
return finishedNodeIds.some((id) => {
|
||||
const node = window['app'].graph.getNodeById(parseInt(id))
|
||||
return node && node.progress === undefined
|
||||
})
|
||||
},
|
||||
testId2,
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
|
||||
// Get current state of nodes
|
||||
const testId3 = executionHelper.getTestId()
|
||||
const currentState = await comfyPage.page.evaluate((testId) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return null
|
||||
const latestState = states[states.length - 1]
|
||||
const graphNodes = {
|
||||
'2': window['app'].graph.getNodeById(2),
|
||||
'3': window['app'].graph.getNodeById(3),
|
||||
'4': window['app'].graph.getNodeById(4)
|
||||
}
|
||||
|
||||
return {
|
||||
stateNodes: latestState.nodes,
|
||||
graphProgress: {
|
||||
'2': graphNodes['2']?.progress,
|
||||
'3': graphNodes['3']?.progress,
|
||||
'4': graphNodes['4']?.progress
|
||||
}
|
||||
}
|
||||
}, testId3)
|
||||
|
||||
// Verify that finished nodes have no progress, running nodes have progress
|
||||
if (currentState && currentState.stateNodes) {
|
||||
Object.entries(currentState.stateNodes).forEach(
|
||||
([nodeId, nodeState]: [string, any]) => {
|
||||
const graphProgress = currentState.graphProgress[nodeId]
|
||||
if (nodeState.state === 'finished') {
|
||||
expect(graphProgress).toBeUndefined()
|
||||
} else if (nodeState.state === 'running') {
|
||||
expect(graphProgress).toBeDefined()
|
||||
expect(graphProgress).toBeGreaterThanOrEqual(0)
|
||||
expect(graphProgress).toBeLessThanOrEqual(1)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Clean up by canceling execution
|
||||
})
|
||||
|
||||
test('Updates visual state for multiple executing nodes', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
|
||||
|
||||
// Wait for the graph to be properly initialized
|
||||
await comfyPage.page.waitForFunction(
|
||||
() => {
|
||||
return window['app']?.graph?.nodes?.length > 0
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
|
||||
// Set up tracking for progress events
|
||||
await executionHelper.setupEventTracking()
|
||||
|
||||
// Start execution
|
||||
await comfyPage.queueButton.click()
|
||||
|
||||
// Wait for multiple nodes to start executing
|
||||
await executionHelper.waitForRunningNodes(2)
|
||||
|
||||
// Wait for the progress to be applied to nodes
|
||||
await executionHelper.waitForGraphNodeProgress([2, 3])
|
||||
|
||||
// Verify that nodes have progress set (indicates they are executing)
|
||||
const nodeStates = await comfyPage.page.evaluate(() => {
|
||||
const node2 = window['app'].graph.getNodeById(2)
|
||||
const node3 = window['app'].graph.getNodeById(3)
|
||||
return {
|
||||
node2Progress: node2?.progress,
|
||||
node3Progress: node3?.progress,
|
||||
// Check if any nodes are marked as running by having progress
|
||||
hasRunningNodes:
|
||||
(node2?.progress !== undefined && node2?.progress >= 0) ||
|
||||
(node3?.progress !== undefined && node3?.progress >= 0)
|
||||
}
|
||||
})
|
||||
|
||||
expect(nodeStates.node2Progress).toBeDefined()
|
||||
expect(nodeStates.node3Progress).toBeDefined()
|
||||
expect(nodeStates.hasRunningNodes).toBe(true)
|
||||
|
||||
// Wait for at least one node to finish
|
||||
await executionHelper.waitForNodeFinish()
|
||||
|
||||
// Wait for progress updates to reflect the finished state
|
||||
const testId4 = executionHelper.getTestId()
|
||||
await comfyPage.page.waitForFunction(
|
||||
(testId) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return false
|
||||
|
||||
const latestState = states[states.length - 1]
|
||||
if (!latestState.nodes) return false
|
||||
|
||||
// Find nodes by their state
|
||||
const finishedNodes = Object.entries(latestState.nodes)
|
||||
.filter(([_, node]: [string, any]) => node.state === 'finished')
|
||||
.map(([id, _]) => parseInt(id))
|
||||
|
||||
const runningNodes = Object.entries(latestState.nodes)
|
||||
.filter(([_, node]: [string, any]) => node.state === 'running')
|
||||
.map(([id, _]) => parseInt(id))
|
||||
|
||||
// Check graph nodes match the state
|
||||
const allFinishedCorrect = finishedNodes.every((id) => {
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
return node && node.progress === undefined
|
||||
})
|
||||
|
||||
const allRunningCorrect = runningNodes.every((id) => {
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
return node && node.progress !== undefined && node.progress >= 0
|
||||
})
|
||||
|
||||
return (
|
||||
allFinishedCorrect && allRunningCorrect && finishedNodes.length > 0
|
||||
)
|
||||
},
|
||||
testId4,
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
|
||||
// Verify the final node states
|
||||
const testId5 = executionHelper.getTestId()
|
||||
const finalNodeStates = await comfyPage.page.evaluate((testId) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return null
|
||||
const latestState = states[states.length - 1]
|
||||
const node2 = window['app'].graph.getNodeById(2)
|
||||
const node3 = window['app'].graph.getNodeById(3)
|
||||
|
||||
return {
|
||||
node2State: latestState.nodes['2']?.state,
|
||||
node3State: latestState.nodes['3']?.state,
|
||||
node2Progress: node2?.progress,
|
||||
node3Progress: node3?.progress
|
||||
}
|
||||
}, testId5)
|
||||
|
||||
// Verify finished nodes have no progress, running nodes have progress
|
||||
if (finalNodeStates) {
|
||||
if (finalNodeStates.node2State === 'finished') {
|
||||
expect(finalNodeStates.node2Progress).toBeUndefined()
|
||||
} else if (finalNodeStates.node2State === 'running') {
|
||||
expect(finalNodeStates.node2Progress).toBeDefined()
|
||||
}
|
||||
|
||||
if (finalNodeStates.node3State === 'finished') {
|
||||
expect(finalNodeStates.node3Progress).toBeUndefined()
|
||||
} else if (finalNodeStates.node3State === 'running') {
|
||||
expect(finalNodeStates.node3Progress).toBeDefined()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('Clears previews when nodes start executing', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
|
||||
|
||||
// Initialize tracking for revoked previews
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window['__revokedNodes'] = []
|
||||
})
|
||||
|
||||
// Set up some fake previews
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window['app'].nodePreviewImages['2'] = ['fake-preview-url-1']
|
||||
window['app'].nodePreviewImages['3'] = ['fake-preview-url-2']
|
||||
})
|
||||
|
||||
// Verify previews exist
|
||||
const previewsBefore = await comfyPage.page.evaluate(() => {
|
||||
return {
|
||||
node2: window['app'].nodePreviewImages['2'],
|
||||
node3: window['app'].nodePreviewImages['3']
|
||||
}
|
||||
})
|
||||
|
||||
expect(previewsBefore.node2).toEqual(['fake-preview-url-1'])
|
||||
expect(previewsBefore.node3).toEqual(['fake-preview-url-2'])
|
||||
|
||||
// Mock revokePreviews to track calls and set up event listeners
|
||||
await previewHelper.setupPreviewTracking()
|
||||
await executionHelper.setupEventTracking()
|
||||
|
||||
// Start real execution using command
|
||||
await comfyPage.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
// Wait for execution to start
|
||||
await executionHelper.waitForExecutionStart()
|
||||
|
||||
// Wait for real execution to trigger progress events that clear previews
|
||||
const testId6 = executionHelper.getTestId()
|
||||
await comfyPage.page.waitForFunction(
|
||||
(testId) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return false
|
||||
|
||||
// Check if we have progress for nodes 2 and 3
|
||||
const hasNode2Progress = states.some(
|
||||
(state: any) =>
|
||||
state.nodes &&
|
||||
state.nodes['2'] &&
|
||||
state.nodes['2'].state === 'running'
|
||||
)
|
||||
const hasNode3Progress = states.some(
|
||||
(state: any) =>
|
||||
state.nodes &&
|
||||
state.nodes['3'] &&
|
||||
state.nodes['3'].state === 'running'
|
||||
)
|
||||
|
||||
return hasNode2Progress && hasNode3Progress
|
||||
},
|
||||
testId6,
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
|
||||
// Wait for the event to be processed and previews to be revoked
|
||||
await comfyPage.page.waitForFunction(
|
||||
() => {
|
||||
const revokedNodes = window['__revokedNodes']
|
||||
const node2PreviewCleared =
|
||||
window['app'].nodePreviewImages['2'] === undefined
|
||||
const node3PreviewCleared =
|
||||
window['app'].nodePreviewImages['3'] === undefined
|
||||
|
||||
return (
|
||||
revokedNodes.includes('2') &&
|
||||
revokedNodes.includes('3') &&
|
||||
node2PreviewCleared &&
|
||||
node3PreviewCleared
|
||||
)
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
|
||||
// Check that revokePreviews was called for both nodes
|
||||
const revokedNodes = await previewHelper.getRevokedNodes()
|
||||
expect(revokedNodes).toContain('2')
|
||||
expect(revokedNodes).toContain('3')
|
||||
|
||||
// Check that previews were cleared
|
||||
const previewsAfter = await comfyPage.page.evaluate(() => {
|
||||
return {
|
||||
node2: window['app'].nodePreviewImages['2'],
|
||||
node3: window['app'].nodePreviewImages['3']
|
||||
}
|
||||
})
|
||||
|
||||
expect(previewsAfter.node2).toBeUndefined()
|
||||
expect(previewsAfter.node3).toBeUndefined()
|
||||
})
|
||||
})
|
||||
168
browser_tests/tests/multiNodeExecutionSimple.spec.ts
Normal file
168
browser_tests/tests/multiNodeExecutionSimple.spec.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '../fixtures/ComfyPage'
|
||||
import { webSocketFixture } from '../fixtures/ws'
|
||||
import {
|
||||
ExecutionTestHelper,
|
||||
PreviewTestHelper
|
||||
} from '../helpers/ExecutionTestHelper'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
test.describe('Multi-node Execution Progress - Simple', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let executionHelper: ExecutionTestHelper
|
||||
let previewHelper: PreviewTestHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
executionHelper = new ExecutionTestHelper(comfyPage.page)
|
||||
previewHelper = new PreviewTestHelper(comfyPage.page)
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (executionHelper) {
|
||||
await executionHelper.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('Can dispatch and receive progress_state events', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
|
||||
|
||||
// Set up event tracking
|
||||
await executionHelper.setupEventTracking()
|
||||
|
||||
// Start real execution
|
||||
await comfyPage.queueButton.click()
|
||||
|
||||
// Wait for real progress_state events from backend
|
||||
const testId = executionHelper.getTestId()
|
||||
await comfyPage.page.waitForFunction(
|
||||
(testId) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return false
|
||||
|
||||
const latestState = states[states.length - 1]
|
||||
return latestState.nodes && Object.keys(latestState.nodes).length > 0
|
||||
},
|
||||
testId,
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
|
||||
// Get the captured states
|
||||
const eventState = await executionHelper.getEventState()
|
||||
const result = eventState.progressStates
|
||||
|
||||
// Should have captured real events
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
const firstState = result[0]
|
||||
expect(firstState).toBeDefined()
|
||||
expect(firstState.prompt_id).toBeDefined()
|
||||
expect(firstState.nodes).toBeDefined()
|
||||
|
||||
// Check that we got real node progress
|
||||
const nodeIds = Object.keys(firstState.nodes)
|
||||
expect(nodeIds.length).toBeGreaterThan(0)
|
||||
|
||||
// Verify node structure
|
||||
for (const nodeId of nodeIds) {
|
||||
const node = firstState.nodes[nodeId]
|
||||
expect(node.state).toBeDefined()
|
||||
expect(node.node_id).toBeDefined()
|
||||
expect(node.display_node_id).toBeDefined()
|
||||
expect(node.prompt_id).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
test('Canvas updates when nodes have progress', async ({ comfyPage, ws }) => {
|
||||
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
|
||||
|
||||
// Set up progress tracking
|
||||
await executionHelper.setupEventTracking()
|
||||
|
||||
// Start real execution
|
||||
await comfyPage.queueButton.click()
|
||||
|
||||
// Wait for nodes to have progress from real execution
|
||||
const testId2 = executionHelper.getTestId()
|
||||
await comfyPage.page.waitForFunction(
|
||||
(testId) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return false
|
||||
|
||||
const latestState = states[states.length - 1]
|
||||
if (!latestState.nodes) return false
|
||||
|
||||
// Check if any nodes are running with progress
|
||||
const runningNodes = Object.values(latestState.nodes).filter(
|
||||
(node: any) => node.state === 'running' && node.value > 0
|
||||
)
|
||||
|
||||
return runningNodes.length > 0
|
||||
},
|
||||
testId2,
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
|
||||
// Wait for progress to be applied to graph nodes
|
||||
await executionHelper.waitForGraphNodeProgress([2, 3, 4])
|
||||
|
||||
// Check that nodes have progress set from real execution
|
||||
const node2Progress = await executionHelper.getGraphNodeProgress(2)
|
||||
const node3Progress = await executionHelper.getGraphNodeProgress(3)
|
||||
const node4Progress = await executionHelper.getGraphNodeProgress(4)
|
||||
|
||||
// At least one node should have progress
|
||||
const hasProgress =
|
||||
(node2Progress !== undefined && node2Progress > 0) ||
|
||||
(node3Progress !== undefined && node3Progress > 0) ||
|
||||
(node4Progress !== undefined && node4Progress > 0)
|
||||
|
||||
expect(hasProgress).toBe(true)
|
||||
})
|
||||
|
||||
test('Preview events include metadata', async ({ comfyPage, ws }) => {
|
||||
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
|
||||
|
||||
// Track preview events
|
||||
await previewHelper.setupPreviewTracking()
|
||||
await executionHelper.setupEventTracking()
|
||||
|
||||
// Start real execution using command
|
||||
await comfyPage.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
// Wait for execution to start
|
||||
await executionHelper.waitForExecutionStart()
|
||||
|
||||
// For this test, we'll check the event structure by simulating one
|
||||
// since real preview events depend on the workflow actually generating images
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const api = window['app'].api
|
||||
// Simulate a preview event that would come from backend
|
||||
api.dispatchCustomEvent('b_preview_with_metadata', {
|
||||
blob: new Blob(['test'], { type: 'image/png' }),
|
||||
nodeId: '10:5:3',
|
||||
displayNodeId: '10',
|
||||
parentNodeId: '10:5',
|
||||
realNodeId: '3',
|
||||
promptId: 'test-prompt-id'
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Check captured events
|
||||
const captured = await previewHelper.getPreviewEvents()
|
||||
expect(captured).toHaveLength(1)
|
||||
expect(captured[0]).toEqual({
|
||||
nodeId: '10:5:3',
|
||||
displayNodeId: '10',
|
||||
parentNodeId: '10:5',
|
||||
realNodeId: '3',
|
||||
promptId: 'test-prompt-id'
|
||||
})
|
||||
})
|
||||
})
|
||||
272
browser_tests/tests/previewWithMetadata.spec.ts
Normal file
272
browser_tests/tests/previewWithMetadata.spec.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '../fixtures/ComfyPage'
|
||||
import { webSocketFixture } from '../fixtures/ws'
|
||||
import {
|
||||
ExecutionTestHelper,
|
||||
PreviewTestHelper
|
||||
} from '../helpers/ExecutionTestHelper'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
test.describe('Preview with Metadata', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let executionHelper: ExecutionTestHelper
|
||||
let previewHelper: PreviewTestHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
executionHelper = new ExecutionTestHelper(comfyPage.page)
|
||||
previewHelper = new PreviewTestHelper(comfyPage.page)
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (executionHelper) {
|
||||
await executionHelper.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('Handles b_preview_with_metadata event correctly', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
|
||||
|
||||
// Clear any existing previews
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window['app'].nodePreviewImages = {}
|
||||
})
|
||||
|
||||
// Set up handler to track preview events and execution
|
||||
await executionHelper.setupEventTracking()
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window['__previewHandled'] = false
|
||||
const api = window['app'].api
|
||||
|
||||
// Add handler to track preview events
|
||||
api.addEventListener('b_preview_with_metadata', (event) => {
|
||||
const { displayNodeId, blob } = event.detail
|
||||
// Create URL from the blob in the event
|
||||
const url = URL.createObjectURL(blob)
|
||||
window['app'].nodePreviewImages[displayNodeId] = [url]
|
||||
window['__previewHandled'] = true
|
||||
window['__lastPreviewUrl'] = url
|
||||
})
|
||||
})
|
||||
|
||||
// Start real execution to test event handling in context
|
||||
await comfyPage.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
// Wait for execution to start
|
||||
await executionHelper.waitForExecutionStart()
|
||||
|
||||
// Trigger b_preview_with_metadata event (simulating what backend would send)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const api = window['app'].api
|
||||
const event = new CustomEvent('b_preview_with_metadata', {
|
||||
detail: {
|
||||
blob: new Blob(['test'], { type: 'image/png' }),
|
||||
nodeId: '2',
|
||||
displayNodeId: '2',
|
||||
parentNodeId: '2',
|
||||
realNodeId: '2',
|
||||
promptId: 'test-prompt-id'
|
||||
}
|
||||
})
|
||||
api.dispatchEvent(event)
|
||||
})
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Wait for preview to be handled
|
||||
await comfyPage.page.waitForFunction(
|
||||
() => window['__previewHandled'] === true,
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
|
||||
// Check that preview was set for the correct node
|
||||
const result = await comfyPage.page.evaluate(() => {
|
||||
return {
|
||||
previewImages: window['app'].nodePreviewImages,
|
||||
lastUrl: window['__lastPreviewUrl']
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.previewImages['2']).toBeDefined()
|
||||
expect(result.previewImages['2']).toHaveLength(1)
|
||||
expect(result.previewImages['2'][0]).toBe(result.lastUrl)
|
||||
})
|
||||
|
||||
test('Clears old previews when new preview arrives', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
|
||||
|
||||
// Set up initial preview
|
||||
const initialBlobUrl = await comfyPage.page.evaluate(() => {
|
||||
const blob = new Blob(['initial image'], { type: 'image/png' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
window['app'].nodePreviewImages['3'] = [url]
|
||||
return url
|
||||
})
|
||||
|
||||
// Create spy to track URL revocations
|
||||
await previewHelper.setupPreviewTracking()
|
||||
|
||||
// Mock the handler to revoke old previews
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const api = window['app'].api
|
||||
api.addEventListener('b_preview_with_metadata', (event) => {
|
||||
const { displayNodeId } = event.detail
|
||||
window['app'].revokePreviews(displayNodeId)
|
||||
const newBlob = new Blob(['new image'], { type: 'image/png' })
|
||||
const newUrl = URL.createObjectURL(newBlob)
|
||||
window['app'].nodePreviewImages[displayNodeId] = [newUrl]
|
||||
})
|
||||
})
|
||||
|
||||
// Trigger new preview for same node
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const api = window['app'].api
|
||||
const event = new CustomEvent('b_preview_with_metadata', {
|
||||
detail: {
|
||||
blob: new Blob(['new image'], { type: 'image/png' }),
|
||||
nodeId: '3',
|
||||
displayNodeId: '3',
|
||||
parentNodeId: '3',
|
||||
realNodeId: '3',
|
||||
promptId: 'test-prompt-id'
|
||||
}
|
||||
})
|
||||
api.dispatchEvent(event)
|
||||
})
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Check that old URL was revoked
|
||||
const finalRevokedUrls = await previewHelper.getRevokedUrls()
|
||||
expect(finalRevokedUrls).toContain(initialBlobUrl)
|
||||
|
||||
// Check that new preview replaced old one
|
||||
const newPreviewImages = await previewHelper.getNodePreviews('3')
|
||||
|
||||
expect(newPreviewImages).toHaveLength(1)
|
||||
expect(newPreviewImages[0]).not.toBe(initialBlobUrl)
|
||||
})
|
||||
|
||||
test('Associates preview with correct display node in subgraph', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
|
||||
|
||||
// Mock handler that stores metadata
|
||||
await previewHelper.setupPreviewTracking()
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window['__previewMetadata'] = {}
|
||||
const api = window['app'].api
|
||||
api.addEventListener('b_preview_with_metadata', (event) => {
|
||||
const { displayNodeId, nodeId, parentNodeId, realNodeId, promptId } =
|
||||
event.detail
|
||||
window['__previewMetadata'][displayNodeId] = {
|
||||
nodeId,
|
||||
displayNodeId,
|
||||
parentNodeId,
|
||||
realNodeId,
|
||||
promptId
|
||||
}
|
||||
// Still create the preview
|
||||
const url = URL.createObjectURL(event.detail.blob)
|
||||
window['app'].nodePreviewImages[displayNodeId] = [url]
|
||||
})
|
||||
})
|
||||
|
||||
// Simulate preview from a subgraph node
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const api = window['app'].api
|
||||
const event = new CustomEvent('b_preview_with_metadata', {
|
||||
detail: {
|
||||
blob: new Blob(['subgraph preview'], { type: 'image/png' }),
|
||||
nodeId: '10:5:3', // Nested execution ID
|
||||
displayNodeId: '10', // Top-level display node
|
||||
parentNodeId: '10:5', // Parent subgraph
|
||||
realNodeId: '3', // Actual node ID within subgraph
|
||||
promptId: 'test-prompt-id'
|
||||
}
|
||||
})
|
||||
api.dispatchEvent(event)
|
||||
})
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Check that preview is associated with display node
|
||||
const metadata = await comfyPage.page.evaluate(
|
||||
() => window['__previewMetadata']
|
||||
)
|
||||
expect(metadata['10']).toBeDefined()
|
||||
expect(metadata['10'].nodeId).toBe('10:5:3')
|
||||
expect(metadata['10'].displayNodeId).toBe('10')
|
||||
expect(metadata['10'].parentNodeId).toBe('10:5')
|
||||
expect(metadata['10'].realNodeId).toBe('3')
|
||||
|
||||
// Check that preview exists for display node
|
||||
const previews = await comfyPage.page.evaluate(
|
||||
() => window['app'].nodePreviewImages
|
||||
)
|
||||
expect(previews['10']).toBeDefined()
|
||||
expect(previews['10']).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('Maintains backward compatibility with b_preview event', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('execution/parallel_async_nodes')
|
||||
|
||||
// Track both events
|
||||
const eventsFired = await comfyPage.page.evaluate(() => {
|
||||
const events: string[] = []
|
||||
const api = window['app'].api
|
||||
|
||||
api.addEventListener('b_preview', () => {
|
||||
events.push('b_preview')
|
||||
})
|
||||
|
||||
api.addEventListener('b_preview_with_metadata', () => {
|
||||
events.push('b_preview_with_metadata')
|
||||
})
|
||||
|
||||
window['__eventsFired'] = events
|
||||
return events
|
||||
})
|
||||
|
||||
// Trigger b_preview_with_metadata
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const api = window['app'].api
|
||||
const blob = new Blob(['test image'], { type: 'image/png' })
|
||||
|
||||
// Simulate the API behavior
|
||||
api.dispatchCustomEvent('b_preview_with_metadata', {
|
||||
blob,
|
||||
nodeId: '2',
|
||||
displayNodeId: '2',
|
||||
parentNodeId: '2',
|
||||
realNodeId: '2',
|
||||
promptId: 'test-prompt-id'
|
||||
})
|
||||
|
||||
// Also dispatch legacy event as the API would
|
||||
api.dispatchCustomEvent('b_preview', blob)
|
||||
})
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Check that both events were fired
|
||||
const finalEvents = await comfyPage.page.evaluate(
|
||||
() => window['__eventsFired']
|
||||
)
|
||||
expect(finalEvents).toContain('b_preview_with_metadata')
|
||||
expect(finalEvents).toContain('b_preview')
|
||||
})
|
||||
})
|
||||
237
browser_tests/tests/subgraphExecutionProgress.spec.ts
Normal file
237
browser_tests/tests/subgraphExecutionProgress.spec.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '../fixtures/ComfyPage'
|
||||
import { webSocketFixture } from '../fixtures/ws'
|
||||
import {
|
||||
ExecutionTestHelper,
|
||||
SubgraphTestHelper
|
||||
} from '../helpers/ExecutionTestHelper'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
test.describe('Subgraph Execution Progress', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
test.setTimeout(30000) // Increase timeout for subgraph tests
|
||||
|
||||
let executionHelper: ExecutionTestHelper
|
||||
let subgraphHelper: SubgraphTestHelper
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
executionHelper = new ExecutionTestHelper(comfyPage.page)
|
||||
subgraphHelper = new SubgraphTestHelper(comfyPage.page)
|
||||
// Share the same test ID to access the same window properties
|
||||
subgraphHelper.setTestId(executionHelper.getTestId())
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (executionHelper) {
|
||||
await executionHelper.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('Shows progress for nodes inside subgraphs', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('execution/nested-subgraph-test')
|
||||
|
||||
// Get reference to the subgraph node
|
||||
const subgraphNode = await comfyPage.getNodeRefById(10)
|
||||
expect(subgraphNode).toBeDefined()
|
||||
|
||||
// Set up tracking for progress events
|
||||
await executionHelper.setupEventTracking()
|
||||
|
||||
// Start real execution using command to avoid click issues
|
||||
await comfyPage.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
// Wait for execution to start
|
||||
await executionHelper.waitForExecutionStart()
|
||||
|
||||
// Wait for real progress events from subgraph execution
|
||||
await subgraphHelper.waitForNestedNodeProgress(1)
|
||||
|
||||
// Wait for progress to be applied to the subgraph node
|
||||
await executionHelper.waitForGraphNodeProgress([10])
|
||||
|
||||
// Check that the subgraph node shows aggregated progress
|
||||
const subgraphProgress = await subgraphNode.getProperty('progress')
|
||||
|
||||
// The progress should be aggregated from child nodes
|
||||
expect(subgraphProgress).toBeDefined()
|
||||
expect(subgraphProgress).toBeGreaterThan(0)
|
||||
expect(subgraphProgress).toBeLessThanOrEqual(1)
|
||||
|
||||
// Wait for stroke style to be applied
|
||||
await comfyPage.page.waitForFunction(
|
||||
(nodeId) => {
|
||||
const node = window['app'].graph.getNodeById(nodeId)
|
||||
if (!node?.strokeStyles?.['running']) return false
|
||||
const style = node.strokeStyles['running'].call(node)
|
||||
return style?.color === '#0f0'
|
||||
},
|
||||
10,
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
|
||||
// Check stroke style
|
||||
const strokeStyle = await comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window['app'].graph.getNodeById(nodeId)
|
||||
if (!node || !node.strokeStyles || !node.strokeStyles['running']) {
|
||||
return null
|
||||
}
|
||||
return node.strokeStyles['running'].call(node)
|
||||
}, 10)
|
||||
|
||||
expect(strokeStyle).toEqual({ color: '#0f0' })
|
||||
})
|
||||
|
||||
test('Handles deeply nested subgraph execution', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('execution/nested-subgraph-test')
|
||||
|
||||
// Set up tracking for progress events
|
||||
await executionHelper.setupEventTracking()
|
||||
|
||||
// Start real execution using command to avoid click issues
|
||||
await comfyPage.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
// Wait for execution to start
|
||||
await executionHelper.waitForExecutionStart()
|
||||
|
||||
// Wait for real progress events from deeply nested subgraph execution
|
||||
await subgraphHelper.waitForNestedNodeProgress(2)
|
||||
|
||||
// Wait for progress to be applied to the top-level subgraph node
|
||||
await executionHelper.waitForGraphNodeProgress([10])
|
||||
|
||||
// Check that top-level subgraph shows progress
|
||||
const subgraphNode = await comfyPage.getNodeRefById(10)
|
||||
const progress = await subgraphNode.getProperty('progress')
|
||||
|
||||
expect(progress).toBeDefined()
|
||||
expect(progress).toBeGreaterThan(0)
|
||||
expect(progress).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('Shows running state for parent nodes when child executes', async ({
|
||||
comfyPage,
|
||||
ws
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('execution/nested-subgraph-test')
|
||||
|
||||
// Track which nodes have running stroke style
|
||||
const getRunningNodes = async () => {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
const runningNodes: number[] = []
|
||||
const nodes = window['app'].graph.nodes
|
||||
|
||||
for (const node of nodes) {
|
||||
if (
|
||||
node.strokeStyles?.['running'] &&
|
||||
node.strokeStyles['running'].call(node)?.color === '#0f0'
|
||||
) {
|
||||
runningNodes.push(node.id)
|
||||
}
|
||||
}
|
||||
|
||||
return runningNodes
|
||||
})
|
||||
}
|
||||
|
||||
// Wait for any existing execution to complete
|
||||
await comfyPage.page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const nodes = window['app'].graph.nodes
|
||||
for (const node of nodes) {
|
||||
if (
|
||||
node.strokeStyles?.['running'] &&
|
||||
node.strokeStyles['running'].call(node)?.color === '#0f0'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
.catch(() => {
|
||||
// If timeout, continue anyway
|
||||
})
|
||||
|
||||
// Initially no nodes should be running
|
||||
let runningNodes = await getRunningNodes()
|
||||
expect(runningNodes).toHaveLength(0)
|
||||
|
||||
// Set up tracking for progress events
|
||||
await executionHelper.setupEventTracking()
|
||||
|
||||
// Start real execution using command to avoid click issues
|
||||
await comfyPage.executeCommand('Comfy.QueuePrompt')
|
||||
|
||||
// Wait for execution to start
|
||||
await executionHelper.waitForExecutionStart()
|
||||
|
||||
// Wait for real nested node execution progress
|
||||
await subgraphHelper.waitForNestedNodeProgress(1)
|
||||
|
||||
// Wait for parent subgraph to show as running
|
||||
await comfyPage.page.waitForFunction(
|
||||
(nodeId) => {
|
||||
const node = window['app'].graph.getNodeById(nodeId)
|
||||
if (!node?.strokeStyles?.['running']) return false
|
||||
const style = node.strokeStyles['running'].call(node)
|
||||
return style?.color === '#0f0'
|
||||
},
|
||||
10,
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
|
||||
// Parent subgraph should show as running
|
||||
runningNodes = await getRunningNodes()
|
||||
expect(runningNodes).toContain(10)
|
||||
|
||||
// Wait for the execution to complete naturally
|
||||
const testId = executionHelper.getTestId()
|
||||
await comfyPage.page.waitForFunction(
|
||||
(testId) => {
|
||||
const states = window[`__progressStates_${testId}`]
|
||||
if (!states || states.length === 0) return false
|
||||
|
||||
// Check if execution is finished (no more running nodes)
|
||||
const latestState = states[states.length - 1]
|
||||
if (!latestState.nodes) return false
|
||||
|
||||
const runningNodes = Object.values(latestState.nodes).filter(
|
||||
(node: any) => node.state === 'running'
|
||||
).length
|
||||
|
||||
return runningNodes === 0
|
||||
},
|
||||
testId,
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
|
||||
// Add a small delay to ensure UI updates
|
||||
await comfyPage.page.waitForTimeout(500)
|
||||
|
||||
// Wait for parent subgraph to no longer be running
|
||||
await comfyPage.page.waitForFunction(
|
||||
(nodeId) => {
|
||||
const node = window['app'].graph.getNodeById(nodeId)
|
||||
if (!node?.strokeStyles?.['running']) return true
|
||||
const style = node.strokeStyles['running'].call(node)
|
||||
return style?.color !== '#0f0'
|
||||
},
|
||||
10,
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
|
||||
// Parent should no longer be running
|
||||
runningNodes = await getRunningNodes()
|
||||
expect(runningNodes).not.toContain(10)
|
||||
})
|
||||
})
|
||||
@@ -72,7 +72,6 @@ import { useWorkflowAutoSave } from '@/composables/useWorkflowAutoSave'
|
||||
import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
|
||||
import { CORE_SETTINGS } from '@/constants/coreSettings'
|
||||
import { i18n, t } from '@/i18n'
|
||||
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
|
||||
import { UnauthorizedError, api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
@@ -192,22 +191,26 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
// Update the progress of the executing node
|
||||
// Update the progress of executing nodes
|
||||
watch(
|
||||
() =>
|
||||
[
|
||||
executionStore.executingNodeId,
|
||||
executionStore.executingNodeProgress
|
||||
] satisfies [NodeId | null, number | null],
|
||||
([executingNodeId, executingNodeProgress]) => {
|
||||
for (const node of comfyApp.graph.nodes) {
|
||||
if (node.id == executingNodeId) {
|
||||
node.progress = executingNodeProgress ?? undefined
|
||||
[executionStore.nodeLocationProgressStates, canvasStore.canvas] as const,
|
||||
([nodeLocationProgressStates, canvas]) => {
|
||||
if (!canvas?.graph) return
|
||||
for (const node of canvas.graph.nodes) {
|
||||
const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(node.id)
|
||||
const progressState = nodeLocationProgressStates[nodeLocatorId]
|
||||
if (progressState && progressState.state === 'running') {
|
||||
node.progress = progressState.value / progressState.max
|
||||
} else {
|
||||
node.progress = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Force canvas redraw to ensure progress updates are visible
|
||||
canvas.graph.setDirtyCanvas(true, false)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// Update node slot errors
|
||||
|
||||
@@ -20,33 +20,44 @@ import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { linkifyHtml, nl2br } from '@/utils/formatUtil'
|
||||
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
widget?: object
|
||||
nodeId: NodeId
|
||||
}>()
|
||||
|
||||
const executionStore = useExecutionStore()
|
||||
const isParentNodeExecuting = ref(true)
|
||||
const formattedText = computed(() => nl2br(linkifyHtml(modelValue.value)))
|
||||
|
||||
let executingNodeId: NodeId | null = null
|
||||
let parentNodeId: NodeId | null = null
|
||||
onMounted(() => {
|
||||
executingNodeId = executionStore.executingNodeId
|
||||
// Get the parent node ID from props if provided
|
||||
// For backward compatibility, fall back to the first executing node
|
||||
parentNodeId = props.nodeId
|
||||
})
|
||||
|
||||
// Watch for either a new node has starting execution or overall execution ending
|
||||
const stopWatching = watch(
|
||||
[() => executionStore.executingNode, () => executionStore.isIdle],
|
||||
[() => executionStore.executingNodeIds, () => executionStore.isIdle],
|
||||
() => {
|
||||
if (executionStore.isIdle) {
|
||||
isParentNodeExecuting.value = false
|
||||
stopWatching()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if parent node is no longer in the executing nodes list
|
||||
if (
|
||||
executionStore.isIdle ||
|
||||
(executionStore.executingNode &&
|
||||
executionStore.executingNode.id !== executingNodeId)
|
||||
parentNodeId &&
|
||||
!executionStore.executingNodeIds.includes(parentNodeId)
|
||||
) {
|
||||
isParentNodeExecuting.value = false
|
||||
stopWatching()
|
||||
}
|
||||
if (!executingNodeId) {
|
||||
executingNodeId = executionStore.executingNodeId
|
||||
|
||||
// Set parent node ID if not set yet
|
||||
if (!parentNodeId && executionStore.executingNodeIds.length > 0) {
|
||||
parentNodeId = executionStore.executingNodeIds[0]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useTitle } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
@@ -36,11 +37,34 @@ export const useBrowserTabTitle = () => {
|
||||
: DEFAULT_TITLE
|
||||
})
|
||||
|
||||
const nodeExecutionTitle = computed(() =>
|
||||
executionStore.executingNode && executionStore.executingNodeProgress
|
||||
? `${executionText.value}[${Math.round(executionStore.executingNodeProgress * 100)}%] ${executionStore.executingNode.type}`
|
||||
: ''
|
||||
)
|
||||
const nodeExecutionTitle = computed(() => {
|
||||
// Check if any nodes are in progress
|
||||
const nodeProgressEntries = Object.entries(
|
||||
executionStore.nodeProgressStates
|
||||
)
|
||||
const runningNodes = nodeProgressEntries.filter(
|
||||
([_, state]) => state.state === 'running'
|
||||
)
|
||||
|
||||
if (runningNodes.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// If multiple nodes are running
|
||||
if (runningNodes.length > 1) {
|
||||
return `${executionText.value}[${runningNodes.length} ${t('g.nodesRunning', 'nodes running')}]`
|
||||
}
|
||||
|
||||
// If only one node is running
|
||||
const [nodeId, state] = runningNodes[0]
|
||||
const progress = Math.round((state.value / state.max) * 100)
|
||||
const nodeType =
|
||||
executionStore.activePrompt?.workflow?.changeTracker?.activeState?.nodes.find(
|
||||
(n) => String(n.id) === nodeId
|
||||
)?.type || 'Node'
|
||||
|
||||
return `${executionText.value}[${progress}%] ${nodeType}`
|
||||
})
|
||||
|
||||
const workflowTitle = computed(
|
||||
() =>
|
||||
|
||||
@@ -23,6 +23,9 @@ export const useTextPreviewWidget = (
|
||||
name: inputSpec.name,
|
||||
component: TextPreviewWidget,
|
||||
inputSpec,
|
||||
componentProps: {
|
||||
nodeId: node.id
|
||||
},
|
||||
options: {
|
||||
getValue: () => widgetValue.value,
|
||||
setValue: (value: string | object) => {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"supports_preview_metadata": false
|
||||
"supports_preview_metadata": true
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '@/schemas/comfyWorkflowSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { useWidgetStore } from '@/stores/widgetStore'
|
||||
@@ -1224,9 +1225,10 @@ export class GroupNodeHandler {
|
||||
node.onDrawForeground = function (ctx) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
onDrawForeground?.apply?.(this, arguments)
|
||||
const progressState = useExecutionStore().nodeProgressStates[this.id]
|
||||
if (
|
||||
// @ts-expect-error fixme ts strict error
|
||||
+app.runningNodeId === this.id &&
|
||||
progressState &&
|
||||
progressState.state === 'running' &&
|
||||
this.runningInternalNodeId !== null
|
||||
) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
@@ -1340,6 +1342,7 @@ export class GroupNodeHandler {
|
||||
this.node.onRemoved = function () {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
onRemoved?.apply(this, arguments)
|
||||
// api.removeEventListener('progress_state', progress_state)
|
||||
api.removeEventListener('executing', executing)
|
||||
api.removeEventListener('executed', executed)
|
||||
}
|
||||
|
||||
@@ -133,7 +133,8 @@
|
||||
"copyURL": "Copy URL",
|
||||
"releaseTitle": "{package} {version} Release",
|
||||
"progressCountOf": "of",
|
||||
"keybindingAlreadyExists": "Keybinding already exists on"
|
||||
"keybindingAlreadyExists": "Keybinding already exists on",
|
||||
"nodesRunning": "nodes running"
|
||||
},
|
||||
"manager": {
|
||||
"title": "Custom Nodes Manager",
|
||||
|
||||
@@ -336,6 +336,7 @@
|
||||
"noTasksFoundMessage": "No hay tareas en la cola.",
|
||||
"noWorkflowsFound": "No se encontraron flujos de trabajo.",
|
||||
"nodes": "Nodos",
|
||||
"nodesRunning": "nodos en ejecución",
|
||||
"ok": "OK",
|
||||
"openNewIssue": "Abrir nuevo problema",
|
||||
"overwrite": "Sobrescribir",
|
||||
|
||||
@@ -336,6 +336,7 @@
|
||||
"noTasksFoundMessage": "Il n'y a pas de tâches dans la file d'attente.",
|
||||
"noWorkflowsFound": "Aucun flux de travail trouvé.",
|
||||
"nodes": "Nœuds",
|
||||
"nodesRunning": "nœuds en cours d’exécution",
|
||||
"ok": "OK",
|
||||
"openNewIssue": "Ouvrir un nouveau problème",
|
||||
"overwrite": "Écraser",
|
||||
|
||||
@@ -336,6 +336,7 @@
|
||||
"noTasksFoundMessage": "キューにタスクがありません。",
|
||||
"noWorkflowsFound": "ワークフローが見つかりません。",
|
||||
"nodes": "ノード",
|
||||
"nodesRunning": "ノードが実行中",
|
||||
"ok": "OK",
|
||||
"openNewIssue": "新しい問題を開く",
|
||||
"overwrite": "上書き",
|
||||
|
||||
@@ -336,6 +336,7 @@
|
||||
"noTasksFoundMessage": "대기열에 작업이 없습니다.",
|
||||
"noWorkflowsFound": "워크플로를 찾을 수 없습니다.",
|
||||
"nodes": "노드",
|
||||
"nodesRunning": "노드 실행 중",
|
||||
"ok": "확인",
|
||||
"openNewIssue": "새 문제 열기",
|
||||
"overwrite": "덮어쓰기",
|
||||
|
||||
@@ -336,6 +336,7 @@
|
||||
"noTasksFoundMessage": "В очереди нет задач.",
|
||||
"noWorkflowsFound": "Рабочие процессы не найдены.",
|
||||
"nodes": "Узлы",
|
||||
"nodesRunning": "запущено узлов",
|
||||
"ok": "ОК",
|
||||
"openNewIssue": "Открыть новую проблему",
|
||||
"overwrite": "Перезаписать",
|
||||
|
||||
@@ -336,6 +336,7 @@
|
||||
"noTasksFoundMessage": "佇列中沒有任務。",
|
||||
"noWorkflowsFound": "找不到工作流程。",
|
||||
"nodes": "節點",
|
||||
"nodesRunning": "節點執行中",
|
||||
"ok": "確定",
|
||||
"openNewIssue": "開啟新問題",
|
||||
"overwrite": "覆蓋",
|
||||
|
||||
@@ -410,4 +410,4 @@
|
||||
"pysssss_SnapToGrid": {
|
||||
"name": "總是對齊格線"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,6 +336,7 @@
|
||||
"noTasksFoundMessage": "队列中没有任务。",
|
||||
"noWorkflowsFound": "未找到工作流。",
|
||||
"nodes": "节点",
|
||||
"nodesRunning": "节点正在运行",
|
||||
"ok": "确定",
|
||||
"openNewIssue": "打开新问题",
|
||||
"overwrite": "覆盖",
|
||||
@@ -785,13 +786,13 @@
|
||||
"Toggle Bottom Panel": "切换底部面板",
|
||||
"Toggle Focus Mode": "切换专注模式",
|
||||
"Toggle Logs Bottom Panel": "切换日志底部面板",
|
||||
"Toggle Model Library Sidebar": "切换模型库侧边栏",
|
||||
"Toggle Node Library Sidebar": "切换节点库侧边栏",
|
||||
"Toggle Queue Sidebar": "切换队列侧边栏",
|
||||
"Toggle Model Library Sidebar": "切換模型庫側邊欄",
|
||||
"Toggle Node Library Sidebar": "切換節點庫側邊欄",
|
||||
"Toggle Queue Sidebar": "切換佇列側邊欄",
|
||||
"Toggle Search Box": "切换搜索框",
|
||||
"Toggle Terminal Bottom Panel": "切换终端底部面板",
|
||||
"Toggle Theme (Dark/Light)": "切换主题(暗/亮)",
|
||||
"Toggle Workflows Sidebar": "切换工作流侧边栏",
|
||||
"Toggle Workflows Sidebar": "切換工作流程側邊欄",
|
||||
"Toggle the Custom Nodes Manager": "切换自定义节点管理器",
|
||||
"Toggle the Custom Nodes Manager Progress Bar": "切换自定义节点管理器进度条",
|
||||
"Undo": "撤销",
|
||||
|
||||
@@ -410,4 +410,4 @@
|
||||
"pysssss_SnapToGrid": {
|
||||
"name": "始终吸附到网格"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,22 @@ const zProgressWsMessage = z.object({
|
||||
node: zNodeId
|
||||
})
|
||||
|
||||
const zNodeProgressState = z.object({
|
||||
value: z.number(),
|
||||
max: z.number(),
|
||||
state: z.enum(['pending', 'running', 'finished', 'error']),
|
||||
node_id: zNodeId,
|
||||
prompt_id: zPromptId,
|
||||
display_node_id: zNodeId.optional(),
|
||||
parent_node_id: zNodeId.optional(),
|
||||
real_node_id: zNodeId.optional()
|
||||
})
|
||||
|
||||
const zProgressStateWsMessage = z.object({
|
||||
prompt_id: zPromptId,
|
||||
nodes: z.record(zNodeId, zNodeProgressState)
|
||||
})
|
||||
|
||||
const zExecutingWsMessage = z.object({
|
||||
node: zNodeId,
|
||||
display_node: zNodeId,
|
||||
@@ -134,6 +150,8 @@ export type ProgressTextWsMessage = z.infer<typeof zProgressTextWsMessage>
|
||||
export type DisplayComponentWsMessage = z.infer<
|
||||
typeof zDisplayComponentWsMessage
|
||||
>
|
||||
export type NodeProgressState = z.infer<typeof zNodeProgressState>
|
||||
export type ProgressStateWsMessage = z.infer<typeof zProgressStateWsMessage>
|
||||
export type FeatureFlagsWsMessage = z.infer<typeof zFeatureFlagsWsMessage>
|
||||
// End of ws messages
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
LogsRawResponse,
|
||||
LogsWsMessage,
|
||||
PendingTaskItem,
|
||||
ProgressStateWsMessage,
|
||||
ProgressTextWsMessage,
|
||||
ProgressWsMessage,
|
||||
PromptResponse,
|
||||
@@ -105,7 +106,17 @@ interface BackendApiCalls {
|
||||
logs: LogsWsMessage
|
||||
/** Binary preview/progress data */
|
||||
b_preview: Blob
|
||||
/** Binary preview with metadata (node_id, prompt_id) */
|
||||
b_preview_with_metadata: {
|
||||
blob: Blob
|
||||
nodeId: string
|
||||
parentNodeId: string
|
||||
displayNodeId: string
|
||||
realNodeId: string
|
||||
promptId: string
|
||||
}
|
||||
progress_text: ProgressTextWsMessage
|
||||
progress_state: ProgressStateWsMessage
|
||||
display_component: DisplayComponentWsMessage
|
||||
feature_flags: FeatureFlagsWsMessage
|
||||
}
|
||||
@@ -457,6 +468,33 @@ export class ComfyApi extends EventTarget {
|
||||
})
|
||||
this.dispatchCustomEvent('b_preview', imageBlob)
|
||||
break
|
||||
case 4:
|
||||
// PREVIEW_IMAGE_WITH_METADATA
|
||||
const decoder4 = new TextDecoder()
|
||||
const metadataLength = view.getUint32(4)
|
||||
const metadataBytes = event.data.slice(8, 8 + metadataLength)
|
||||
const metadata = JSON.parse(decoder4.decode(metadataBytes))
|
||||
const imageData4 = event.data.slice(8 + metadataLength)
|
||||
|
||||
let imageMime4 = metadata.image_type
|
||||
|
||||
const imageBlob4 = new Blob([imageData4], {
|
||||
type: imageMime4
|
||||
})
|
||||
|
||||
// Dispatch enhanced preview event with metadata
|
||||
this.dispatchCustomEvent('b_preview_with_metadata', {
|
||||
blob: imageBlob4,
|
||||
nodeId: metadata.node_id,
|
||||
displayNodeId: metadata.display_node_id,
|
||||
parentNodeId: metadata.parent_node_id,
|
||||
realNodeId: metadata.real_node_id,
|
||||
promptId: metadata.prompt_id
|
||||
})
|
||||
|
||||
// Also dispatch legacy b_preview for backward compatibility
|
||||
this.dispatchCustomEvent('b_preview', imageBlob4)
|
||||
break
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown binary websocket message of type ${eventType}`
|
||||
@@ -486,6 +524,7 @@ export class ComfyApi extends EventTarget {
|
||||
case 'execution_cached':
|
||||
case 'execution_success':
|
||||
case 'progress':
|
||||
case 'progress_state':
|
||||
case 'executed':
|
||||
case 'graphChanged':
|
||||
case 'promptQueued':
|
||||
|
||||
@@ -194,6 +194,8 @@ export class ComfyApp {
|
||||
|
||||
/**
|
||||
* @deprecated Use useExecutionStore().executingNodeId instead
|
||||
* TODO: Update to support multiple executing nodes. This getter returns only the first executing node.
|
||||
* Consider updating consumers to handle multiple nodes or use executingNodeIds array.
|
||||
*/
|
||||
get runningNodeId(): NodeId | null {
|
||||
return useExecutionStore().executingNodeId
|
||||
@@ -635,10 +637,6 @@ export class ComfyApp {
|
||||
|
||||
api.addEventListener('executing', () => {
|
||||
this.graph.setDirtyCanvas(true, false)
|
||||
// @ts-expect-error fixme ts strict error
|
||||
this.revokePreviews(this.runningNodeId)
|
||||
// @ts-expect-error fixme ts strict error
|
||||
delete this.nodePreviewImages[this.runningNodeId]
|
||||
})
|
||||
|
||||
api.addEventListener('executed', ({ detail }) => {
|
||||
@@ -689,15 +687,13 @@ export class ComfyApp {
|
||||
this.canvas.draw(true, true)
|
||||
})
|
||||
|
||||
api.addEventListener('b_preview', ({ detail }) => {
|
||||
const id = this.runningNodeId
|
||||
if (id == null) return
|
||||
|
||||
const blob = detail
|
||||
api.addEventListener('b_preview_with_metadata', ({ detail }) => {
|
||||
// Enhanced preview with explicit node context
|
||||
const { blob, displayNodeId } = detail
|
||||
this.revokePreviews(displayNodeId)
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
// Ensure clean up if `executing` event is missed.
|
||||
this.revokePreviews(id)
|
||||
this.nodePreviewImages[id] = [blobUrl]
|
||||
// Preview cleanup is now handled in progress_state event to support multiple concurrent previews
|
||||
this.nodePreviewImages[displayNodeId] = [blobUrl]
|
||||
})
|
||||
|
||||
api.init()
|
||||
|
||||
@@ -237,6 +237,7 @@ export class ComponentWidgetImpl<
|
||||
component: Component
|
||||
inputSpec: InputSpec
|
||||
props?: P
|
||||
componentProps?: Partial<P>
|
||||
options: DOMWidgetOptions<V>
|
||||
}) {
|
||||
super({
|
||||
@@ -245,7 +246,9 @@ export class ComponentWidgetImpl<
|
||||
})
|
||||
this.component = obj.component
|
||||
this.inputSpec = obj.inputSpec
|
||||
this.props = obj.props
|
||||
this.props = obj.componentProps
|
||||
? ({ ...obj.props, ...obj.componentProps } as P)
|
||||
: obj.props
|
||||
}
|
||||
|
||||
override computeLayoutSize() {
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
|
||||
import { ComfyApp, app } from '@/scripts/app'
|
||||
import { $el } from '@/scripts/ui'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
@@ -107,7 +108,11 @@ export const useLitegraphService = () => {
|
||||
*/
|
||||
#setupStrokeStyles() {
|
||||
this.strokeStyles['running'] = function (this: LGraphNode) {
|
||||
if (this.id == app.runningNodeId) {
|
||||
const nodeId = String(this.id)
|
||||
const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(nodeId)
|
||||
const state =
|
||||
useExecutionStore().nodeLocationProgressStates[nodeLocatorId]?.state
|
||||
if (state === 'running') {
|
||||
return { color: '#0f0' }
|
||||
}
|
||||
}
|
||||
@@ -362,7 +367,11 @@ export const useLitegraphService = () => {
|
||||
*/
|
||||
#setupStrokeStyles() {
|
||||
this.strokeStyles['running'] = function (this: LGraphNode) {
|
||||
if (this.id == app.runningNodeId) {
|
||||
const nodeId = String(this.id)
|
||||
const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(nodeId)
|
||||
const state =
|
||||
useExecutionStore().nodeLocationProgressStates[nodeLocatorId]?.state
|
||||
if (state === 'running') {
|
||||
return { color: '#0f0' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import type {
|
||||
ExecutionErrorWsMessage,
|
||||
ExecutionStartWsMessage,
|
||||
NodeError,
|
||||
NodeProgressState,
|
||||
ProgressStateWsMessage,
|
||||
ProgressTextWsMessage,
|
||||
ProgressWsMessage
|
||||
} from '@/schemas/apiSchema'
|
||||
@@ -21,6 +23,9 @@ import type {
|
||||
NodeId
|
||||
} from '@/schemas/comfyWorkflowSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
|
||||
import { useCanvasStore } from './graphStore'
|
||||
import { ComfyWorkflow, useWorkflowStore } from './workflowStore'
|
||||
@@ -46,7 +51,97 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
const queuedPrompts = ref<Record<NodeId, QueuedPrompt>>({})
|
||||
const lastNodeErrors = ref<Record<NodeId, NodeError> | null>(null)
|
||||
const lastExecutionError = ref<ExecutionErrorWsMessage | null>(null)
|
||||
const executingNodeId = ref<NodeId | null>(null)
|
||||
// This is the progress of all nodes in the currently executing workflow
|
||||
const nodeProgressStates = ref<Record<string, NodeProgressState>>({})
|
||||
|
||||
/**
|
||||
* Convert execution context node IDs to NodeLocatorIds
|
||||
* @param nodeId The node ID from execution context (could be execution ID)
|
||||
* @returns The NodeLocatorId
|
||||
*/
|
||||
const executionIdToNodeLocatorId = (
|
||||
nodeId: string | number
|
||||
): NodeLocatorId => {
|
||||
const nodeIdStr = String(nodeId)
|
||||
|
||||
if (!nodeIdStr.includes(':')) {
|
||||
// It's a top-level node ID
|
||||
return nodeIdStr as NodeLocatorId
|
||||
}
|
||||
|
||||
// It's an execution node ID
|
||||
const parts = nodeIdStr.split(':')
|
||||
const localNodeId = parts[parts.length - 1]
|
||||
const subgraphs = getSubgraphsFromInstanceIds(app.graph, parts)
|
||||
const nodeLocatorId = createNodeLocatorId(subgraphs.at(-1)!.id, localNodeId)
|
||||
return nodeLocatorId
|
||||
}
|
||||
|
||||
const mergeExecutionProgressStates = (
|
||||
currentState: NodeProgressState | undefined,
|
||||
newState: NodeProgressState
|
||||
): NodeProgressState => {
|
||||
if (currentState === undefined) {
|
||||
return newState
|
||||
}
|
||||
|
||||
const mergedState = { ...currentState }
|
||||
if (mergedState.state === 'error') {
|
||||
return mergedState
|
||||
} else if (newState.state === 'running') {
|
||||
const newPerc = newState.max > 0 ? newState.value / newState.max : 0.0
|
||||
const oldPerc =
|
||||
mergedState.max > 0 ? mergedState.value / mergedState.max : 0.0
|
||||
if (
|
||||
mergedState.state !== 'running' ||
|
||||
oldPerc === 0.0 ||
|
||||
newPerc < oldPerc
|
||||
) {
|
||||
mergedState.value = newState.value
|
||||
mergedState.max = newState.max
|
||||
}
|
||||
mergedState.state = 'running'
|
||||
}
|
||||
|
||||
return mergedState
|
||||
}
|
||||
|
||||
const nodeLocationProgressStates = computed<
|
||||
Record<NodeLocatorId, NodeProgressState>
|
||||
>(() => {
|
||||
const result: Record<NodeLocatorId, NodeProgressState> = {}
|
||||
|
||||
const states = nodeProgressStates.value // Apparently doing this inside `Object.entries` causes issues
|
||||
for (const [_, state] of Object.entries(states)) {
|
||||
const parts = String(state.display_node_id).split(':')
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const executionId = parts.slice(0, i + 1).join(':')
|
||||
const locatorId = executionIdToNodeLocatorId(executionId)
|
||||
if (!locatorId) continue
|
||||
|
||||
result[locatorId] = mergeExecutionProgressStates(
|
||||
result[locatorId],
|
||||
state
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// Easily access all currently executing node IDs
|
||||
const executingNodeIds = computed<NodeId[]>(() => {
|
||||
return Object.entries(nodeProgressStates)
|
||||
.filter(([_, state]) => state.state === 'running')
|
||||
.map(([nodeId, _]) => nodeId)
|
||||
})
|
||||
|
||||
// @deprecated For backward compatibility - stores the primary executing node ID
|
||||
const executingNodeId = computed<NodeId | null>(() => {
|
||||
return executingNodeIds.value.length > 0 ? executingNodeIds.value[0] : null
|
||||
})
|
||||
|
||||
// For backward compatibility - returns the primary executing node
|
||||
const executingNode = computed<ComfyNode | null>(() => {
|
||||
if (!executingNodeId.value) return null
|
||||
|
||||
@@ -93,30 +188,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
return getSubgraphsFromInstanceIds(subgraph, subgraphNodeIds, subgraphs)
|
||||
}
|
||||
|
||||
const executionIdToCurrentId = (id: string) => {
|
||||
const subgraph = workflowStore.activeSubgraph
|
||||
|
||||
// Short-circuit: ID belongs to the parent workflow / no active subgraph
|
||||
if (!id.includes(':')) {
|
||||
return !subgraph ? id : undefined
|
||||
} else if (!subgraph) {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the hierarchical ID (e.g., "123:456:789")
|
||||
const subgraphNodeIds = id.split(':')
|
||||
|
||||
// If the last subgraph is the active subgraph, return the node ID
|
||||
const subgraphs = getSubgraphsFromInstanceIds(
|
||||
subgraph.rootGraph,
|
||||
subgraphNodeIds
|
||||
)
|
||||
if (subgraphs.at(-1) === subgraph) {
|
||||
return subgraphNodeIds.at(-1)
|
||||
}
|
||||
}
|
||||
|
||||
// This is the progress of the currently executing node, if any
|
||||
// This is the progress of the currently executing node (for backward compatibility)
|
||||
const _executingNodeProgress = ref<ProgressWsMessage | null>(null)
|
||||
const executingNodeProgress = computed(() =>
|
||||
_executingNodeProgress.value
|
||||
@@ -153,6 +225,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
api.addEventListener('executed', handleExecuted)
|
||||
api.addEventListener('executing', handleExecuting)
|
||||
api.addEventListener('progress', handleProgress)
|
||||
api.addEventListener('progress_state', handleProgressState)
|
||||
api.addEventListener('status', handleStatus)
|
||||
api.addEventListener('execution_error', handleExecutionError)
|
||||
}
|
||||
@@ -165,6 +238,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
api.removeEventListener('executed', handleExecuted)
|
||||
api.removeEventListener('executing', handleExecuting)
|
||||
api.removeEventListener('progress', handleProgress)
|
||||
api.removeEventListener('progress_state', handleProgressState)
|
||||
api.removeEventListener('status', handleStatus)
|
||||
api.removeEventListener('execution_error', handleExecutionError)
|
||||
api.removeEventListener('progress_text', handleProgressText)
|
||||
@@ -194,19 +268,42 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
|
||||
if (!activePrompt.value) return
|
||||
|
||||
if (executingNodeId.value && activePrompt.value) {
|
||||
// Seems sometimes nodes that are cached fire executing but not executed
|
||||
activePrompt.value.nodes[executingNodeId.value] = true
|
||||
// Update the executing nodes list
|
||||
if (typeof e.detail !== 'string') {
|
||||
if (activePromptId.value) {
|
||||
delete queuedPrompts.value[activePromptId.value]
|
||||
}
|
||||
activePromptId.value = null
|
||||
}
|
||||
if (typeof e.detail === 'string') {
|
||||
executingNodeId.value = executionIdToCurrentId(e.detail) ?? null
|
||||
} else {
|
||||
executingNodeId.value = e.detail
|
||||
if (executingNodeId.value === null) {
|
||||
if (activePromptId.value) {
|
||||
delete queuedPrompts.value[activePromptId.value]
|
||||
}
|
||||
activePromptId.value = null
|
||||
}
|
||||
|
||||
function handleProgressState(e: CustomEvent<ProgressStateWsMessage>) {
|
||||
const { nodes } = e.detail
|
||||
|
||||
// Revoke previews for nodes that are starting to execute
|
||||
for (const nodeId in nodes) {
|
||||
const nodeState = nodes[nodeId]
|
||||
if (nodeState.state === 'running' && !nodeProgressStates.value[nodeId]) {
|
||||
// This node just started executing, revoke its previews
|
||||
// Note that we're doing the *actual* node id instead of the display node id
|
||||
// here intentionally. That way, we don't clear the preview every time a new node
|
||||
// within an expanded graph starts executing.
|
||||
app.revokePreviews(nodeId)
|
||||
delete app.nodePreviewImages[nodeId]
|
||||
}
|
||||
}
|
||||
|
||||
// Update the progress states for all nodes
|
||||
nodeProgressStates.value = nodes
|
||||
|
||||
// If we have progress for the currently executing node, update it for backwards compatibility
|
||||
if (executingNodeId.value && nodes[executingNodeId.value]) {
|
||||
const nodeState = nodes[executingNodeId.value]
|
||||
_executingNodeProgress.value = {
|
||||
value: nodeState.value,
|
||||
max: nodeState.max,
|
||||
prompt_id: nodeState.prompt_id,
|
||||
node: nodeState.display_node_id || nodeState.node_id
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,7 +336,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
const { nodeId, text } = e.detail
|
||||
if (!text || !nodeId) return
|
||||
|
||||
// Handle hierarchical node IDs for subgraphs
|
||||
// Handle execution node IDs for subgraphs
|
||||
const currentId = getNodeIdIfExecuting(nodeId)
|
||||
const node = canvasStore.getCanvas().graph?.getNodeById(currentId)
|
||||
if (!node) return
|
||||
@@ -250,7 +347,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
function handleDisplayComponent(e: CustomEvent<DisplayComponentWsMessage>) {
|
||||
const { node_id: nodeId, component, props = {} } = e.detail
|
||||
|
||||
// Handle hierarchical node IDs for subgraphs
|
||||
// Handle execution node IDs for subgraphs
|
||||
const currentId = getNodeIdIfExecuting(nodeId)
|
||||
const node = canvasStore.getCanvas().graph?.getNodeById(currentId)
|
||||
if (!node) return
|
||||
@@ -290,6 +387,18 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a NodeLocatorId to an execution context ID
|
||||
* @param locatorId The NodeLocatorId
|
||||
* @returns The execution ID or null if conversion fails
|
||||
*/
|
||||
const nodeLocatorIdToExecutionId = (
|
||||
locatorId: NodeLocatorId | string
|
||||
): string | null => {
|
||||
const executionId = workflowStore.nodeLocatorIdToNodeExecutionId(locatorId)
|
||||
return executionId
|
||||
}
|
||||
|
||||
return {
|
||||
isIdle,
|
||||
clientId,
|
||||
@@ -310,9 +419,13 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
*/
|
||||
lastExecutionError,
|
||||
/**
|
||||
* The id of the node that is currently being executed
|
||||
* The id of the node that is currently being executed (backward compatibility)
|
||||
*/
|
||||
executingNodeId,
|
||||
/**
|
||||
* The list of all nodes that are currently executing
|
||||
*/
|
||||
executingNodeIds,
|
||||
/**
|
||||
* The prompt that is currently being executed
|
||||
*/
|
||||
@@ -330,17 +443,25 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
*/
|
||||
executionProgress,
|
||||
/**
|
||||
* The node that is currently being executed
|
||||
* The node that is currently being executed (backward compatibility)
|
||||
*/
|
||||
executingNode,
|
||||
/**
|
||||
* The progress of the executing node (if the node reports progress)
|
||||
* The progress of the executing node (backward compatibility)
|
||||
*/
|
||||
executingNodeProgress,
|
||||
/**
|
||||
* All node progress states from progress_state events
|
||||
*/
|
||||
nodeProgressStates,
|
||||
nodeLocationProgressStates,
|
||||
bindExecutionEvents,
|
||||
unbindExecutionEvents,
|
||||
storePrompt,
|
||||
// Raw executing progress data for backward compatibility in ComfyApp.
|
||||
_executingNodeProgress
|
||||
_executingNodeProgress,
|
||||
// NodeLocatorId conversion helpers
|
||||
executionIdToNodeLocatorId,
|
||||
nodeLocatorIdToExecutionId
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,10 +4,18 @@ import { defineStore } from 'pinia'
|
||||
import { type Raw, computed, markRaw, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
||||
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { defaultGraphJSON } from '@/scripts/defaultGraph'
|
||||
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import {
|
||||
createNodeExecutionId,
|
||||
createNodeLocatorId,
|
||||
parseNodeExecutionId,
|
||||
parseNodeLocatorId
|
||||
} from '@/types/nodeIdentification'
|
||||
import { getPathDetails } from '@/utils/formatUtil'
|
||||
import { syncEntities } from '@/utils/syncUtil'
|
||||
import { isSubgraph } from '@/utils/typeGuardUtil'
|
||||
@@ -163,6 +171,15 @@ export interface WorkflowStore {
|
||||
/** Updates the {@link subgraphNamePath} and {@link isSubgraphActive} values. */
|
||||
updateActiveGraph: () => void
|
||||
executionIdToCurrentId: (id: string) => any
|
||||
nodeIdToNodeLocatorId: (nodeId: NodeId, subgraph?: Subgraph) => NodeLocatorId
|
||||
nodeExecutionIdToNodeLocatorId: (
|
||||
nodeExecutionId: NodeExecutionId | string
|
||||
) => NodeLocatorId | null
|
||||
nodeLocatorIdToNodeId: (locatorId: NodeLocatorId | string) => NodeId | null
|
||||
nodeLocatorIdToNodeExecutionId: (
|
||||
locatorId: NodeLocatorId | string,
|
||||
targetSubgraph?: Subgraph
|
||||
) => NodeExecutionId | null
|
||||
}
|
||||
|
||||
export const useWorkflowStore = defineStore('workflow', () => {
|
||||
@@ -473,7 +490,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the hierarchical ID (e.g., "123:456:789")
|
||||
// Parse the execution ID (e.g., "123:456:789")
|
||||
const subgraphNodeIds = id.split(':')
|
||||
|
||||
// Start from the root graph
|
||||
@@ -488,6 +505,136 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
|
||||
watch(activeWorkflow, updateActiveGraph)
|
||||
|
||||
/**
|
||||
* Convert a node ID to a NodeLocatorId
|
||||
* @param nodeId The local node ID
|
||||
* @param subgraph The subgraph containing the node (defaults to active subgraph)
|
||||
* @returns The NodeLocatorId (for root graph nodes, returns the node ID as-is)
|
||||
*/
|
||||
const nodeIdToNodeLocatorId = (
|
||||
nodeId: NodeId,
|
||||
subgraph?: Subgraph
|
||||
): NodeLocatorId => {
|
||||
const targetSubgraph = subgraph ?? activeSubgraph.value
|
||||
if (!targetSubgraph) {
|
||||
// Node is in the root graph, return the node ID as-is
|
||||
return String(nodeId) as NodeLocatorId
|
||||
}
|
||||
|
||||
return createNodeLocatorId(targetSubgraph.id, nodeId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an execution ID to a NodeLocatorId
|
||||
* @param nodeExecutionId The execution node ID (e.g., "123:456:789")
|
||||
* @returns The NodeLocatorId or null if conversion fails
|
||||
*/
|
||||
const nodeExecutionIdToNodeLocatorId = (
|
||||
nodeExecutionId: NodeExecutionId | string
|
||||
): NodeLocatorId | null => {
|
||||
// Handle simple node IDs (root graph - no colons)
|
||||
if (!nodeExecutionId.includes(':')) {
|
||||
return nodeExecutionId as NodeLocatorId
|
||||
}
|
||||
|
||||
const parts = parseNodeExecutionId(nodeExecutionId)
|
||||
if (!parts || parts.length === 0) return null
|
||||
|
||||
const nodeId = parts[parts.length - 1]
|
||||
const subgraphNodeIds = parts.slice(0, -1)
|
||||
|
||||
if (subgraphNodeIds.length === 0) {
|
||||
// Node is in root graph, return the node ID as-is
|
||||
return String(nodeId) as NodeLocatorId
|
||||
}
|
||||
|
||||
try {
|
||||
const subgraphs = getSubgraphsFromInstanceIds(
|
||||
comfyApp.graph,
|
||||
subgraphNodeIds.map((id) => String(id))
|
||||
)
|
||||
const immediateSubgraph = subgraphs[subgraphs.length - 1]
|
||||
return createNodeLocatorId(immediateSubgraph.id, nodeId)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the node ID from a NodeLocatorId
|
||||
* @param locatorId The NodeLocatorId
|
||||
* @returns The local node ID or null if invalid
|
||||
*/
|
||||
const nodeLocatorIdToNodeId = (
|
||||
locatorId: NodeLocatorId | string
|
||||
): NodeId | null => {
|
||||
const parsed = parseNodeLocatorId(locatorId)
|
||||
return parsed?.localNodeId ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a NodeLocatorId to an execution ID for a specific context
|
||||
* @param locatorId The NodeLocatorId
|
||||
* @param targetSubgraph The subgraph context (defaults to active subgraph)
|
||||
* @returns The execution ID or null if the node is not accessible from the target context
|
||||
*/
|
||||
const nodeLocatorIdToNodeExecutionId = (
|
||||
locatorId: NodeLocatorId | string,
|
||||
targetSubgraph?: Subgraph
|
||||
): NodeExecutionId | null => {
|
||||
const parsed = parseNodeLocatorId(locatorId)
|
||||
if (!parsed) return null
|
||||
|
||||
const { subgraphUuid, localNodeId } = parsed
|
||||
|
||||
// If no subgraph UUID, this is a root graph node
|
||||
if (!subgraphUuid) {
|
||||
return String(localNodeId) as NodeExecutionId
|
||||
}
|
||||
|
||||
// Find the path from root to the subgraph with this UUID
|
||||
const findSubgraphPath = (
|
||||
graph: LGraph | Subgraph,
|
||||
targetUuid: string,
|
||||
path: NodeId[] = []
|
||||
): NodeId[] | null => {
|
||||
if (isSubgraph(graph) && graph.id === targetUuid) {
|
||||
return path
|
||||
}
|
||||
|
||||
for (const node of graph._nodes) {
|
||||
if (node.isSubgraphNode?.() && (node as any).subgraph) {
|
||||
const result = findSubgraphPath((node as any).subgraph, targetUuid, [
|
||||
...path,
|
||||
node.id
|
||||
])
|
||||
if (result) return result
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const path = findSubgraphPath(comfyApp.graph, subgraphUuid)
|
||||
if (!path) return null
|
||||
|
||||
// If we have a target subgraph, check if the path goes through it
|
||||
if (
|
||||
targetSubgraph &&
|
||||
!path.some((_, idx) => {
|
||||
const subgraphs = getSubgraphsFromInstanceIds(
|
||||
comfyApp.graph,
|
||||
path.slice(0, idx + 1).map((id) => String(id))
|
||||
)
|
||||
return subgraphs[subgraphs.length - 1] === targetSubgraph
|
||||
})
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return createNodeExecutionId([...path, localNodeId])
|
||||
}
|
||||
|
||||
return {
|
||||
activeWorkflow,
|
||||
isActive,
|
||||
@@ -514,7 +661,11 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
isSubgraphActive,
|
||||
activeSubgraph,
|
||||
updateActiveGraph,
|
||||
executionIdToCurrentId
|
||||
executionIdToCurrentId,
|
||||
nodeIdToNodeLocatorId,
|
||||
nodeExecutionIdToNodeLocatorId,
|
||||
nodeLocatorIdToNodeId,
|
||||
nodeLocatorIdToNodeExecutionId
|
||||
}
|
||||
}) satisfies () => WorkflowStore
|
||||
|
||||
|
||||
@@ -31,6 +31,16 @@ export type { ComfyApi } from '@/scripts/api'
|
||||
export type { ComfyApp } from '@/scripts/app'
|
||||
export type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
export type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
export type {
|
||||
NodeLocatorId,
|
||||
NodeExecutionId,
|
||||
isNodeLocatorId,
|
||||
isNodeExecutionId,
|
||||
parseNodeLocatorId,
|
||||
createNodeLocatorId,
|
||||
parseNodeExecutionId,
|
||||
createNodeExecutionId
|
||||
} from './nodeIdentification'
|
||||
export type {
|
||||
EmbeddingsResponse,
|
||||
ExtensionsResponse,
|
||||
|
||||
123
src/types/nodeIdentification.ts
Normal file
123
src/types/nodeIdentification.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
|
||||
|
||||
/**
|
||||
* A globally unique identifier for nodes that maintains consistency across
|
||||
* multiple instances of the same subgraph.
|
||||
*
|
||||
* Format:
|
||||
* - For subgraph nodes: `<immediate-contained-subgraph-uuid>:<local-node-id>`
|
||||
* - For root graph nodes: `<local-node-id>`
|
||||
*
|
||||
* Examples:
|
||||
* - "a1b2c3d4-e5f6-7890-abcd-ef1234567890:123" (node in subgraph)
|
||||
* - "456" (node in root graph)
|
||||
*
|
||||
* Unlike execution IDs which change based on the instance path,
|
||||
* NodeLocatorId remains the same for all instances of a particular node.
|
||||
*/
|
||||
export type NodeLocatorId = string
|
||||
|
||||
/**
|
||||
* An execution identifier representing a node's position in nested subgraphs.
|
||||
* Also known as ExecutionId in some contexts.
|
||||
*
|
||||
* Format: Colon-separated path of node IDs
|
||||
* Example: "123:456:789" (node 789 in subgraph 456 in subgraph 123)
|
||||
*/
|
||||
export type NodeExecutionId = string
|
||||
|
||||
/**
|
||||
* Type guard to check if a value is a NodeLocatorId
|
||||
*/
|
||||
export function isNodeLocatorId(value: unknown): value is NodeLocatorId {
|
||||
if (typeof value !== 'string') return false
|
||||
|
||||
// Check if it's a simple node ID (root graph node)
|
||||
const parts = value.split(':')
|
||||
if (parts.length === 1) {
|
||||
// Simple node ID - must be non-empty
|
||||
return value.length > 0
|
||||
}
|
||||
|
||||
// Check for UUID:nodeId format
|
||||
if (parts.length !== 2) return false
|
||||
|
||||
// Check that node ID part is not empty
|
||||
if (!parts[1]) return false
|
||||
|
||||
// Basic UUID format check (8-4-4-4-12 hex characters)
|
||||
const uuidPattern =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
return uuidPattern.test(parts[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a value is a NodeExecutionId
|
||||
*/
|
||||
export function isNodeExecutionId(value: unknown): value is NodeExecutionId {
|
||||
if (typeof value !== 'string') return false
|
||||
// Must contain at least one colon to be an execution ID
|
||||
return value.includes(':')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a NodeLocatorId into its components
|
||||
* @param id The NodeLocatorId to parse
|
||||
* @returns The subgraph UUID and local node ID, or null if invalid
|
||||
*/
|
||||
export function parseNodeLocatorId(
|
||||
id: string
|
||||
): { subgraphUuid: string | null; localNodeId: NodeId } | null {
|
||||
if (!isNodeLocatorId(id)) return null
|
||||
|
||||
const parts = id.split(':')
|
||||
|
||||
if (parts.length === 1) {
|
||||
// Simple node ID (root graph)
|
||||
return {
|
||||
subgraphUuid: null,
|
||||
localNodeId: isNaN(Number(id)) ? id : Number(id)
|
||||
}
|
||||
}
|
||||
|
||||
const [subgraphUuid, localNodeId] = parts
|
||||
return {
|
||||
subgraphUuid,
|
||||
localNodeId: isNaN(Number(localNodeId)) ? localNodeId : Number(localNodeId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a NodeLocatorId from components
|
||||
* @param subgraphUuid The UUID of the immediate containing subgraph
|
||||
* @param localNodeId The local node ID within that subgraph
|
||||
* @returns A properly formatted NodeLocatorId
|
||||
*/
|
||||
export function createNodeLocatorId(
|
||||
subgraphUuid: string,
|
||||
localNodeId: NodeId
|
||||
): NodeLocatorId {
|
||||
return `${subgraphUuid}:${localNodeId}` as NodeLocatorId
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a NodeExecutionId into its component node IDs
|
||||
* @param id The NodeExecutionId to parse
|
||||
* @returns Array of node IDs from root to target, or null if not an execution ID
|
||||
*/
|
||||
export function parseNodeExecutionId(id: string): NodeId[] | null {
|
||||
if (!isNodeExecutionId(id)) return null
|
||||
|
||||
return id
|
||||
.split(':')
|
||||
.map((part) => (isNaN(Number(part)) ? part : Number(part)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a NodeExecutionId from an array of node IDs
|
||||
* @param nodeIds Array of node IDs from root to target
|
||||
* @returns A properly formatted NodeExecutionId
|
||||
*/
|
||||
export function createNodeExecutionId(nodeIds: NodeId[]): NodeExecutionId {
|
||||
return nodeIds.join(':') as NodeExecutionId
|
||||
}
|
||||
@@ -3,12 +3,20 @@ import { nextTick, reactive } from 'vue'
|
||||
|
||||
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
|
||||
|
||||
// Mock i18n module
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string, fallback: string) =>
|
||||
key === 'g.nodesRunning' ? 'nodes running' : fallback
|
||||
}))
|
||||
|
||||
// Mock the execution store
|
||||
const executionStore = reactive({
|
||||
isIdle: true,
|
||||
executionProgress: 0,
|
||||
executingNode: null as any,
|
||||
executingNodeProgress: 0
|
||||
executingNodeProgress: 0,
|
||||
nodeProgressStates: {} as any,
|
||||
activePrompt: null as any
|
||||
})
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => executionStore
|
||||
@@ -37,6 +45,8 @@ describe('useBrowserTabTitle', () => {
|
||||
executionStore.executionProgress = 0
|
||||
executionStore.executingNode = null as any
|
||||
executionStore.executingNodeProgress = 0
|
||||
executionStore.nodeProgressStates = {}
|
||||
executionStore.activePrompt = null
|
||||
|
||||
// reset setting and workflow stores
|
||||
;(settingStore.get as any).mockReturnValue('Enabled')
|
||||
@@ -97,13 +107,41 @@ describe('useBrowserTabTitle', () => {
|
||||
expect(document.title).toBe('[30%]ComfyUI')
|
||||
})
|
||||
|
||||
it('shows node execution title when executing a node', async () => {
|
||||
it('shows node execution title when executing a node using nodeProgressStates', async () => {
|
||||
executionStore.isIdle = false
|
||||
executionStore.executionProgress = 0.4
|
||||
executionStore.executingNodeProgress = 0.5
|
||||
executionStore.executingNode = { type: 'Foo' }
|
||||
executionStore.nodeProgressStates = {
|
||||
'1': { state: 'running', value: 5, max: 10, node: '1', prompt_id: 'test' }
|
||||
}
|
||||
executionStore.activePrompt = {
|
||||
workflow: {
|
||||
changeTracker: {
|
||||
activeState: {
|
||||
nodes: [{ id: 1, type: 'Foo' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
useBrowserTabTitle()
|
||||
await nextTick()
|
||||
expect(document.title).toBe('[40%][50%] Foo')
|
||||
})
|
||||
|
||||
it('shows multiple nodes running when multiple nodes are executing', async () => {
|
||||
executionStore.isIdle = false
|
||||
executionStore.executionProgress = 0.4
|
||||
executionStore.nodeProgressStates = {
|
||||
'1': {
|
||||
state: 'running',
|
||||
value: 5,
|
||||
max: 10,
|
||||
node: '1',
|
||||
prompt_id: 'test'
|
||||
},
|
||||
'2': { state: 'running', value: 8, max: 10, node: '2', prompt_id: 'test' }
|
||||
}
|
||||
useBrowserTabTitle()
|
||||
await nextTick()
|
||||
expect(document.title).toBe('[40%][2 nodes running]')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useWorkflowStore } from '@/stores/workflowStore'
|
||||
|
||||
// Mock the workflowStore
|
||||
vi.mock('@/stores/workflowStore', () => ({
|
||||
useWorkflowStore: vi.fn(() => ({
|
||||
nodeExecutionIdToNodeLocatorId: vi.fn(),
|
||||
nodeIdToNodeLocatorId: vi.fn(),
|
||||
nodeLocatorIdToNodeExecutionId: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
// Remove any previous global types
|
||||
declare global {
|
||||
// Empty interface to override any previous declarations
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
interface Window {}
|
||||
}
|
||||
|
||||
@@ -22,12 +33,16 @@ vi.mock('@/composables/node/useNodeProgressText', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
// Create a local mock instead of using global to avoid conflicts
|
||||
const mockApp = {
|
||||
graph: {
|
||||
getNodeById: vi.fn()
|
||||
// Mock the app import with proper implementation
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
graph: {
|
||||
getNodeById: vi.fn()
|
||||
},
|
||||
revokePreviews: vi.fn(),
|
||||
nodePreviewImages: {}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
describe('executionStore - display_component handling', () => {
|
||||
function createDisplayComponentEvent(
|
||||
@@ -47,7 +62,7 @@ describe('executionStore - display_component handling', () => {
|
||||
|
||||
function handleDisplayComponentMessage(event: CustomEvent) {
|
||||
const { node_id, component } = event.detail
|
||||
const node = mockApp.graph.getNodeById(node_id)
|
||||
const node = vi.mocked(app.graph.getNodeById)(node_id)
|
||||
if (node && component === 'ChatHistoryWidget') {
|
||||
mockShowChatHistory(node)
|
||||
}
|
||||
@@ -60,23 +75,121 @@ describe('executionStore - display_component handling', () => {
|
||||
})
|
||||
|
||||
it('handles ChatHistoryWidget display_component messages', () => {
|
||||
const mockNode = { id: '123' }
|
||||
mockApp.graph.getNodeById.mockReturnValue(mockNode)
|
||||
const mockNode = { id: '123' } as any
|
||||
vi.mocked(app.graph.getNodeById).mockReturnValue(mockNode)
|
||||
|
||||
const event = createDisplayComponentEvent('123')
|
||||
handleDisplayComponentMessage(event)
|
||||
|
||||
expect(mockApp.graph.getNodeById).toHaveBeenCalledWith('123')
|
||||
expect(app.graph.getNodeById).toHaveBeenCalledWith('123')
|
||||
expect(mockShowChatHistory).toHaveBeenCalledWith(mockNode)
|
||||
})
|
||||
|
||||
it('does nothing if node is not found', () => {
|
||||
mockApp.graph.getNodeById.mockReturnValue(null)
|
||||
vi.mocked(app.graph.getNodeById).mockReturnValue(null)
|
||||
|
||||
const event = createDisplayComponentEvent('non-existent')
|
||||
handleDisplayComponentMessage(event)
|
||||
|
||||
expect(mockApp.graph.getNodeById).toHaveBeenCalledWith('non-existent')
|
||||
expect(app.graph.getNodeById).toHaveBeenCalledWith('non-existent')
|
||||
expect(mockShowChatHistory).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionStore - NodeLocatorId conversions', () => {
|
||||
let store: ReturnType<typeof useExecutionStore>
|
||||
let workflowStore: ReturnType<typeof useWorkflowStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
|
||||
// Create the mock workflowStore instance
|
||||
const mockWorkflowStore = {
|
||||
nodeExecutionIdToNodeLocatorId: vi.fn(),
|
||||
nodeIdToNodeLocatorId: vi.fn(),
|
||||
nodeLocatorIdToNodeExecutionId: vi.fn()
|
||||
}
|
||||
|
||||
// Mock the useWorkflowStore function to return our mock
|
||||
vi.mocked(useWorkflowStore).mockReturnValue(mockWorkflowStore as any)
|
||||
|
||||
workflowStore = mockWorkflowStore as any
|
||||
store = useExecutionStore()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('executionIdToNodeLocatorId', () => {
|
||||
it('should convert execution ID to NodeLocatorId', () => {
|
||||
// Mock subgraph structure
|
||||
const mockSubgraph = {
|
||||
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||
_nodes: []
|
||||
}
|
||||
|
||||
const mockNode = {
|
||||
id: 123,
|
||||
isSubgraphNode: () => true,
|
||||
subgraph: mockSubgraph
|
||||
} as any
|
||||
|
||||
// Mock app.graph.getNodeById to return the mock node
|
||||
vi.mocked(app.graph.getNodeById).mockReturnValue(mockNode)
|
||||
|
||||
const result = store.executionIdToNodeLocatorId('123:456')
|
||||
|
||||
expect(result).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890:456')
|
||||
})
|
||||
|
||||
it('should convert simple node ID to NodeLocatorId', () => {
|
||||
const result = store.executionIdToNodeLocatorId('123')
|
||||
|
||||
// For simple node IDs, it should return the ID as-is
|
||||
expect(result).toBe('123')
|
||||
})
|
||||
|
||||
it('should handle numeric node IDs', () => {
|
||||
const result = store.executionIdToNodeLocatorId(123)
|
||||
|
||||
// For numeric IDs, it should convert to string and return as-is
|
||||
expect(result).toBe('123')
|
||||
})
|
||||
|
||||
it('should return null when conversion fails', () => {
|
||||
// Mock app.graph.getNodeById to return null (node not found)
|
||||
vi.mocked(app.graph.getNodeById).mockReturnValue(null)
|
||||
|
||||
// This should throw an error as the node is not found
|
||||
expect(() => store.executionIdToNodeLocatorId('999:456')).toThrow(
|
||||
'Subgraph not found: 999'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nodeLocatorIdToExecutionId', () => {
|
||||
it('should convert NodeLocatorId to execution ID', () => {
|
||||
const mockExecutionId = '123:456'
|
||||
vi.spyOn(workflowStore, 'nodeLocatorIdToNodeExecutionId').mockReturnValue(
|
||||
mockExecutionId as any
|
||||
)
|
||||
|
||||
const result = store.nodeLocatorIdToExecutionId(
|
||||
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456'
|
||||
)
|
||||
|
||||
expect(workflowStore.nodeLocatorIdToNodeExecutionId).toHaveBeenCalledWith(
|
||||
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456'
|
||||
)
|
||||
expect(result).toBe(mockExecutionId)
|
||||
})
|
||||
|
||||
it('should return null when conversion fails', () => {
|
||||
vi.spyOn(workflowStore, 'nodeLocatorIdToNodeExecutionId').mockReturnValue(
|
||||
null
|
||||
)
|
||||
|
||||
const result = store.nodeLocatorIdToExecutionId('invalid:format')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Subgraph } from '@comfyorg/litegraph'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
useWorkflowBookmarkStore,
|
||||
useWorkflowStore
|
||||
} from '@/stores/workflowStore'
|
||||
import { isSubgraph } from '@/utils/typeGuardUtil'
|
||||
|
||||
// Add mock for api at the top of the file
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
@@ -26,10 +28,15 @@ vi.mock('@/scripts/api', () => ({
|
||||
// Mock comfyApp globally for the store setup
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: null // Start with canvas potentially undefined or null
|
||||
canvas: {} // Start with empty canvas object
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock isSubgraph
|
||||
vi.mock('@/utils/typeGuardUtil', () => ({
|
||||
isSubgraph: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
describe('useWorkflowStore', () => {
|
||||
let store: ReturnType<typeof useWorkflowStore>
|
||||
let bookmarkStore: ReturnType<typeof useWorkflowBookmarkStore>
|
||||
@@ -518,8 +525,13 @@ describe('useWorkflowStore', () => {
|
||||
{ name: 'Level 1 Subgraph' },
|
||||
{ name: 'Level 2 Subgraph' }
|
||||
]
|
||||
}
|
||||
vi.mocked(comfyApp.canvas).subgraph = mockSubgraph as any
|
||||
} as any
|
||||
vi.mocked(comfyApp.canvas).subgraph = mockSubgraph
|
||||
|
||||
// Mock isSubgraph to return true for our mockSubgraph
|
||||
vi.mocked(isSubgraph).mockImplementation(
|
||||
(obj): obj is Subgraph => obj === mockSubgraph
|
||||
)
|
||||
|
||||
// Act: Trigger the update
|
||||
store.updateActiveGraph()
|
||||
@@ -536,8 +548,13 @@ describe('useWorkflowStore', () => {
|
||||
name: 'Initial Subgraph',
|
||||
pathToRootGraph: [{ name: 'Root' }, { name: 'Initial Subgraph' }],
|
||||
isRootGraph: false
|
||||
}
|
||||
vi.mocked(comfyApp.canvas).subgraph = initialSubgraph as any
|
||||
} as any
|
||||
vi.mocked(comfyApp.canvas).subgraph = initialSubgraph
|
||||
|
||||
// Mock isSubgraph to return true for our initialSubgraph
|
||||
vi.mocked(isSubgraph).mockImplementation(
|
||||
(obj): obj is Subgraph => obj === initialSubgraph
|
||||
)
|
||||
|
||||
// Trigger initial update based on the *first* workflow opened in beforeEach
|
||||
store.updateActiveGraph()
|
||||
@@ -561,6 +578,11 @@ describe('useWorkflowStore', () => {
|
||||
// This ensures the watcher *does* cause a state change we can assert
|
||||
vi.mocked(comfyApp.canvas).subgraph = undefined
|
||||
|
||||
// Mock isSubgraph to return false for undefined
|
||||
vi.mocked(isSubgraph).mockImplementation(
|
||||
(_obj): _obj is Subgraph => false
|
||||
)
|
||||
|
||||
await store.openWorkflow(workflow2) // This changes activeWorkflow and triggers the watch
|
||||
await nextTick() // Allow watcher and potential async operations in updateActiveGraph to complete
|
||||
|
||||
@@ -569,4 +591,131 @@ describe('useWorkflowStore', () => {
|
||||
expect(store.activeSubgraph).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('NodeLocatorId conversions', () => {
|
||||
beforeEach(() => {
|
||||
// Setup mock graph structure with subgraphs
|
||||
const mockSubgraph = {
|
||||
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||
_nodes: []
|
||||
}
|
||||
|
||||
const mockNode = {
|
||||
id: 123,
|
||||
isSubgraphNode: () => true,
|
||||
subgraph: mockSubgraph
|
||||
}
|
||||
|
||||
const mockRootGraph = {
|
||||
_nodes: [mockNode],
|
||||
subgraphs: new Map([[mockSubgraph.id, mockSubgraph]]),
|
||||
getNodeById: (id: string | number) => {
|
||||
if (String(id) === '123') return mockNode
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
vi.mocked(comfyApp).graph = mockRootGraph as any
|
||||
vi.mocked(comfyApp.canvas).subgraph = mockSubgraph as any
|
||||
store.activeSubgraph = mockSubgraph as any
|
||||
})
|
||||
|
||||
describe('nodeIdToNodeLocatorId', () => {
|
||||
it('should convert node ID to NodeLocatorId for subgraph nodes', () => {
|
||||
const result = store.nodeIdToNodeLocatorId(456)
|
||||
expect(result).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890:456')
|
||||
})
|
||||
|
||||
it('should return simple node ID for root graph nodes', () => {
|
||||
store.activeSubgraph = undefined
|
||||
const result = store.nodeIdToNodeLocatorId(123)
|
||||
expect(result).toBe('123')
|
||||
})
|
||||
|
||||
it('should use provided subgraph instead of active one', () => {
|
||||
const customSubgraph = {
|
||||
id: 'custom-uuid-1234-5678-90ab-cdef12345678'
|
||||
} as any
|
||||
const result = store.nodeIdToNodeLocatorId(789, customSubgraph)
|
||||
expect(result).toBe('custom-uuid-1234-5678-90ab-cdef12345678:789')
|
||||
})
|
||||
})
|
||||
|
||||
describe('nodeExecutionIdToNodeLocatorId', () => {
|
||||
it('should convert execution ID to NodeLocatorId', () => {
|
||||
const result = store.nodeExecutionIdToNodeLocatorId('123:456')
|
||||
expect(result).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890:456')
|
||||
})
|
||||
|
||||
it('should return simple node ID for root level nodes', () => {
|
||||
const result = store.nodeExecutionIdToNodeLocatorId('123')
|
||||
expect(result).toBe('123')
|
||||
})
|
||||
|
||||
it('should return null for invalid execution IDs', () => {
|
||||
const result = store.nodeExecutionIdToNodeLocatorId('999:456')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('nodeLocatorIdToNodeId', () => {
|
||||
it('should extract node ID from NodeLocatorId', () => {
|
||||
const result = store.nodeLocatorIdToNodeId(
|
||||
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456'
|
||||
)
|
||||
expect(result).toBe(456)
|
||||
})
|
||||
|
||||
it('should handle string node IDs', () => {
|
||||
const result = store.nodeLocatorIdToNodeId(
|
||||
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:node_1'
|
||||
)
|
||||
expect(result).toBe('node_1')
|
||||
})
|
||||
|
||||
it('should handle simple node IDs (root graph)', () => {
|
||||
const result = store.nodeLocatorIdToNodeId('123')
|
||||
expect(result).toBe(123)
|
||||
|
||||
const stringResult = store.nodeLocatorIdToNodeId('node_1')
|
||||
expect(stringResult).toBe('node_1')
|
||||
})
|
||||
|
||||
it('should return null for invalid NodeLocatorId', () => {
|
||||
const result = store.nodeLocatorIdToNodeId('invalid:format')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('nodeLocatorIdToNodeExecutionId', () => {
|
||||
it('should convert NodeLocatorId to execution ID', () => {
|
||||
// Need to mock isSubgraph to identify our mockSubgraph
|
||||
vi.mocked(isSubgraph).mockImplementation((obj): obj is Subgraph => {
|
||||
return obj === store.activeSubgraph
|
||||
})
|
||||
|
||||
const result = store.nodeLocatorIdToNodeExecutionId(
|
||||
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456'
|
||||
)
|
||||
expect(result).toBe('123:456')
|
||||
})
|
||||
|
||||
it('should handle simple node IDs (root graph)', () => {
|
||||
const result = store.nodeLocatorIdToNodeExecutionId('123')
|
||||
expect(result).toBe('123')
|
||||
})
|
||||
|
||||
it('should return null for unknown subgraph UUID', () => {
|
||||
const result = store.nodeLocatorIdToNodeExecutionId(
|
||||
'unknown-uuid-1234-5678-90ab-cdef12345678:456'
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null for invalid NodeLocatorId', () => {
|
||||
const result = store.nodeLocatorIdToNodeExecutionId('invalid:format')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
207
tests-ui/tests/types/nodeIdentification.test.ts
Normal file
207
tests-ui/tests/types/nodeIdentification.test.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
|
||||
import {
|
||||
type NodeLocatorId,
|
||||
createNodeExecutionId,
|
||||
createNodeLocatorId,
|
||||
isNodeExecutionId,
|
||||
isNodeLocatorId,
|
||||
parseNodeExecutionId,
|
||||
parseNodeLocatorId
|
||||
} from '@/types/nodeIdentification'
|
||||
|
||||
describe('nodeIdentification', () => {
|
||||
describe('NodeLocatorId', () => {
|
||||
const validUuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||
const validNodeId = '123'
|
||||
const validNodeLocatorId = `${validUuid}:${validNodeId}` as NodeLocatorId
|
||||
|
||||
describe('isNodeLocatorId', () => {
|
||||
it('should return true for valid NodeLocatorId', () => {
|
||||
expect(isNodeLocatorId(validNodeLocatorId)).toBe(true)
|
||||
expect(isNodeLocatorId(`${validUuid}:456`)).toBe(true)
|
||||
expect(isNodeLocatorId(`${validUuid}:node_1`)).toBe(true)
|
||||
// Simple node IDs (root graph)
|
||||
expect(isNodeLocatorId('123')).toBe(true)
|
||||
expect(isNodeLocatorId('node_1')).toBe(true)
|
||||
expect(isNodeLocatorId('5')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for invalid formats', () => {
|
||||
expect(isNodeLocatorId('123:456')).toBe(false) // No UUID in first part
|
||||
expect(isNodeLocatorId('not-a-uuid:123')).toBe(false)
|
||||
expect(isNodeLocatorId('')).toBe(false) // Empty string
|
||||
expect(isNodeLocatorId(':123')).toBe(false) // Empty UUID
|
||||
expect(isNodeLocatorId(`${validUuid}:`)).toBe(false) // Empty node ID
|
||||
expect(isNodeLocatorId(`${validUuid}:123:456`)).toBe(false) // Too many parts
|
||||
expect(isNodeLocatorId(123)).toBe(false) // Not a string
|
||||
expect(isNodeLocatorId(null)).toBe(false)
|
||||
expect(isNodeLocatorId(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('should validate UUID format correctly', () => {
|
||||
// Valid UUID formats
|
||||
expect(
|
||||
isNodeLocatorId('00000000-0000-0000-0000-000000000000:123')
|
||||
).toBe(true)
|
||||
expect(
|
||||
isNodeLocatorId('A1B2C3D4-E5F6-7890-ABCD-EF1234567890:123')
|
||||
).toBe(true)
|
||||
|
||||
// Invalid UUID formats
|
||||
expect(isNodeLocatorId('00000000-0000-0000-0000-00000000000:123')).toBe(
|
||||
false
|
||||
) // Too short
|
||||
expect(
|
||||
isNodeLocatorId('00000000-0000-0000-0000-0000000000000:123')
|
||||
).toBe(false) // Too long
|
||||
expect(
|
||||
isNodeLocatorId('00000000_0000_0000_0000_000000000000:123')
|
||||
).toBe(false) // Wrong separator
|
||||
expect(
|
||||
isNodeLocatorId('g0000000-0000-0000-0000-000000000000:123')
|
||||
).toBe(false) // Invalid hex
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseNodeLocatorId', () => {
|
||||
it('should parse valid NodeLocatorId', () => {
|
||||
const result = parseNodeLocatorId(validNodeLocatorId)
|
||||
expect(result).toEqual({
|
||||
subgraphUuid: validUuid,
|
||||
localNodeId: 123
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle string node IDs', () => {
|
||||
const stringNodeId = `${validUuid}:node_1`
|
||||
const result = parseNodeLocatorId(stringNodeId)
|
||||
expect(result).toEqual({
|
||||
subgraphUuid: validUuid,
|
||||
localNodeId: 'node_1'
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle simple node IDs (root graph)', () => {
|
||||
const result = parseNodeLocatorId('123')
|
||||
expect(result).toEqual({
|
||||
subgraphUuid: null,
|
||||
localNodeId: 123
|
||||
})
|
||||
|
||||
const stringResult = parseNodeLocatorId('node_1')
|
||||
expect(stringResult).toEqual({
|
||||
subgraphUuid: null,
|
||||
localNodeId: 'node_1'
|
||||
})
|
||||
})
|
||||
|
||||
it('should return null for invalid formats', () => {
|
||||
expect(parseNodeLocatorId('123:456')).toBeNull() // No UUID in first part
|
||||
expect(parseNodeLocatorId('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createNodeLocatorId', () => {
|
||||
it('should create NodeLocatorId from components', () => {
|
||||
const result = createNodeLocatorId(validUuid, 123)
|
||||
expect(result).toBe(validNodeLocatorId)
|
||||
expect(isNodeLocatorId(result)).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle string node IDs', () => {
|
||||
const result = createNodeLocatorId(validUuid, 'node_1')
|
||||
expect(result).toBe(`${validUuid}:node_1`)
|
||||
expect(isNodeLocatorId(result)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('NodeExecutionId', () => {
|
||||
describe('isNodeExecutionId', () => {
|
||||
it('should return true for execution IDs', () => {
|
||||
expect(isNodeExecutionId('123:456')).toBe(true)
|
||||
expect(isNodeExecutionId('123:456:789')).toBe(true)
|
||||
expect(isNodeExecutionId('node_1:node_2')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for non-execution IDs', () => {
|
||||
expect(isNodeExecutionId('123')).toBe(false)
|
||||
expect(isNodeExecutionId('node_1')).toBe(false)
|
||||
expect(isNodeExecutionId('')).toBe(false)
|
||||
expect(isNodeExecutionId(123)).toBe(false)
|
||||
expect(isNodeExecutionId(null)).toBe(false)
|
||||
expect(isNodeExecutionId(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseNodeExecutionId', () => {
|
||||
it('should parse execution IDs correctly', () => {
|
||||
expect(parseNodeExecutionId('123:456')).toEqual([123, 456])
|
||||
expect(parseNodeExecutionId('123:456:789')).toEqual([123, 456, 789])
|
||||
expect(parseNodeExecutionId('node_1:node_2')).toEqual([
|
||||
'node_1',
|
||||
'node_2'
|
||||
])
|
||||
expect(parseNodeExecutionId('123:node_2:456')).toEqual([
|
||||
123,
|
||||
'node_2',
|
||||
456
|
||||
])
|
||||
})
|
||||
|
||||
it('should return null for non-execution IDs', () => {
|
||||
expect(parseNodeExecutionId('123')).toBeNull()
|
||||
expect(parseNodeExecutionId('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createNodeExecutionId', () => {
|
||||
it('should create execution IDs from node arrays', () => {
|
||||
expect(createNodeExecutionId([123, 456])).toBe('123:456')
|
||||
expect(createNodeExecutionId([123, 456, 789])).toBe('123:456:789')
|
||||
expect(createNodeExecutionId(['node_1', 'node_2'])).toBe(
|
||||
'node_1:node_2'
|
||||
)
|
||||
expect(createNodeExecutionId([123, 'node_2', 456])).toBe(
|
||||
'123:node_2:456'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle single node ID', () => {
|
||||
const result = createNodeExecutionId([123])
|
||||
expect(result).toBe('123')
|
||||
// Single node IDs are not execution IDs
|
||||
expect(isNodeExecutionId(result)).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle empty array', () => {
|
||||
expect(createNodeExecutionId([])).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration tests', () => {
|
||||
it('should round-trip NodeLocatorId correctly', () => {
|
||||
const uuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||
const nodeId: NodeId = 123
|
||||
|
||||
const locatorId = createNodeLocatorId(uuid, nodeId)
|
||||
const parsed = parseNodeLocatorId(locatorId)
|
||||
|
||||
expect(parsed).toBeTruthy()
|
||||
expect(parsed!.subgraphUuid).toBe(uuid)
|
||||
expect(parsed!.localNodeId).toBe(nodeId)
|
||||
})
|
||||
|
||||
it('should round-trip NodeExecutionId correctly', () => {
|
||||
const nodeIds: NodeId[] = [123, 'node_2', 456]
|
||||
|
||||
const executionId = createNodeExecutionId(nodeIds)
|
||||
const parsed = parseNodeExecutionId(executionId)
|
||||
|
||||
expect(parsed).toEqual(nodeIds)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user