Add Playwright tests for async execution

This commit is contained in:
Jacob Segal
2025-07-17 18:27:09 -07:00
parent aae20d9417
commit ada4f1d533
12 changed files with 2603 additions and 2 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)
})
})