Compare commits

...

14 Commits

Author SHA1 Message Date
Jacob Segal
ada4f1d533 Add Playwright tests for async execution 2025-07-17 18:27:09 -07:00
Jacob Segal
aae20d9417 Merge remote-tracking branch 'origin/main' into js/async_nodes 2025-07-16 10:41:50 -07:00
Jacob Segal
9f4abbc3af Rename HierarchicalNodeId to NodeExecutionId 2025-07-11 19:04:48 -07:00
Jacob Segal
0887bb6654 Address PR feedback 2025-07-11 16:01:09 -07:00
github-actions
868e047272 Update locales [skip ci] 2025-07-10 22:44:44 +00:00
Jacob Segal
abb82e5fd1 Merge remote-tracking branch 'upstream/main' into js/async_nodes 2025-07-10 15:35:08 -07:00
Jacob Segal
db70265e16 Fix some unit tests 2025-07-08 01:22:13 -07:00
github-actions
c8137f7f98 Update locales [skip ci] 2025-07-08 07:48:52 +00:00
Jacob Segal
132a9dbb5f Add a feature flags message to reduce bandwidth 2025-07-08 00:23:10 -07:00
Jacob Segal
f076a1c422 Fix bug with browser tab title 2025-07-07 19:27:17 -07:00
Jacob Segal
f6c65d3fe7 Progress bars work in subgraphs 2025-07-07 17:16:38 -07:00
Jacob Segal
c8371d6089 Add functions for mapping node locations to an id
These locations differ from either local ids (like `5`) or execution ids
(like `3:4:5`) in that all nodes which are 'linked' such that changes to
one node will apply changes to other nodes will map to the same id. This
is specifically relevant for multiple instances of the same subgraph.

These IDs will actually get used in a followup commit
2025-07-06 20:41:58 -07:00
Jacob Segal
a70a81c9ae Address PR feedback 2025-07-03 14:03:11 -07:00
Jacob Segal
aa5fa81824 Update the frontend to support async nodes. 2025-07-02 12:16:17 -07:00
41 changed files with 3748 additions and 118 deletions

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

@@ -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]
}
}
)

View File

@@ -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(
() =>

View File

@@ -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) => {

View File

@@ -1,3 +1,3 @@
{
"supports_preview_metadata": false
"supports_preview_metadata": true
}

View File

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

View File

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

View File

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

View File

@@ -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 dexécution",
"ok": "OK",
"openNewIssue": "Ouvrir un nouveau problème",
"overwrite": "Écraser",

View File

@@ -336,6 +336,7 @@
"noTasksFoundMessage": "キューにタスクがありません。",
"noWorkflowsFound": "ワークフローが見つかりません。",
"nodes": "ノード",
"nodesRunning": "ノードが実行中",
"ok": "OK",
"openNewIssue": "新しい問題を開く",
"overwrite": "上書き",

View File

@@ -336,6 +336,7 @@
"noTasksFoundMessage": "대기열에 작업이 없습니다.",
"noWorkflowsFound": "워크플로를 찾을 수 없습니다.",
"nodes": "노드",
"nodesRunning": "노드 실행 중",
"ok": "확인",
"openNewIssue": "새 문제 열기",
"overwrite": "덮어쓰기",

View File

@@ -336,6 +336,7 @@
"noTasksFoundMessage": "В очереди нет задач.",
"noWorkflowsFound": "Рабочие процессы не найдены.",
"nodes": "Узлы",
"nodesRunning": "запущено узлов",
"ok": "ОК",
"openNewIssue": "Открыть новую проблему",
"overwrite": "Перезаписать",

View File

@@ -336,6 +336,7 @@
"noTasksFoundMessage": "佇列中沒有任務。",
"noWorkflowsFound": "找不到工作流程。",
"nodes": "節點",
"nodesRunning": "節點執行中",
"ok": "確定",
"openNewIssue": "開啟新問題",
"overwrite": "覆蓋",

View File

@@ -410,4 +410,4 @@
"pysssss_SnapToGrid": {
"name": "總是對齊格線"
}
}
}

View File

@@ -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": "撤销",

View File

@@ -410,4 +410,4 @@
"pysssss_SnapToGrid": {
"name": "始终吸附到网格"
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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() {

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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]')
})
})

View File

@@ -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()
})
})
})

View File

@@ -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()
})
})
})
})

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