Add queue overlay tests and stories (#7342)

## Summary
- add Playwright queue list fixture and coverage for toggle/count
display
- update queue overlay unit tests plus storybook stories for inline
progress and job item
- adjust useJobList expectations for ordered tasks

main <-- https://github.com/Comfy-Org/ComfyUI_frontend/pull/7336 <--
https://github.com/Comfy-Org/ComfyUI_frontend/pull/7338 <--
https://github.com/Comfy-Org/ComfyUI_frontend/pull/7342

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7342-Add-queue-overlay-tests-and-stories-2c66d73d365081ae8e32d6e34f87e1d9)
by [Unito](https://www.unito.io)
This commit is contained in:
Benjamin Lu
2025-12-13 17:48:06 -08:00
committed by GitHub
parent 18303fafce
commit 53dbca9fea
9 changed files with 456 additions and 29 deletions

View File

@@ -13,6 +13,7 @@ import { ComfyTemplates } from '../helpers/templates'
import { ComfyMouse } from './ComfyMouse'
import { VueNodeHelpers } from './VueNodeHelpers'
import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox'
import { QueueList } from './components/QueueList'
import { SettingDialog } from './components/SettingDialog'
import {
NodeLibrarySidebarTab,
@@ -126,20 +127,6 @@ class ConfirmDialog {
const loc = this[locator]
await expect(loc).toBeVisible()
await loc.click()
// Wait for the dialog mask to disappear after confirming
const mask = this.page.locator('.p-dialog-mask')
const count = await mask.count()
if (count > 0) {
await mask.first().waitFor({ state: 'hidden', timeout: 3000 })
}
// Wait for workflow service to finish if it's busy
await this.page.waitForFunction(
() => window['app']?.extensionManager?.workflow?.isBusy === false,
undefined,
{ timeout: 3000 }
)
}
}
@@ -165,6 +152,7 @@ export class ComfyPage {
// Components
public readonly searchBox: ComfyNodeSearchBox
public readonly queueList: QueueList
public readonly menu: ComfyMenu
public readonly actionbar: ComfyActionbar
public readonly templates: ComfyTemplates
@@ -197,6 +185,7 @@ export class ComfyPage {
this.visibleToasts = page.locator('.p-toast-message:visible')
this.searchBox = new ComfyNodeSearchBox(page)
this.queueList = new QueueList(page)
this.menu = new ComfyMenu(page)
this.actionbar = new ComfyActionbar(page)
this.templates = new ComfyTemplates(page)
@@ -256,9 +245,6 @@ export class ComfyPage {
await this.page.evaluate(async () => {
await window['app'].extensionManager.workflow.syncWorkflows()
})
// Wait for Vue to re-render the workflow list
await this.nextFrame()
}
async setupUser(username: string) {

View File

@@ -0,0 +1,57 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
export class QueueList {
constructor(public readonly page: Page) {}
get toggleButton() {
return this.page.getByTestId('queue-toggle-button')
}
get inlineProgress() {
return this.page.getByTestId('queue-inline-progress')
}
get overlay() {
return this.page.getByTestId('queue-overlay')
}
get closeButton() {
return this.page.getByTestId('queue-overlay-close-button')
}
get jobItems() {
return this.page.getByTestId('queue-job-item')
}
get clearHistoryButton() {
return this.page.getByRole('button', { name: /Clear History/i })
}
async open() {
if (!(await this.overlay.isVisible())) {
await this.toggleButton.click()
await expect(this.overlay).toBeVisible()
}
}
async close() {
if (await this.overlay.isVisible()) {
await this.closeButton.click()
await expect(this.overlay).not.toBeVisible()
}
}
async getJobCount(state?: string) {
if (state) {
return await this.page
.locator(`[data-testid="queue-job-item"][data-job-state="${state}"]`)
.count()
}
return await this.jobItems.count()
}
getJobAction(actionKey: string) {
return this.page.getByTestId(`job-action-${actionKey}`)
}
}

View File

@@ -1,7 +1,11 @@
import { test as base } from '@playwright/test'
import type { StatusWsMessage } from '../../src/schemas/apiSchema'
export type WsMessage = { type: 'status'; data: StatusWsMessage }
export const webSocketFixture = base.extend<{
ws: { trigger(data: any, url?: string): Promise<void> }
ws: { trigger(data: WsMessage, url?: string): Promise<void> }
}>({
ws: [
async ({ page }, use) => {

View File

@@ -1,9 +1,9 @@
import type { Response } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import type { StatusWsMessage } from '../../src/schemas/apiSchema.ts'
import { comfyPageFixture } from '../fixtures/ComfyPage.ts'
import { webSocketFixture } from '../fixtures/ws.ts'
import { comfyPageFixture } from '../fixtures/ComfyPage'
import { webSocketFixture } from '../fixtures/ws'
import type { WsMessage } from '../fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
@@ -61,7 +61,7 @@ test.describe('Actionbar', () => {
// Trigger a status websocket message
const triggerStatus = async (queueSize: number) => {
await ws.trigger({
const message = {
type: 'status',
data: {
status: {
@@ -70,7 +70,9 @@ test.describe('Actionbar', () => {
}
}
}
} as StatusWsMessage)
} satisfies WsMessage
await ws.trigger(message)
}
// Extract the width from the queue response

View File

@@ -0,0 +1,157 @@
import { expect, mergeTests } from '@playwright/test'
import type { ComfyPage } from '../../fixtures/ComfyPage'
import { comfyPageFixture } from '../../fixtures/ComfyPage'
import { webSocketFixture } from '../../fixtures/ws'
import type { WsMessage } from '../../fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
type QueueState = {
running: QueueJob[]
pending: QueueJob[]
}
type QueueJob = [
string,
string,
Record<string, unknown>,
Record<string, unknown>,
string[]
]
type QueueController = {
state: QueueState
sync: (
ws: { trigger(data: WsMessage, url?: string): Promise<void> },
nextState: Partial<QueueState>
) => Promise<void>
}
test.describe('Queue UI', () => {
let queue: QueueController
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.route('**/api/prompt', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
prompt_id: 'mock-prompt-id',
number: 1,
node_errors: {}
})
})
})
// Mock history to avoid pulling real data
await comfyPage.page.route('**/api/history**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ History: [] })
})
})
queue = await createQueueController(comfyPage)
})
test('toggles overlay and updates count from status events', async ({
comfyPage,
ws
}) => {
await queue.sync(ws, { running: [], pending: [] })
await expect(comfyPage.queueList.toggleButton).toContainText('0')
await expect(comfyPage.queueList.toggleButton).toContainText(/queued/i)
await expect(comfyPage.queueList.overlay).toBeHidden()
await queue.sync(ws, {
pending: [queueJob('1', 'mock-pending', 'client-a')]
})
await expect(comfyPage.queueList.toggleButton).toContainText('1')
await expect(comfyPage.queueList.toggleButton).toContainText(/queued/i)
await comfyPage.queueList.open()
await expect(comfyPage.queueList.overlay).toBeVisible()
await expect(comfyPage.queueList.jobItems).toHaveCount(1)
await comfyPage.queueList.close()
await expect(comfyPage.queueList.overlay).toBeHidden()
})
test('displays running and pending jobs via status updates', async ({
comfyPage,
ws
}) => {
await queue.sync(ws, {
running: [queueJob('2', 'mock-running', 'client-b')],
pending: [queueJob('3', 'mock-pending', 'client-c')]
})
await comfyPage.queueList.open()
await expect(comfyPage.queueList.jobItems).toHaveCount(2)
const firstJob = comfyPage.queueList.jobItems.first()
await firstJob.hover()
const cancelAction = firstJob
.getByTestId('job-action-cancel-running')
.or(firstJob.getByTestId('job-action-cancel-hover'))
await expect(cancelAction).toBeVisible()
})
})
const queueJob = (
queueIndex: string,
promptId: string,
clientId: string
): QueueJob => [
queueIndex,
promptId,
{ client_id: clientId },
{ class_type: 'Note' },
['output']
]
const createQueueController = async (
comfyPage: ComfyPage
): Promise<QueueController> => {
const state: QueueState = { running: [], pending: [] }
// Single queue handler reads the latest in-memory state
await comfyPage.page.route('**/api/queue', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
queue_running: state.running,
queue_pending: state.pending
})
})
})
const sync = async (
ws: { trigger(data: WsMessage, url?: string): Promise<void> },
nextState: Partial<QueueState>
) => {
if (nextState.running) state.running = nextState.running
if (nextState.pending) state.pending = nextState.pending
const total = state.running.length + state.pending.length
const queueResponse = comfyPage.page.waitForResponse('**/api/queue')
await ws.trigger({
type: 'status',
data: {
status: { exec_info: { queue_remaining: total } }
}
})
await queueResponse
}
return { state, sync }
}

