From 53dbca9fea29c5a446fa2725a0a195bfdbee797c Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Sat, 13 Dec 2025 17:48:06 -0800 Subject: [PATCH] Add queue overlay tests and stories (#7342) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) --- browser_tests/fixtures/ComfyPage.ts | 20 +- .../fixtures/components/QueueList.ts | 57 +++++ browser_tests/fixtures/ws.ts | 6 +- browser_tests/tests/actionbar.spec.ts | 12 +- browser_tests/tests/queue/queueList.spec.ts | 157 +++++++++++++ .../QueueInlineProgressSummary.stories.ts | 211 ++++++++++++++++++ .../queue/QueueOverlayHeader.test.ts | 11 +- .../queue/job/QueueJobItem.stories.ts | 3 +- tests-ui/tests/composables/useJobList.test.ts | 8 +- 9 files changed, 456 insertions(+), 29 deletions(-) create mode 100644 browser_tests/fixtures/components/QueueList.ts create mode 100644 browser_tests/tests/queue/queueList.spec.ts create mode 100644 src/components/queue/QueueInlineProgressSummary.stories.ts diff --git a/browser_tests/fixtures/ComfyPage.ts b/browser_tests/fixtures/ComfyPage.ts index 782af9921..f5bee8269 100644 --- a/browser_tests/fixtures/ComfyPage.ts +++ b/browser_tests/fixtures/ComfyPage.ts @@ -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) { diff --git a/browser_tests/fixtures/components/QueueList.ts b/browser_tests/fixtures/components/QueueList.ts new file mode 100644 index 000000000..453ef0765 --- /dev/null +++ b/browser_tests/fixtures/components/QueueList.ts @@ -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}`) + } +} diff --git a/browser_tests/fixtures/ws.ts b/browser_tests/fixtures/ws.ts index f1ab1a538..7606f73d2 100644 --- a/browser_tests/fixtures/ws.ts +++ b/browser_tests/fixtures/ws.ts @@ -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 } + ws: { trigger(data: WsMessage, url?: string): Promise } }>({ ws: [ async ({ page }, use) => { diff --git a/browser_tests/tests/actionbar.spec.ts b/browser_tests/tests/actionbar.spec.ts index bd086b461..23e327b5c 100644 --- a/browser_tests/tests/actionbar.spec.ts +++ b/browser_tests/tests/actionbar.spec.ts @@ -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 diff --git a/browser_tests/tests/queue/queueList.spec.ts b/browser_tests/tests/queue/queueList.spec.ts new file mode 100644 index 000000000..e0bb04a9c --- /dev/null +++ b/browser_tests/tests/queue/queueList.spec.ts @@ -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, + Record, + string[] +] + +type QueueController = { + state: QueueState + sync: ( + ws: { trigger(data: WsMessage, url?: string): Promise }, + nextState: Partial + ) => Promise +} + +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 => { + 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 }, + nextState: Partial + ) => { + 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 } +} diff --git a/src/components/queue/QueueInlineProgressSummary.stories.ts b/src/components/queue/QueueInlineProgressSummary.stories.ts new file mode 100644 index 000000000..9b43b9ced --- /dev/null +++ b/src/components/queue/QueueInlineProgressSummary.stories.ts @@ -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 + 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 = 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 = { + title: 'Queue/QueueInlineProgressSummary', + component: QueueInlineProgressSummary, + parameters: { + layout: 'padded', + backgrounds: { + default: 'light' + } + } +} + +export default meta +type Story = StoryObj + +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: ` +
+ +
+ ` + }) +} + +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: ` +
+ +
+ ` + }) +} + +export const ProgressWithoutCurrentNode: Story = { + render: () => ({ + components: { QueueInlineProgressSummary }, + setup() { + seedExecutionState({ + promptId: 'prompt-progress-only', + nodes: { '21': true, '22': true, '23': true, '24': false } + }) + + return {} + }, + template: ` +
+ +
+ ` + }) +} diff --git a/src/components/queue/QueueOverlayHeader.test.ts b/src/components/queue/QueueOverlayHeader.test.ts index 2a64cdc6f..8d26f8fb6 100644 --- a/src/components/queue/QueueOverlayHeader.test.ts +++ b/src/components/queue/QueueOverlayHeader.test.ts @@ -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) + }) }) diff --git a/src/components/queue/job/QueueJobItem.stories.ts b/src/components/queue/job/QueueJobItem.stories.ts index 6f4246e79..21e02f798 100644 --- a/src/components/queue/job/QueueJobItem.stories.ts +++ b/src/components/queue/job/QueueJobItem.stories.ts @@ -64,7 +64,8 @@ export const RunningWithCurrent: Story = { state: 'running', title: 'Generating image', progressTotalPercent: 66, - progressCurrentPercent: 10 + progressCurrentPercent: 10, + runningNodeName: 'KSampler' } } diff --git a/tests-ui/tests/composables/useJobList.test.ts b/tests-ui/tests/composables/useJobList.test.ts index c65f79afe..d433804d7 100644 --- a/tests-ui/tests/composables/useJobList.test.ts +++ b/tests-ui/tests/composables/useJobList.test.ts @@ -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 = [