From ada4f1d533e2d89f4b7957db6bcae7a17ada0a8b Mon Sep 17 00:00:00 2001 From: Jacob Segal Date: Thu, 17 Jul 2025 18:27:09 -0700 Subject: [PATCH] Add Playwright tests for async execution --- .github/workflows/test-ui.yaml | 4 +- browser_tests/README.md | 6 + .../execution/nested-subgraph-test.json | 293 ++++++++++ .../execution/parallel_async_nodes.json | 139 +++++ .../assets/execution/parallel_ksamplers.json | 193 +++++++ browser_tests/helpers/ExecutionTestHelper.ts | 507 ++++++++++++++++++ .../tests/browserTabTitleMultiNode.spec.ts | 316 +++++++++++ .../tests/browserTabTitleSimple.spec.ts | 85 +++ .../tests/multiNodeExecution.spec.ts | 385 +++++++++++++ .../tests/multiNodeExecutionSimple.spec.ts | 168 ++++++ .../tests/previewWithMetadata.spec.ts | 272 ++++++++++ .../tests/subgraphExecutionProgress.spec.ts | 237 ++++++++ 12 files changed, 2603 insertions(+), 2 deletions(-) create mode 100644 browser_tests/assets/execution/nested-subgraph-test.json create mode 100644 browser_tests/assets/execution/parallel_async_nodes.json create mode 100644 browser_tests/assets/execution/parallel_ksamplers.json create mode 100644 browser_tests/helpers/ExecutionTestHelper.ts create mode 100644 browser_tests/tests/browserTabTitleMultiNode.spec.ts create mode 100644 browser_tests/tests/browserTabTitleSimple.spec.ts create mode 100644 browser_tests/tests/multiNodeExecution.spec.ts create mode 100644 browser_tests/tests/multiNodeExecutionSimple.spec.ts create mode 100644 browser_tests/tests/previewWithMetadata.spec.ts create mode 100644 browser_tests/tests/subgraphExecutionProgress.spec.ts diff --git a/.github/workflows/test-ui.yaml b/.github/workflows/test-ui.yaml index 580bdee19..77fba17dc 100644 --- a/.github/workflows/test-ui.yaml +++ b/.github/workflows/test-ui.yaml @@ -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 diff --git a/browser_tests/README.md b/browser_tests/README.md index 1aeaa6e54..d999a3ce4 100644 --- a/browser_tests/README.md +++ b/browser_tests/README.md @@ -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. diff --git a/browser_tests/assets/execution/nested-subgraph-test.json b/browser_tests/assets/execution/nested-subgraph-test.json new file mode 100644 index 000000000..43266a0a5 --- /dev/null +++ b/browser_tests/assets/execution/nested-subgraph-test.json @@ -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 +} diff --git a/browser_tests/assets/execution/parallel_async_nodes.json b/browser_tests/assets/execution/parallel_async_nodes.json new file mode 100644 index 000000000..280881205 --- /dev/null +++ b/browser_tests/assets/execution/parallel_async_nodes.json @@ -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 +} \ No newline at end of file diff --git a/browser_tests/assets/execution/parallel_ksamplers.json b/browser_tests/assets/execution/parallel_ksamplers.json new file mode 100644 index 000000000..0adf9f1ed --- /dev/null +++ b/browser_tests/assets/execution/parallel_ksamplers.json @@ -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 +} \ No newline at end of file diff --git a/browser_tests/helpers/ExecutionTestHelper.ts b/browser_tests/helpers/ExecutionTestHelper.ts new file mode 100644 index 000000000..597187d64 --- /dev/null +++ b/browser_tests/helpers/ExecutionTestHelper.ts @@ -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 { + 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 { + 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 { + await this.page.waitForFunction( + (testId) => window[`__executionStarted_${testId}`] === true, + this.testId, + { timeout } + ) + } + + /** + * Waits for execution to finish + */ + async waitForExecutionFinish(timeout: number = 30000): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const error = await this.page.evaluate( + (testId) => window[`__executionError_${testId}`], + this.testId + ) + return error !== null + } + + /** + * Gets execution error details + */ + async getExecutionError(): Promise { + return await this.page.evaluate( + (testId) => window[`__executionError_${testId}`], + this.testId + ) + } + + /** + * Cleanup event listeners when test is done + */ + async cleanup(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return await this.page.evaluate(() => window['__previewEvents'] || []) + } + + /** + * Gets revoked node IDs + */ + async getRevokedNodes(): Promise { + return await this.page.evaluate(() => window['__revokedNodes'] || []) + } + + /** + * Gets revoked URLs + */ + async getRevokedUrls(): Promise { + return await this.page.evaluate(() => window['__revokedUrls'] || []) + } + + /** + * Sets fake preview for a node + */ + async setNodePreview(nodeId: string, previewUrl: string): Promise { + await this.page.evaluate( + ({ id, url }) => { + window['app'].nodePreviewImages[id] = [url] + }, + { id: nodeId, url: previewUrl } + ) + } + + /** + * Gets node preview URLs + */ + async getNodePreviews(nodeId: string): Promise { + 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 { + 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 { + return await this.page.evaluate((testId) => { + const states = window[`__progressStates_${testId}`] || [] + const nestedIds = new Set() + + 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 { + 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) + } +} diff --git a/browser_tests/tests/browserTabTitleMultiNode.spec.ts b/browser_tests/tests/browserTabTitleMultiNode.spec.ts new file mode 100644 index 000000000..b1635052f --- /dev/null +++ b/browser_tests/tests/browserTabTitleMultiNode.spec.ts @@ -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') + }) +}) diff --git a/browser_tests/tests/browserTabTitleSimple.spec.ts b/browser_tests/tests/browserTabTitleSimple.spec.ts new file mode 100644 index 000000000..1533e0152 --- /dev/null +++ b/browser_tests/tests/browserTabTitleSimple.spec.ts @@ -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 + }) +}) diff --git a/browser_tests/tests/multiNodeExecution.spec.ts b/browser_tests/tests/multiNodeExecution.spec.ts new file mode 100644 index 000000000..65951adb6 --- /dev/null +++ b/browser_tests/tests/multiNodeExecution.spec.ts @@ -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() + }) +}) diff --git a/browser_tests/tests/multiNodeExecutionSimple.spec.ts b/browser_tests/tests/multiNodeExecutionSimple.spec.ts new file mode 100644 index 000000000..34277319a --- /dev/null +++ b/browser_tests/tests/multiNodeExecutionSimple.spec.ts @@ -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' + }) + }) +}) diff --git a/browser_tests/tests/previewWithMetadata.spec.ts b/browser_tests/tests/previewWithMetadata.spec.ts new file mode 100644 index 000000000..d16cfee65 --- /dev/null +++ b/browser_tests/tests/previewWithMetadata.spec.ts @@ -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') + }) +}) diff --git a/browser_tests/tests/subgraphExecutionProgress.spec.ts b/browser_tests/tests/subgraphExecutionProgress.spec.ts new file mode 100644 index 000000000..7058c7c97 --- /dev/null +++ b/browser_tests/tests/subgraphExecutionProgress.spec.ts @@ -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) + }) +})