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..6fef31a 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,13 @@ { "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", "scripts": { "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:e2e": "playwright test" }, "repository": { "type": "git", @@ -15,6 +16,7 @@ "license": "MIT", "devDependencies": { "jsdom": "^28.1.0", + "playwright": "^1.58.2", "vitest": "^4.0.18" } } 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' }]], +}) 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" } diff --git a/tests/comfyAdapter.test.js b/tests/comfyAdapter.test.js index b1d8e5d..178f6c0 100644 --- a/tests/comfyAdapter.test.js +++ b/tests/comfyAdapter.test.js @@ -13,10 +13,11 @@ 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) + // running uses tuple[1] (UUID) so promptId is consistent with pending expect(result.running).toEqual([ { promptId: 'prompt-1', status: 'running', outputs: {} }, ]) @@ -25,6 +26,21 @@ describe('normalizeQueue', () => { ]) }) + 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({ @@ -36,10 +52,11 @@ 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) + // tuple has length >= 2, so uses tuple[1] (UUID) expect(result.running).toEqual([ { promptId: 'valid-id', status: 'running', outputs: {} }, ]) @@ -47,6 +64,22 @@ describe('normalizeQueue', () => { 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 = { @@ -61,12 +94,13 @@ 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) expect(result.running).toHaveLength(3) expect(result.pending).toHaveLength(2) + // 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/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/lib/comfyAdapter.js b/web/lib/comfyAdapter.js index b61d744..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: 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/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 = { diff --git a/web/queue-sidebar.js b/web/queue-sidebar.js index f36bd44..1137ca9 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 = @@ -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) } @@ -269,6 +270,7 @@ function buildSidebar(sidebarEl) { ) scrollEl.appendChild(gridEl) sidebarEl.appendChild(scrollEl) + render() refresh() } @@ -278,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() }