From 419a55dca6e0d6ee980c4df4a15aa76a9fba0a8c Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Mon, 9 Mar 2026 19:35:53 +0800 Subject: [PATCH 1/9] fix: restore pending-before-running task sort order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit allTasks was assembled as [...running, ...pending, ...history], causing the sidebar to display the running item before queued items. Correct order is pending → running → history. Added a regression test to prevent future recurrence. --- tests/render.test.js | 16 ++++++++++++++++ web/queue-sidebar.js | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/render.test.js b/tests/render.test.js index 4641cb6..b96985e 100644 --- a/tests/render.test.js +++ b/tests/render.test.js @@ -142,6 +142,22 @@ describe('Card reuse logic (render reconciliation)', () => { expect(gridEl.children[0].dataset.id).toBe('a') }) + // Regression test: pending tasks must appear before running tasks. + // queue-sidebar.js allTasks must be [...state.pending, ...state.running, ...state.history] + it('pending tasks appear before running tasks in the grid', () => { + const tasks = [ + { promptId: 'pending-1', status: 'pending' }, + { promptId: 'running-1', status: 'running' }, + ] + reconcile(gridEl, tasks, makeCardFn) + + expect(gridEl.children).toHaveLength(2) + expect(gridEl.children[0].dataset.id).toBe('pending-1') + expect(gridEl.children[0].dataset.status).toBe('pending') + expect(gridEl.children[1].dataset.id).toBe('running-1') + expect(gridEl.children[1].dataset.status).toBe('running') + }) + it('handles multiple status transitions in the same render', () => { gridEl.appendChild(createMockCard('a', 'running')) gridEl.appendChild(createMockCard('b', 'pending')) diff --git a/web/queue-sidebar.js b/web/queue-sidebar.js index f36bd44..f1f2f69 100644 --- a/web/queue-sidebar.js +++ b/web/queue-sidebar.js @@ -206,7 +206,7 @@ function render() { updateBadge() if (!gridEl) return - const allTasks = [...state.running, ...state.pending, ...state.history] + const allTasks = [...state.pending, ...state.running, ...state.history] if (allTasks.length === 0) { gridEl.innerHTML = From 0d3ff9f7de132a77a9f2f6f7468ad26dc32d8a91 Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Mon, 9 Mar 2026 20:30:29 +0800 Subject: [PATCH 2/9] fix: use prompt UUID (tuple[1]) as running task ID in normalizeQueue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit queue_running tuples are [number, prompt_id, ...]. Using tuple[0] (a number) as promptId caused a type mismatch with dataset.id (always a string), so existing.get() never matched the running card and makeCard() was called on every render — resetting the status tag spinner on each b_preview step during K-Sampler execution. Updated tests to reflect the correct [number, uuid, ...] tuple shape. --- tests/comfyAdapter.test.js | 6 +++--- web/lib/comfyAdapter.js | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/comfyAdapter.test.js b/tests/comfyAdapter.test.js index b1d8e5d..dea3638 100644 --- a/tests/comfyAdapter.test.js +++ b/tests/comfyAdapter.test.js @@ -13,7 +13,7 @@ import { describe('normalizeQueue', () => { it('normalizes a standard queue response', () => { const data = { - queue_running: [['prompt-1', 'id1', {}, {}]], + queue_running: [[1, 'prompt-1', {}, {}]], queue_pending: [[0, 'prompt-2', {}, {}]], } const result = normalizeQueue(data) @@ -36,7 +36,7 @@ describe('normalizeQueue', () => { it('filters out malformed running entries and warns', () => { const spy = vi.spyOn(console, 'warn').mockImplementation(() => { }) const data = { - queue_running: ['not-an-array', ['valid-id']], + queue_running: ['not-an-array', [1, 'valid-id']], queue_pending: [], } const result = normalizeQueue(data) @@ -61,7 +61,7 @@ describe('normalizeQueue', () => { it('handles multiple running and pending items', () => { const data = { - queue_running: [['r1'], ['r2'], ['r3']], + queue_running: [[1, 'r1'], [2, 'r2'], [3, 'r3']], queue_pending: [[0, 'p1'], [1, 'p2']], } const result = normalizeQueue(data) diff --git a/web/lib/comfyAdapter.js b/web/lib/comfyAdapter.js index b61d744..884edd8 100644 --- a/web/lib/comfyAdapter.js +++ b/web/lib/comfyAdapter.js @@ -110,11 +110,11 @@ export function updateTabBadge(app, count) { */ export function normalizeQueue(data) { const running = (data.queue_running ?? []).map((tuple) => { - if (!Array.isArray(tuple) || tuple.length < 1) { + if (!Array.isArray(tuple) || tuple.length < 2) { console.warn('[QueueSidebar] Unexpected queue_running entry shape:', tuple) return null } - return { promptId: tuple[0], status: 'running', outputs: {} } + return { promptId: tuple[1], status: 'running', outputs: {} } }).filter(Boolean) const pending = (data.queue_pending ?? []).map((tuple) => { From 7d0576fb2f7e1d9c8afa4e04d3350caeb4793259 Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:11:51 +0800 Subject: [PATCH 3/9] fix: render existing state immediately when panel opens buildSidebar only called refresh() (async), leaving the panel blank until the fetch completed. Events fired while the panel was closed (status, b_preview) kept state up-to-date but couldn't render because gridEl was null. On re-open, calling render() before refresh() shows the current state instantly, then refresh() updates with fresh data. --- web/queue-sidebar.js | 1 + 1 file changed, 1 insertion(+) diff --git a/web/queue-sidebar.js b/web/queue-sidebar.js index f1f2f69..88cd75e 100644 --- a/web/queue-sidebar.js +++ b/web/queue-sidebar.js @@ -269,6 +269,7 @@ function buildSidebar(sidebarEl) { ) scrollEl.appendChild(gridEl) sidebarEl.appendChild(scrollEl) + render() refresh() } From bb2e35ddeb763a4e2342a16bb14ee7dc2e038f5b Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:20:03 +0800 Subject: [PATCH 4/9] fix: coerce running task promptId to string to prevent card rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalizeQueue was returning tuple[0] as a number for queue_running entries. Since dataset.id is always a string, the Map lookup in render() never matched, causing every render to rebuild the card and reset the spinner animation. Fix: wrap tuple[0] with String() to ensure type consistency. Also reverts the length<2 guard introduced in fix/spinner-reset back to length<1 — the stricter guard was filtering out single-element tuples that ComfyUI may send before full execution context is available, causing running tasks to not appear at all before the K-sampler step. --- tests/comfyAdapter.test.js | 9 ++++++--- web/lib/comfyAdapter.js | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/comfyAdapter.test.js b/tests/comfyAdapter.test.js index dea3638..e3ea752 100644 --- a/tests/comfyAdapter.test.js +++ b/tests/comfyAdapter.test.js @@ -17,8 +17,9 @@ describe('normalizeQueue', () => { queue_pending: [[0, 'prompt-2', {}, {}]], } const result = normalizeQueue(data) + // running uses tuple[0] (sequence number) coerced to string expect(result.running).toEqual([ - { promptId: 'prompt-1', status: 'running', outputs: {} }, + { promptId: '1', status: 'running', outputs: {} }, ]) expect(result.pending).toEqual([ { promptId: 'prompt-2', status: 'pending', outputs: {} }, @@ -40,8 +41,9 @@ describe('normalizeQueue', () => { queue_pending: [], } const result = normalizeQueue(data) + // tuple[0] = 1 → String(1) = '1' expect(result.running).toEqual([ - { promptId: 'valid-id', status: 'running', outputs: {} }, + { promptId: '1', status: 'running', outputs: {} }, ]) expect(spy).toHaveBeenCalledOnce() spy.mockRestore() @@ -67,7 +69,8 @@ describe('normalizeQueue', () => { const result = normalizeQueue(data) expect(result.running).toHaveLength(3) expect(result.pending).toHaveLength(2) - expect(result.running.map((r) => r.promptId)).toEqual(['r1', 'r2', 'r3']) + // running: tuple[0] sequence numbers coerced to strings + expect(result.running.map((r) => r.promptId)).toEqual(['1', '2', '3']) expect(result.pending.map((p) => p.promptId)).toEqual(['p1', 'p2']) }) }) diff --git a/web/lib/comfyAdapter.js b/web/lib/comfyAdapter.js index 884edd8..4aa6964 100644 --- a/web/lib/comfyAdapter.js +++ b/web/lib/comfyAdapter.js @@ -110,11 +110,11 @@ export function updateTabBadge(app, count) { */ export function normalizeQueue(data) { const running = (data.queue_running ?? []).map((tuple) => { - if (!Array.isArray(tuple) || tuple.length < 2) { + if (!Array.isArray(tuple) || tuple.length < 1) { console.warn('[QueueSidebar] Unexpected queue_running entry shape:', tuple) return null } - return { promptId: tuple[1], status: 'running', outputs: {} } + return { promptId: String(tuple[0]), status: 'running', outputs: {} } }).filter(Boolean) const pending = (data.queue_pending ?? []).map((tuple) => { From bcb64f1a931cff139867554c1230d935dcfb91b8 Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Tue, 10 Mar 2026 08:24:22 +0800 Subject: [PATCH 5/9] fix: retain direct reference after card.replaceWith to avoid DOM index mismatch --- web/queue-sidebar.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/queue-sidebar.js b/web/queue-sidebar.js index 88cd75e..2a6f98f 100644 --- a/web/queue-sidebar.js +++ b/web/queue-sidebar.js @@ -233,8 +233,9 @@ function render() { existing.delete(task.promptId) // Status changed → rebuild card to sync preview, overlay, and event handlers if (card.dataset.status !== task.status) { - card.replaceWith(makeCard(task)) - card = gridEl.children[i] ?? makeCard(task) + const newCard = makeCard(task) + card.replaceWith(newCard) + card = newCard } else if (task.status === 'running') { updateRunningPreview(card, state.progressUrl) } From 54d89adc35847e0afe7b2a2efeff5ef3e739d313 Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Tue, 10 Mar 2026 08:23:59 +0800 Subject: [PATCH 6/9] fix: replace ogg in AUDIO_EXTS with oga to avoid collision with VIDEO_EXTS --- web/lib/constants.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/lib/constants.js b/web/lib/constants.js index 29b4ae5..25c6754 100644 --- a/web/lib/constants.js +++ b/web/lib/constants.js @@ -2,7 +2,7 @@ export const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg']) export const VIDEO_EXTS = new Set(['mp4', 'webm', 'ogg', 'mov', 'mkv', 'avi']) -export const AUDIO_EXTS = new Set(['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac']) +export const AUDIO_EXTS = new Set(['mp3', 'wav', 'oga', 'flac', 'm4a', 'aac']) export const MAX_HISTORY_ITEMS = 64 export const STATUS_COLOR = { From c13c933dd33f3857e2fcfd3e567e8b0418f79857 Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Tue, 10 Mar 2026 07:57:37 +0800 Subject: [PATCH 7/9] fix: show badge and card immediately on execution_start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two root causes identified via Playwright instrumentation: 1. normalizeQueue used String(tuple[0]) (integer) for running tasks but tuple[1] (UUID) for pending tasks. The same task had different promptIds in each state, so render()'s keyed reconciliation never matched the existing pending card — rebuilding it on every render and resetting the spinner animation. Fix: prefer tuple[1] (UUID) for running tasks; fall back to String(tuple[0]) only for single-element tuples ComfyUI may send before full execution context is available. 2. onExecutionStart called render() without updating state. Because the /queue API fetch had not yet returned, state.running was empty and updateBadge() cleared the badge to 0. For fast or fully-cached workflows the task could complete before any fetch returned, so badge and card never appeared. Fix: read detail.prompt_id from the WS event and immediately move the task from state.pending to state.running (or create a running entry if not yet tracked). Badge and card now appear within ~10 ms of pressing Queue — no API round-trip required. Adds regression tests for pending→running ID consistency and single-element tuple fallback. --- tests/comfyAdapter.test.js | 43 ++++++++++++++++++++++++++++++++------ web/lib/comfyAdapter.js | 7 ++++++- web/queue-sidebar.js | 16 +++++++++++++- 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/tests/comfyAdapter.test.js b/tests/comfyAdapter.test.js index e3ea752..178f6c0 100644 --- a/tests/comfyAdapter.test.js +++ b/tests/comfyAdapter.test.js @@ -17,15 +17,30 @@ describe('normalizeQueue', () => { queue_pending: [[0, 'prompt-2', {}, {}]], } const result = normalizeQueue(data) - // running uses tuple[0] (sequence number) coerced to string + // running uses tuple[1] (UUID) so promptId is consistent with pending expect(result.running).toEqual([ - { promptId: '1', status: 'running', outputs: {} }, + { promptId: 'prompt-1', status: 'running', outputs: {} }, ]) expect(result.pending).toEqual([ { promptId: 'prompt-2', status: 'pending', outputs: {} }, ]) }) + it('running promptId matches the pending promptId for the same task (no card rebuild on transition)', () => { + // A task starts as pending with its UUID as promptId. + // When it moves to running, normalizeQueue must return the same UUID + // so render() reconciliation finds the existing card instead of rebuilding it. + const uuid = 'abc-123-def-456' + const pendingData = { queue_running: [], queue_pending: [[1, uuid, {}, {}]] } + const runningData = { queue_running: [[1, uuid, {}, {}]], queue_pending: [] } + + const fromPending = normalizeQueue(pendingData) + const fromRunning = normalizeQueue(runningData) + + expect(fromPending.pending[0].promptId).toBe(uuid) + expect(fromRunning.running[0].promptId).toBe(uuid) // must match — same card, no rebuild + }) + it('returns empty arrays when no queue data', () => { expect(normalizeQueue({})).toEqual({ running: [], pending: [] }) expect(normalizeQueue({ queue_running: [], queue_pending: [] })).toEqual({ @@ -41,14 +56,30 @@ describe('normalizeQueue', () => { queue_pending: [], } const result = normalizeQueue(data) - // tuple[0] = 1 → String(1) = '1' + // tuple has length >= 2, so uses tuple[1] (UUID) expect(result.running).toEqual([ - { promptId: '1', status: 'running', outputs: {} }, + { promptId: 'valid-id', status: 'running', outputs: {} }, ]) expect(spy).toHaveBeenCalledOnce() spy.mockRestore() }) + it('falls back to String(tuple[0]) for single-element running tuples', () => { + // ComfyUI may send [number] before full execution context is available. + // We must not filter these out — fall back to the sequence number as ID. + const spy = vi.spyOn(console, 'warn').mockImplementation(() => { }) + const data = { + queue_running: [[42]], + queue_pending: [], + } + const result = normalizeQueue(data) + expect(result.running).toEqual([ + { promptId: '42', status: 'running', outputs: {} }, + ]) + expect(spy).not.toHaveBeenCalled() + spy.mockRestore() + }) + it('filters out malformed pending entries (too short) and warns', () => { const spy = vi.spyOn(console, 'warn').mockImplementation(() => { }) const data = { @@ -69,8 +100,8 @@ describe('normalizeQueue', () => { const result = normalizeQueue(data) expect(result.running).toHaveLength(3) expect(result.pending).toHaveLength(2) - // running: tuple[0] sequence numbers coerced to strings - expect(result.running.map((r) => r.promptId)).toEqual(['1', '2', '3']) + // running: tuple[1] UUIDs + expect(result.running.map((r) => r.promptId)).toEqual(['r1', 'r2', 'r3']) expect(result.pending.map((p) => p.promptId)).toEqual(['p1', 'p2']) }) }) diff --git a/web/lib/comfyAdapter.js b/web/lib/comfyAdapter.js index 4aa6964..60f0d6f 100644 --- a/web/lib/comfyAdapter.js +++ b/web/lib/comfyAdapter.js @@ -114,7 +114,12 @@ export function normalizeQueue(data) { console.warn('[QueueSidebar] Unexpected queue_running entry shape:', tuple) return null } - return { promptId: String(tuple[0]), status: 'running', outputs: {} } + // Use UUID (tuple[1]) when available so the promptId matches the pending card's ID, + // enabling card reconciliation across the pending→running transition. + // Fall back to String(tuple[0]) only for single-element tuples ComfyUI may send + // before full execution context is available. + const promptId = tuple.length >= 2 ? tuple[1] : String(tuple[0]) + return { promptId, status: 'running', outputs: {} } }).filter(Boolean) const pending = (data.queue_pending ?? []).map((tuple) => { diff --git a/web/queue-sidebar.js b/web/queue-sidebar.js index 2a6f98f..1137ca9 100644 --- a/web/queue-sidebar.js +++ b/web/queue-sidebar.js @@ -280,11 +280,25 @@ function onStatus() { refresh() } -function onExecutionStart() { +function onExecutionStart({ detail }) { if (state.progressUrl) { URL.revokeObjectURL(state.progressUrl) state.progressUrl = null } + // Immediately move the task from pending → running using the prompt_id from the WS + // event, without waiting for a /queue API fetch. This ensures the badge and card + // appear instantly — including for fast or fully-cached workflows where the task + // can complete before the API fetch returns. + const promptId = detail?.prompt_id + if (promptId) { + const pendIdx = state.pending.findIndex(t => t.promptId === promptId) + if (pendIdx >= 0) { + const [moved] = state.pending.splice(pendIdx, 1) + state.running = [{ ...moved, status: 'running' }] + } else if (state.running.every(t => t.promptId !== promptId)) { + state.running = [{ promptId, status: 'running', outputs: {} }] + } + } render() } From 7f94d11117b93f49d86fe257cfb846a91ef5ac99 Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:57:02 +0800 Subject: [PATCH 8/9] test: add Playwright E2E tests for queue sidebar - 5 E2E tests: cached state, pending order, no duplicate cards, instant badge via WS execution_start, no console warnings - Shared helpers with client_id-aware queuePrompt - Playwright config (single worker, headless, failure traces) - Add test:e2e script to package.json --- .gitignore | 4 +- e2e/helpers.mjs | 117 ++++++++++++++++++++++++ e2e/queue-sidebar.spec.mjs | 180 +++++++++++++++++++++++++++++++++++++ package.json | 3 +- playwright.config.mjs | 17 ++++ 5 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 e2e/helpers.mjs create mode 100644 e2e/queue-sidebar.spec.mjs create mode 100644 playwright.config.mjs diff --git a/.gitignore b/.gitignore index c1db52b..0df54dc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ __pycache__/ *.py[cod] .DS_Store node_modules/ -package-lock.json \ No newline at end of file +package-lock.json +test-results/ +playwright-report/ \ No newline at end of file diff --git a/e2e/helpers.mjs b/e2e/helpers.mjs new file mode 100644 index 0000000..3be7fc0 --- /dev/null +++ b/e2e/helpers.mjs @@ -0,0 +1,117 @@ +const COMFY_URL = 'http://127.0.0.1:8188' + +/** + * Check if ComfyUI is reachable. + */ +export async function isComfyReachable() { + try { + const res = await fetch(`${COMFY_URL}/system_stats`) + return res.ok + } catch { + return false + } +} + +/** + * Open the Queue Sidebar tab by clicking the pi-history icon. + */ +export async function openSidebar(page) { + const tab = page.locator('.sidebar-icon-wrapper .pi-history').first() + await tab.click() + // Give the sidebar time to render its grid + await page.locator('[data-status]').first().waitFor({ state: 'attached', timeout: 5000 }) + .catch(() => {}) // Grid may be empty — that's OK +} + +/** + * Get a workflow with a modified KSampler seed to force non-cached execution. + * Tries the current graph first (app.graphToPrompt), then falls back to history. + * Returns the prompt object or null. + */ +export async function getModifiedWorkflow(page) { + return page.evaluate(async () => { + // Try current graph first + let prompt = null + try { + const result = await window.app.graphToPrompt() + prompt = result?.output + } catch {} + + // Fall back to last history entry + if (!prompt) { + const historyRes = await fetch('/history?max_items=1').then(r => r.json()) + const lastEntry = Object.values(historyRes)[0] + prompt = lastEntry?.prompt?.[2] + } + + if (!prompt) return null + + const copy = JSON.parse(JSON.stringify(prompt)) + let changed = false + for (const [, node] of Object.entries(copy)) { + if (node.class_type === 'KSampler' || node.class_type === 'KSamplerAdvanced') { + node.inputs.seed = Math.floor(Math.random() * 2 ** 32) + changed = true + } + } + return changed ? copy : null + }) +} + +/** + * Queue a prompt via the /prompt API. Returns { prompt_id, number }. + * Includes the WS client_id so ComfyUI sends execution_start / executing / + * execution_success events to our session (without it only broadcast-level + * status events are received). + */ +export async function queuePrompt(page, prompt) { + return page.evaluate(async (p) => { + const clientId = window.comfyAPI?.api?.api?.clientId ?? '' + const res = await fetch('/prompt', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt: p, client_id: clientId }), + }) + return res.json() + }, prompt) +} + +/** + * Wait until the ComfyUI queue is empty (no running, no pending). + */ +export async function waitForQueueEmpty(page, timeout = 30_000) { + const start = Date.now() + while (Date.now() - start < timeout) { + const q = await page.evaluate(async () => { + const r = await fetch('/queue').then(res => res.json()) + return { running: r.queue_running?.length ?? 0, pending: r.queue_pending?.length ?? 0 } + }) + if (q.running === 0 && q.pending === 0) return + await page.waitForTimeout(500) + } + throw new Error(`Queue did not empty within ${timeout}ms`) +} + +/** + * Collect all task cards currently in the DOM. + * Returns [{ id, status, index }]. + */ +export async function getCards(page) { + return page.evaluate(() => + [...document.querySelectorAll('[data-id][data-status]')].map((el, i) => ({ + id: el.dataset.id, + status: el.dataset.status, + index: i, + })) + ) +} + +/** + * Get the badge text content, or null if not present. + */ +export async function getBadgeText(page) { + return page.evaluate(() => { + const el = document.querySelector('.sidebar-icon-badge') + return el?.textContent?.trim() || null + }) +} diff --git a/e2e/queue-sidebar.spec.mjs b/e2e/queue-sidebar.spec.mjs new file mode 100644 index 0000000..150f039 --- /dev/null +++ b/e2e/queue-sidebar.spec.mjs @@ -0,0 +1,180 @@ +import { test, expect } from 'playwright/test' +import { + isComfyReachable, + openSidebar, + getModifiedWorkflow, + queuePrompt, + waitForQueueEmpty, + getCards, + getBadgeText, +} from './helpers.mjs' + +test.describe('Queue Sidebar E2E', () => { + test.beforeAll(async () => { + const reachable = await isComfyReachable() + if (!reachable) test.skip(true, 'ComfyUI is not running at 127.0.0.1:8188') + }) + + test.beforeEach(async ({ page }) => { + await page.goto('/', { waitUntil: 'networkidle', timeout: 30_000 }) + await page.waitForTimeout(2000) // let extensions load + await openSidebar(page) + }) + + // ── #1 — Cached state renders immediately when sidebar reopens ───── + test('cached state renders immediately on reopen', async ({ page }) => { + // First, ensure state has data by running a prompt to completion. + const wf = await getModifiedWorkflow(page) + test.skip(!wf, 'No workflow available') + + await queuePrompt(page, wf) + await waitForQueueEmpty(page) + // Wait for history refresh to populate cards + await page.waitForTimeout(2000) + + // Verify cards exist now + const cardsBefore = await getCards(page) + test.skip(cardsBefore.length === 0, 'No cards rendered after prompt completion') + + // Close the sidebar tab. + const tab = page.locator('.sidebar-icon-wrapper .pi-history').first() + await tab.click() + await page.waitForTimeout(300) + + // Intercept /queue and /history with a 3-second delay so we can prove + // that cards come from cached in-memory state, not from a fresh fetch. + await page.route('**/queue', async (route) => { + await new Promise(r => setTimeout(r, 3000)) + await route.continue() + }) + await page.route('**/history*', async (route) => { + await new Promise(r => setTimeout(r, 3000)) + await route.continue() + }) + + // Reopen the sidebar + await tab.click() + + // Cards should appear within 500ms (from cached state, not delayed API) + await expect(page.locator('[data-id][data-status]').first()) + .toBeAttached({ timeout: 500 }) + + await page.unroute('**/queue') + await page.unroute('**/history*') + }) + + // ── #2 — Pending cards appear above running cards ────────────────── + test('pending cards appear above running cards', async ({ page }) => { + const wf = await getModifiedWorkflow(page) + test.skip(!wf, 'No workflow available in history') + + // Queue two prompts with different seeds — the first starts running, + // the second stays pending. + await queuePrompt(page, wf) + const wf2 = await getModifiedWorkflow(page) + await queuePrompt(page, wf2) + + // Wait for at least one running AND one pending card to appear + await expect(async () => { + const cards = await getCards(page) + const hasRunning = cards.some(c => c.status === 'running') + const hasPending = cards.some(c => c.status === 'pending') + expect(hasRunning && hasPending).toBe(true) + }).toPass({ timeout: 15_000 }) + + // Verify order: all pending indices < all running indices + const cards = await getCards(page) + const pendingIndices = cards.filter(c => c.status === 'pending').map(c => c.index) + const runningIndices = cards.filter(c => c.status === 'running').map(c => c.index) + const maxPending = Math.max(...pendingIndices) + const minRunning = Math.min(...runningIndices) + expect(maxPending).toBeLessThan(minRunning) + + await waitForQueueEmpty(page) + }) + + // ── #3 — No duplicate cards during pending→running transition ────── + test('no duplicate cards during pending-to-running transition', async ({ page }) => { + const wf = await getModifiedWorkflow(page) + test.skip(!wf, 'No workflow available in history') + + await queuePrompt(page, wf) + + // Poll DOM rapidly, looking for duplicate data-id values + let duplicateFound = false + const start = Date.now() + + while (Date.now() - start < 15_000) { + const ids = await page.evaluate(() => + [...document.querySelectorAll('[data-id]')].map(el => el.dataset.id) + ) + const seen = new Set() + for (const id of ids) { + if (seen.has(id)) { duplicateFound = true; break } + seen.add(id) + } + if (duplicateFound) break + + const q = await page.evaluate(async () => { + const r = await fetch('/queue').then(res => res.json()) + return { running: r.queue_running?.length ?? 0, pending: r.queue_pending?.length ?? 0 } + }) + if (q.running === 0 && q.pending === 0) break + await page.waitForTimeout(100) + } + + expect(duplicateFound).toBe(false) + }) + + // ── #4 — Badge and card appear instantly on execution_start ──────── + test('badge and running card appear on execution_start', async ({ page }) => { + const wf = await getModifiedWorkflow(page) + test.skip(!wf, 'No workflow available') + + // Block /api/queue so the API-poll path (refresh → fetchQueue → render) + // can never deliver queue state. The ONLY way a running card can appear + // is through the WS execution_start → onExecutionStart → render() path. + const blocked = [] + await page.route('**/queue', async (route) => { + blocked.push(route.request().url()) + await new Promise(r => setTimeout(r, 30_000)) + await route.continue() + }) + + await queuePrompt(page, wf) + + // If onExecutionStart works, the running card appears within seconds. + // The 10s timeout is generous but still far shorter than the 30s block. + await expect(page.locator('[data-status="running"]').first()) + .toBeAttached({ timeout: 10_000 }) + + const badge = await getBadgeText(page) + expect(badge).toBeTruthy() + + // Confirm the route actually blocked at least one /api/queue request, + // proving the card did NOT come from the API-poll path. + expect(blocked.length).toBeGreaterThanOrEqual(1) + + await page.unroute('**/queue') + await waitForQueueEmpty(page) + }) + + // ── #6 — No [QueueSidebar] console.warn during normal operation ──── + test('no QueueSidebar console warnings during normal operation', async ({ page }) => { + const warnings = [] + page.on('console', msg => { + if (msg.type() === 'warning' && msg.text().includes('[QueueSidebar]')) { + warnings.push(msg.text()) + } + }) + + const wf = await getModifiedWorkflow(page) + if (wf) { + await queuePrompt(page, wf) + await waitForQueueEmpty(page) + } + + await page.waitForTimeout(1000) + expect(warnings).toEqual([]) + }) +}) diff --git a/package.json b/package.json index c4549bb..19e0189 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "type": "module", "scripts": { "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:e2e": "playwright test" }, "repository": { "type": "git", diff --git a/playwright.config.mjs b/playwright.config.mjs new file mode 100644 index 0000000..ba4062e --- /dev/null +++ b/playwright.config.mjs @@ -0,0 +1,17 @@ +import { defineConfig } from 'playwright/test' + +export default defineConfig({ + testDir: './e2e', + testMatch: '*.spec.mjs', + timeout: 60_000, + expect: { timeout: 10_000 }, + retries: 0, + workers: 1, + use: { + baseURL: 'http://127.0.0.1:8188', + headless: true, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + }, + reporter: [['list'], ['html', { open: 'never' }]], +}) From 7559bbefbf5f48cc881e89d56d9442229985fa98 Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:45:33 +0800 Subject: [PATCH 9/9] chore: bump version to 1.1.1 --- package.json | 3 ++- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 19e0189..6fef31a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "comfyui_queue_sidebar", - "version": "1.1.0", + "version": "1.1.1", "private": true, "description": "ComfyUI sidebar extension — brings back the queue panel with image previews", "type": "module", @@ -16,6 +16,7 @@ "license": "MIT", "devDependencies": { "jsdom": "^28.1.0", + "playwright": "^1.58.2", "vitest": "^4.0.18" } } diff --git a/pyproject.toml b/pyproject.toml index e3de271..c4af196 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "comfyui-queue-sidebar" -version = "1.1.0" +version = "1.1.1" description = "ComfyUI sidebar extension — brings back the queue panel with image previews" license = { file = "LICENSE" }