View File

@@ -0,0 +1,211 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import QueueInlineProgressSummary from './QueueInlineProgressSummary.vue'
import { useExecutionStore } from '@/stores/executionStore'
import { ChangeTracker } from '@/scripts/changeTracker'
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import type {
ComfyWorkflowJSON,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { NodeProgressState, ProgressWsMessage } from '@/schemas/apiSchema'
type SeedOptions = {
promptId: string
nodes: Record<NodeId, boolean>
runningNodeId?: NodeId
runningNodeTitle?: string
runningNodeType?: string
currentValue?: number
currentMax?: number
}
function createWorkflow({
promptId,
nodes,
runningNodeId,
runningNodeTitle,
runningNodeType
}: SeedOptions): ComfyWorkflow {
const workflow = new ComfyWorkflow({
path: `${ComfyWorkflow.basePath}${promptId}.json`,
modified: Date.now(),
size: -1
})
const workflowState: ComfyWorkflowJSON = {
last_node_id: Object.keys(nodes).length,
last_link_id: 0,
nodes: Object.keys(nodes).map((id, index) => ({
id,
type: id === runningNodeId ? (runningNodeType ?? 'Node') : 'Node',
title: id === runningNodeId ? (runningNodeTitle ?? '') : `Node ${id}`,
pos: [index * 120, 0],
size: [240, 120],
flags: {},
order: index,
mode: 0,
properties: {},
widgets_values: []
})),
links: [],
groups: [],
config: {},
extra: {},
version: 0.4
}
workflow.changeTracker = new ChangeTracker(workflow, workflowState)
return workflow
}
function resetExecutionStore() {
const exec = useExecutionStore()
exec.activePromptId = null
exec.queuedPrompts = {}
exec.nodeProgressStates = {}
exec.nodeProgressStatesByPrompt = {}
exec._executingNodeProgress = null
exec.lastExecutionError = null
exec.lastNodeErrors = null
exec.initializingPromptIds = new Set()
exec.promptIdToWorkflowId = new Map()
}
function seedExecutionState({
promptId,
nodes,
runningNodeId,
runningNodeTitle,
runningNodeType,
currentValue = 0,
currentMax = 100
}: SeedOptions) {
resetExecutionStore()
const exec = useExecutionStore()
const workflow = runningNodeId
? createWorkflow({
promptId,
nodes,
runningNodeId,
runningNodeTitle,
runningNodeType
})
: undefined
exec.activePromptId = promptId
exec.queuedPrompts = {
[promptId]: {
nodes,
...(workflow ? { workflow } : {})
}
}
const nodeProgress: Record<string, NodeProgressState> = runningNodeId
? {
[String(runningNodeId)]: {
value: currentValue,
max: currentMax,
state: 'running',
node_id: runningNodeId,
display_node_id: runningNodeId,
prompt_id: promptId
}
}
: {}
exec.nodeProgressStates = nodeProgress
exec.nodeProgressStatesByPrompt = runningNodeId
? { [promptId]: nodeProgress }
: {}
exec._executingNodeProgress = runningNodeId
? ({
value: currentValue,
max: currentMax,
prompt_id: promptId,
node: runningNodeId
} satisfies ProgressWsMessage)
: null
}
const meta: Meta<typeof QueueInlineProgressSummary> = {
title: 'Queue/QueueInlineProgressSummary',
component: QueueInlineProgressSummary,
parameters: {
layout: 'padded',
backgrounds: {
default: 'light'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const RunningKSampler: Story = {
render: () => ({
components: { QueueInlineProgressSummary },
setup() {
seedExecutionState({
promptId: 'prompt-running',
nodes: { '1': true, '2': false, '3': false, '4': true },
runningNodeId: '2',
runningNodeTitle: 'KSampler',
runningNodeType: 'KSampler',
currentValue: 12,
currentMax: 100
})
return {}
},
template: `
<div style="background: var(--color-surface-primary); width: 420px; padding: 12px;">
<QueueInlineProgressSummary />
</div>
`
})
}
export const RunningWithFallbackName: Story = {
render: () => ({
components: { QueueInlineProgressSummary },
setup() {
seedExecutionState({
promptId: 'prompt-fallback',
nodes: { '10': true, '11': true, '12': false, '13': true },
runningNodeId: '12',
runningNodeTitle: '',
runningNodeType: 'custom_node',
currentValue: 78,
currentMax: 100
})
return {}
},
template: `
<div style="background: var(--color-surface-primary); width: 420px; padding: 12px;">
<QueueInlineProgressSummary />
</div>
`
})
}
export const ProgressWithoutCurrentNode: Story = {
render: () => ({
components: { QueueInlineProgressSummary },
setup() {
seedExecutionState({
promptId: 'prompt-progress-only',
nodes: { '21': true, '22': true, '23': true, '24': false }
})
return {}
},
template: `
<div style="background: var(--color-surface-primary); width: 420px; padding: 12px;">
<QueueInlineProgressSummary />
</div>
`
})
}

View File

@@ -36,7 +36,7 @@ const i18n = createI18n({
locale: 'en',
messages: {
en: {
g: { more: 'More' },
g: { more: 'More', close: 'Close' },
sideToolbar: {
queueProgressOverlay: {
running: 'running',
@@ -95,4 +95,13 @@ describe('QueueOverlayHeader', () => {
expect(popoverHideSpy).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('clearHistory')).toHaveLength(1)
})
it('emits close when close button is clicked', async () => {
const wrapper = mountHeader()
const closeButton = wrapper.get('button[aria-label="Close"]')
await closeButton.trigger('click')
expect(wrapper.emitted('close')).toHaveLength(1)
})
})

View File

@@ -64,7 +64,8 @@ export const RunningWithCurrent: Story = {
state: 'running',
title: 'Generating image',
progressTotalPercent: 66,
progressCurrentPercent: 10
progressCurrentPercent: 10,
runningNodeName: 'KSampler'
}
}

View File

@@ -312,7 +312,7 @@ describe('useJobList', () => {
expect(vi.getTimerCount()).toBe(0)
})
it('sorts all tasks by queue index descending', async () => {
it('sorts tasks by queue index descending', async () => {
queueStoreMock.pendingTasks = [
createTask({ promptId: 'p', queueIndex: 1, mockState: 'pending' })
]
@@ -323,10 +323,10 @@ describe('useJobList', () => {
createTask({ promptId: 'h', queueIndex: 3, mockState: 'completed' })
]
const { allTasksSorted } = initComposable()
const { orderedTasks } = initComposable()
await flush()
expect(allTasksSorted.value.map((task) => task.promptId)).toEqual([
expect(orderedTasks.value.map((task) => task.promptId)).toEqual([
'r',
'h',
'p'
@@ -389,7 +389,7 @@ describe('useJobList', () => {
)
})
it('groups job items by date label and sorts by total generation time when requested', async () => {
it('groups job items by date label using queue order', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-01-10T12:00:00Z'))
queueStoreMock.historyTasks = [