Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ __pycache__/
*.py[cod]
.DS_Store
node_modules/
package-lock.json
package-lock.json
test-results/
playwright-report/
117 changes: 117 additions & 0 deletions e2e/helpers.mjs
Original file line number Diff line number Diff line change
@@ -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
})
}
180 changes: 180 additions & 0 deletions e2e/queue-sidebar.spec.mjs
Original file line number Diff line number Diff line change
@@ -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([])
})
})
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -15,6 +16,7 @@
"license": "MIT",
"devDependencies": {
"jsdom": "^28.1.0",
"playwright": "^1.58.2",
"vitest": "^4.0.18"
}
}
17 changes: 17 additions & 0 deletions playwright.config.mjs
Original file line number Diff line number Diff line change
@@ -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' }]],
})
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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" }

Expand Down
Loading