mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-01 22:09:55 +00:00
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:
@@ -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) {
|
||||
|
||||
57
browser_tests/fixtures/components/QueueList.ts
Normal file
57
browser_tests/fixtures/components/QueueList.ts
Normal 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}`)
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
157
browser_tests/tests/queue/queueList.spec.ts
Normal file
157
browser_tests/tests/queue/queueList.spec.ts
Normal 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 }
|
||||
}
|
||||
211
src/components/queue/QueueInlineProgressSummary.stories.ts
Normal file
211
src/components/queue/QueueInlineProgressSummary.stories.ts
Normal 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>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -64,7 +64,8 @@ export const RunningWithCurrent: Story = {
|
||||
state: 'running',
|
||||
title: 'Generating image',
|
||||
progressTotalPercent: 66,
|
||||
progressCurrentPercent: 10
|
||||
progressCurrentPercent: 10,
|
||||
runningNodeName: 'KSampler'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user