Skip to content

Commit 08963cc

Browse files
authored
Release 1.1.1 (#2)
Bug-fix release focusing on queue rendering reliability. - fix: retain direct reference after card.replaceWith to avoid DOM index mismatch - fix: coerce running task promptId to string to prevent card rebuild - fix: use prompt UUID (tuple[1]) as running task ID in normalizeQueue - fix: restore pending-before-running task sort order - fix: show badge and card immediately on execution_start - fix: render existing state immediately when panel opens - fix: replace ogg in AUDIO_EXTS with oga to avoid collision with VIDEO_EXTS - test: add Playwright E2E tests for queue sidebar - chore: bump version to 1.1.1
1 parent 8c95a3e commit 08963cc

File tree

11 files changed

+402
-13
lines changed

11 files changed

+402
-13
lines changed

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@ __pycache__/
22
*.py[cod]
33
.DS_Store
44
node_modules/
5-
package-lock.json
5+
package-lock.json
6+
test-results/
7+
playwright-report/

e2e/helpers.mjs

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
const COMFY_URL = 'http://127.0.0.1:8188'
2+
3+
/**
4+
* Check if ComfyUI is reachable.
5+
*/
6+
export async function isComfyReachable() {
7+
try {
8+
const res = await fetch(`${COMFY_URL}/system_stats`)
9+
return res.ok
10+
} catch {
11+
return false
12+
}
13+
}
14+
15+
/**
16+
* Open the Queue Sidebar tab by clicking the pi-history icon.
17+
*/
18+
export async function openSidebar(page) {
19+
const tab = page.locator('.sidebar-icon-wrapper .pi-history').first()
20+
await tab.click()
21+
// Give the sidebar time to render its grid
22+
await page.locator('[data-status]').first().waitFor({ state: 'attached', timeout: 5000 })
23+
.catch(() => {}) // Grid may be empty — that's OK
24+
}
25+
26+
/**
27+
* Get a workflow with a modified KSampler seed to force non-cached execution.
28+
* Tries the current graph first (app.graphToPrompt), then falls back to history.
29+
* Returns the prompt object or null.
30+
*/
31+
export async function getModifiedWorkflow(page) {
32+
return page.evaluate(async () => {
33+
// Try current graph first
34+
let prompt = null
35+
try {
36+
const result = await window.app.graphToPrompt()
37+
prompt = result?.output
38+
} catch {}
39+
40+
// Fall back to last history entry
41+
if (!prompt) {
42+
const historyRes = await fetch('/history?max_items=1').then(r => r.json())
43+
const lastEntry = Object.values(historyRes)[0]
44+
prompt = lastEntry?.prompt?.[2]
45+
}
46+
47+
if (!prompt) return null
48+
49+
const copy = JSON.parse(JSON.stringify(prompt))
50+
let changed = false
51+
for (const [, node] of Object.entries(copy)) {
52+
if (node.class_type === 'KSampler' || node.class_type === 'KSamplerAdvanced') {
53+
node.inputs.seed = Math.floor(Math.random() * 2 ** 32)
54+
changed = true
55+
}
56+
}
57+
return changed ? copy : null
58+
})
59+
}
60+
61+
/**
62+
* Queue a prompt via the /prompt API. Returns { prompt_id, number }.
63+
* Includes the WS client_id so ComfyUI sends execution_start / executing /
64+
* execution_success events to our session (without it only broadcast-level
65+
* status events are received).
66+
*/
67+
export async function queuePrompt(page, prompt) {
68+
return page.evaluate(async (p) => {
69+
const clientId = window.comfyAPI?.api?.api?.clientId ?? ''
70+
const res = await fetch('/prompt', {
71+
method: 'POST',
72+
headers: { 'Content-Type': 'application/json' },
73+
body: JSON.stringify({ prompt: p, client_id: clientId }),
74+
})
75+
return res.json()
76+
}, prompt)
77+
}
78+
79+
/**
80+
* Wait until the ComfyUI queue is empty (no running, no pending).
81+
*/
82+
export async function waitForQueueEmpty(page, timeout = 30_000) {
83+
const start = Date.now()
84+
while (Date.now() - start < timeout) {
85+
const q = await page.evaluate(async () => {
86+
const r = await fetch('/queue').then(res => res.json())
87+
return { running: r.queue_running?.length ?? 0, pending: r.queue_pending?.length ?? 0 }
88+
})
89+
if (q.running === 0 && q.pending === 0) return
90+
await page.waitForTimeout(500)
91+
}
92+
throw new Error(`Queue did not empty within ${timeout}ms`)
93+
}
94+
95+
/**
96+
* Collect all task cards currently in the DOM.
97+
* Returns [{ id, status, index }].
98+
*/
99+
export async function getCards(page) {
100+
return page.evaluate(() =>
101+
[...document.querySelectorAll('[data-id][data-status]')].map((el, i) => ({
102+
id: el.dataset.id,
103+
status: el.dataset.status,
104+
index: i,
105+
}))
106+
)
107+
}
108+
109+
/**
110+
* Get the badge text content, or null if not present.
111+
*/
112+
export async function getBadgeText(page) {
113+
return page.evaluate(() => {
114+
const el = document.querySelector('.sidebar-icon-badge')
115+
return el?.textContent?.trim() || null
116+
})
117+
}

e2e/queue-sidebar.spec.mjs

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { test, expect } from 'playwright/test'
2+
import {
3+
isComfyReachable,
4+
openSidebar,
5+
getModifiedWorkflow,
6+
queuePrompt,
7+
waitForQueueEmpty,
8+
getCards,
9+
getBadgeText,
10+
} from './helpers.mjs'
11+
12+
test.describe('Queue Sidebar E2E', () => {
13+
test.beforeAll(async () => {
14+
const reachable = await isComfyReachable()
15+
if (!reachable) test.skip(true, 'ComfyUI is not running at 127.0.0.1:8188')
16+
})
17+
18+
test.beforeEach(async ({ page }) => {
19+
await page.goto('/', { waitUntil: 'networkidle', timeout: 30_000 })
20+
await page.waitForTimeout(2000) // let extensions load
21+
await openSidebar(page)
22+
})
23+
24+
// ── #1 — Cached state renders immediately when sidebar reopens ─────
25+
test('cached state renders immediately on reopen', async ({ page }) => {
26+
// First, ensure state has data by running a prompt to completion.
27+
const wf = await getModifiedWorkflow(page)
28+
test.skip(!wf, 'No workflow available')
29+
30+
await queuePrompt(page, wf)
31+
await waitForQueueEmpty(page)
32+
// Wait for history refresh to populate cards
33+
await page.waitForTimeout(2000)
34+
35+
// Verify cards exist now
36+
const cardsBefore = await getCards(page)
37+
test.skip(cardsBefore.length === 0, 'No cards rendered after prompt completion')
38+
39+
// Close the sidebar tab.
40+
const tab = page.locator('.sidebar-icon-wrapper .pi-history').first()
41+
await tab.click()
42+
await page.waitForTimeout(300)
43+
44+
// Intercept /queue and /history with a 3-second delay so we can prove
45+
// that cards come from cached in-memory state, not from a fresh fetch.
46+
await page.route('**/queue', async (route) => {
47+
await new Promise(r => setTimeout(r, 3000))
48+
await route.continue()
49+
})
50+
await page.route('**/history*', async (route) => {
51+
await new Promise(r => setTimeout(r, 3000))
52+
await route.continue()
53+
})
54+
55+
// Reopen the sidebar
56+
await tab.click()
57+
58+
// Cards should appear within 500ms (from cached state, not delayed API)
59+
await expect(page.locator('[data-id][data-status]').first())
60+
.toBeAttached({ timeout: 500 })
61+
62+
await page.unroute('**/queue')
63+
await page.unroute('**/history*')
64+
})
65+
66+
// ── #2 — Pending cards appear above running cards ──────────────────
67+
test('pending cards appear above running cards', async ({ page }) => {
68+
const wf = await getModifiedWorkflow(page)
69+
test.skip(!wf, 'No workflow available in history')
70+
71+
// Queue two prompts with different seeds — the first starts running,
72+
// the second stays pending.
73+
await queuePrompt(page, wf)
74+
const wf2 = await getModifiedWorkflow(page)
75+
await queuePrompt(page, wf2)
76+
77+
// Wait for at least one running AND one pending card to appear
78+
await expect(async () => {
79+
const cards = await getCards(page)
80+
const hasRunning = cards.some(c => c.status === 'running')
81+
const hasPending = cards.some(c => c.status === 'pending')
82+
expect(hasRunning && hasPending).toBe(true)
83+
}).toPass({ timeout: 15_000 })
84+
85+
// Verify order: all pending indices < all running indices
86+
const cards = await getCards(page)
87+
const pendingIndices = cards.filter(c => c.status === 'pending').map(c => c.index)
88+
const runningIndices = cards.filter(c => c.status === 'running').map(c => c.index)
89+
const maxPending = Math.max(...pendingIndices)
90+
const minRunning = Math.min(...runningIndices)
91+
expect(maxPending).toBeLessThan(minRunning)
92+
93+
await waitForQueueEmpty(page)
94+
})
95+
96+
// ── #3 — No duplicate cards during pending→running transition ──────
97+
test('no duplicate cards during pending-to-running transition', async ({ page }) => {
98+
const wf = await getModifiedWorkflow(page)
99+
test.skip(!wf, 'No workflow available in history')
100+
101+
await queuePrompt(page, wf)
102+
103+
// Poll DOM rapidly, looking for duplicate data-id values
104+
let duplicateFound = false
105+
const start = Date.now()
106+
107+
while (Date.now() - start < 15_000) {
108+
const ids = await page.evaluate(() =>
109+
[...document.querySelectorAll('[data-id]')].map(el => el.dataset.id)
110+
)
111+
const seen = new Set()
112+
for (const id of ids) {
113+
if (seen.has(id)) { duplicateFound = true; break }
114+
seen.add(id)
115+
}
116+
if (duplicateFound) break
117+
118+
const q = await page.evaluate(async () => {
119+
const r = await fetch('/queue').then(res => res.json())
120+
return { running: r.queue_running?.length ?? 0, pending: r.queue_pending?.length ?? 0 }
121+
})
122+
if (q.running === 0 && q.pending === 0) break
123+
await page.waitForTimeout(100)
124+
}
125+
126+
expect(duplicateFound).toBe(false)
127+
})
128+
129+
// ── #4 — Badge and card appear instantly on execution_start ────────
130+
test('badge and running card appear on execution_start', async ({ page }) => {
131+
const wf = await getModifiedWorkflow(page)
132+
test.skip(!wf, 'No workflow available')
133+
134+
// Block /api/queue so the API-poll path (refresh → fetchQueue → render)
135+
// can never deliver queue state. The ONLY way a running card can appear
136+
// is through the WS execution_start → onExecutionStart → render() path.
137+
const blocked = []
138+
await page.route('**/queue', async (route) => {
139+
blocked.push(route.request().url())
140+
await new Promise(r => setTimeout(r, 30_000))
141+
await route.continue()
142+
})
143+
144+
await queuePrompt(page, wf)
145+
146+
// If onExecutionStart works, the running card appears within seconds.
147+
// The 10s timeout is generous but still far shorter than the 30s block.
148+
await expect(page.locator('[data-status="running"]').first())
149+
.toBeAttached({ timeout: 10_000 })
150+
151+
const badge = await getBadgeText(page)
152+
expect(badge).toBeTruthy()
153+
154+
// Confirm the route actually blocked at least one /api/queue request,
155+
// proving the card did NOT come from the API-poll path.
156+
expect(blocked.length).toBeGreaterThanOrEqual(1)
157+
158+
await page.unroute('**/queue')
159+
await waitForQueueEmpty(page)
160+
})
161+
162+
// ── #6 — No [QueueSidebar] console.warn during normal operation ────
163+
test('no QueueSidebar console warnings during normal operation', async ({ page }) => {
164+
const warnings = []
165+
page.on('console', msg => {
166+
if (msg.type() === 'warning' && msg.text().includes('[QueueSidebar]')) {
167+
warnings.push(msg.text())
168+
}
169+
})
170+
171+
const wf = await getModifiedWorkflow(page)
172+
if (wf) {
173+
await queuePrompt(page, wf)
174+
await waitForQueueEmpty(page)
175+
}
176+
177+
await page.waitForTimeout(1000)
178+
expect(warnings).toEqual([])
179+
})
180+
})

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
{
22
"name": "comfyui_queue_sidebar",
3-
"version": "1.1.0",
3+
"version": "1.1.1",
44
"private": true,
55
"description": "ComfyUI sidebar extension — brings back the queue panel with image previews",
66
"type": "module",
77
"scripts": {
88
"test": "vitest run",
9-
"test:watch": "vitest"
9+
"test:watch": "vitest",
10+
"test:e2e": "playwright test"
1011
},
1112
"repository": {
1213
"type": "git",
@@ -15,6 +16,7 @@
1516
"license": "MIT",
1617
"devDependencies": {
1718
"jsdom": "^28.1.0",
19+
"playwright": "^1.58.2",
1820
"vitest": "^4.0.18"
1921
}
2022
}

playwright.config.mjs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { defineConfig } from 'playwright/test'
2+
3+
export default defineConfig({
4+
testDir: './e2e',
5+
testMatch: '*.spec.mjs',
6+
timeout: 60_000,
7+
expect: { timeout: 10_000 },
8+
retries: 0,
9+
workers: 1,
10+
use: {
11+
baseURL: 'http://127.0.0.1:8188',
12+
headless: true,
13+
screenshot: 'only-on-failure',
14+
trace: 'retain-on-failure',
15+
},
16+
reporter: [['list'], ['html', { open: 'never' }]],
17+
})

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "comfyui-queue-sidebar"
3-
version = "1.1.0"
3+
version = "1.1.1"
44
description = "ComfyUI sidebar extension — brings back the queue panel with image previews"
55
license = { file = "LICENSE" }
66

0 commit comments

Comments
 (0)