test: add in-memory jobs backend fixture

This commit is contained in:
Benjamin Lu
2026-04-15 15:13:55 -07:00
parent 0e62ef0cbc
commit f257b7136e
9 changed files with 702 additions and 13 deletions

View File

@@ -108,6 +108,11 @@ Browser tests in this project follow a specific organization pattern:
- Organized by functionality (e.g., `widget.spec.ts`, `interaction.spec.ts`)
- Snapshot directories (e.g., `widget.spec.ts-snapshots/`) contain reference screenshots
- **Helper Unit Tests**: Colocated alongside browser-test helpers and fixtures
- Use `.test.ts`
- Run under Vitest, not Playwright
- Do not place them under `browser_tests/tests/`, which is reserved for Playwright `*.spec.ts`
- **Utilities**: Located in `utils/` - Common utility functions
- `litegraphUtils.ts` - Utilities for working with LiteGraph nodes

View File

@@ -0,0 +1,398 @@
import type { Page, Route } from '@playwright/test'
import type {
JobDetailResponse,
JobEntry,
JobsListResponse
} from '@comfyorg/ingest-types'
import { describe, expect, it, vi } from 'vitest'
import { InMemoryJobsBackend } from '@e2e/fixtures/helpers/InMemoryJobsBackend'
import type { SeededJob } from '@e2e/fixtures/helpers/InMemoryJobsBackend'
type RouteHandler = (route: Route) => Promise<void>
type RegisteredRoute = {
pattern: string | RegExp
handler: RouteHandler
}
type PageStub = Pick<Page, 'route' | 'unroute'>
type FulfillOptions = NonNullable<Parameters<Route['fulfill']>[0]>
function createPageStub(): {
page: PageStub
routes: RegisteredRoute[]
} {
const routes: RegisteredRoute[] = []
const page = {
route: vi.fn(async (pattern: string | RegExp, handler: RouteHandler) => {
routes.push({ pattern, handler })
}),
unroute: vi.fn(async () => {})
} satisfies PageStub
return { page, routes }
}
function createSeededJob({
id,
status = 'completed',
createTime,
executionStartTime = createTime,
executionEndTime = createTime + 1_000,
workflowId
}: {
id: string
status?: JobEntry['status']
createTime: number
executionStartTime?: number
executionEndTime?: number
workflowId?: string
}): SeededJob {
const previewOutput = { filename: `${id}.png` }
const terminalState =
status === 'completed' || status === 'failed' || status === 'cancelled'
const listItem: JobEntry = {
id,
status,
create_time: createTime,
...(workflowId ? { workflow_id: workflowId } : {}),
...(terminalState
? {
preview_output: previewOutput,
outputs_count: 1,
execution_start_time: executionStartTime,
execution_end_time: executionEndTime
}
: {})
}
const detail: JobDetailResponse = {
id,
status,
create_time: createTime,
update_time: executionEndTime,
...(workflowId ? { workflow_id: workflowId } : {}),
...(terminalState
? {
preview_output: previewOutput,
outputs_count: 1,
outputs: {}
}
: {})
}
return { listItem, detail }
}
function getRouteHandler(routes: RegisteredRoute[], url: string): RouteHandler {
const registeredRoute = routes.find(({ pattern }) =>
typeof pattern === 'string' ? pattern === url : pattern.test(url)
)
if (!registeredRoute) {
throw new Error(`Expected route handler for ${url}`)
}
return registeredRoute.handler
}
function createRouteInvocation({
url,
method = 'POST',
requestBody
}: {
url: string
method?: string
requestBody?: unknown
}): {
route: Route
continued: ReturnType<typeof vi.fn>
getFulfilled: () => FulfillOptions | undefined
} {
let fulfilled: FulfillOptions | undefined
const continued = vi.fn(async () => {})
const route = {
request: () =>
({
method: () => method,
url: () => url,
postDataJSON: () => requestBody
}) as ReturnType<Route['request']>,
continue: continued,
fulfill: vi.fn(async (options?: FulfillOptions) => {
if (!options) {
throw new Error('Expected route to be fulfilled with options')
}
fulfilled = options
})
} satisfies Pick<Route, 'request' | 'continue' | 'fulfill'>
return {
route: route as unknown as Route,
continued,
getFulfilled: () => fulfilled
}
}
function bodyToText(body: FulfillOptions['body']): string {
if (body instanceof Uint8Array) {
return Buffer.from(body).toString('utf-8')
}
return `${body ?? ''}`
}
async function invokeJsonRoute<T>(
handler: RouteHandler,
args: {
url: string
requestBody?: unknown
}
): Promise<{
status: number | undefined
body: T
}> {
const invocation = createRouteInvocation(args)
await handler(invocation.route)
const fulfilled = invocation.getFulfilled()
expect(fulfilled).toBeDefined()
return {
status: fulfilled?.status,
body: JSON.parse(bodyToText(fulfilled?.body)) as T
}
}
describe('InMemoryJobsBackend', () => {
it('lists jobs sorted by create_time descending by default', async () => {
const { page, routes } = createPageStub()
const backend = new InMemoryJobsBackend(page as unknown as Page)
await backend.seed([
createSeededJob({ id: 'job-oldest', createTime: 1_000 }),
createSeededJob({ id: 'job-newest', createTime: 3_000 }),
createSeededJob({ id: 'job-middle', createTime: 2_000 })
])
const listRouteHandler = getRouteHandler(
routes,
'http://localhost/api/jobs'
)
const response = await invokeJsonRoute<JobsListResponse>(listRouteHandler, {
url: 'http://localhost/api/jobs?offset=-1&limit=0'
})
expect(response.body.jobs.map((job) => job.id)).toEqual([
'job-newest',
'job-middle',
'job-oldest'
])
expect(response.body.pagination).toEqual({
offset: 0,
limit: 3,
total: 3,
has_more: false
})
})
it('filters by status and workflow_id, then sorts and paginates by execution_duration', async () => {
const { page, routes } = createPageStub()
const backend = new InMemoryJobsBackend(page as unknown as Page)
await backend.seed([
createSeededJob({
id: 'job-fast',
status: 'completed',
workflowId: 'wf-1',
createTime: 1_000,
executionEndTime: 1_100
}),
createSeededJob({
id: 'job-slow',
status: 'completed',
workflowId: 'wf-1',
createTime: 2_000,
executionEndTime: 4_000
}),
createSeededJob({
id: 'job-other-workflow',
status: 'completed',
workflowId: 'wf-2',
createTime: 3_000,
executionEndTime: 8_000
}),
createSeededJob({
id: 'job-pending',
status: 'pending',
workflowId: 'wf-1',
createTime: 4_000
})
])
const listRouteHandler = getRouteHandler(
routes,
'http://localhost/api/jobs'
)
const response = await invokeJsonRoute<JobsListResponse>(listRouteHandler, {
url: 'http://localhost/api/jobs?status=completed&workflow_id=wf-1&sort_by=execution_duration&sort_order=asc&offset=1&limit=1'
})
expect(response.body.jobs.map((job) => job.id)).toEqual(['job-slow'])
expect(response.body.pagination).toEqual({
offset: 1,
limit: 1,
total: 2,
has_more: false
})
})
it('returns job detail responses by id', async () => {
const { page, routes } = createPageStub()
const backend = new InMemoryJobsBackend(page as unknown as Page)
const seededJob = createSeededJob({
id: 'job-detail',
createTime: 5_000,
workflowId: 'wf-detail'
})
await backend.seed([seededJob])
const detailRouteHandler = getRouteHandler(
routes,
'http://localhost/api/jobs/job-detail'
)
const response = await invokeJsonRoute<JobDetailResponse>(
detailRouteHandler,
{
url: 'http://localhost/api/jobs/job-detail'
}
)
expect(response.status).toBe(200)
expect(response.body).toEqual(seededJob.detail)
})
it('returns 404 for unknown job detail requests', async () => {
const { page, routes } = createPageStub()
const backend = new InMemoryJobsBackend(page as unknown as Page)
await backend.seed([])
const detailRouteHandler = getRouteHandler(
routes,
'http://localhost/api/jobs/missing-job'
)
const response = await invokeJsonRoute<{ error: string }>(
detailRouteHandler,
{
url: 'http://localhost/api/jobs/missing-job'
}
)
expect(response.status).toBe(404)
expect(response.body).toEqual({ error: 'Job not found' })
})
it('clears terminal jobs while preserving in-progress jobs for history clear', async () => {
const { page, routes } = createPageStub()
const backend = new InMemoryJobsBackend(page as unknown as Page)
await backend.seed([
createSeededJob({
id: 'job-completed',
status: 'completed',
createTime: 1_000
}),
createSeededJob({
id: 'job-failed',
status: 'failed',
createTime: 2_000
}),
createSeededJob({
id: 'job-running',
status: 'in_progress',
createTime: 3_000
})
])
const historyRouteHandler = getRouteHandler(
routes,
'http://localhost/api/history'
)
const clearInvocation = createRouteInvocation({
url: 'http://localhost/api/history',
requestBody: { clear: true }
})
await historyRouteHandler(clearInvocation.route)
const listRouteHandler = getRouteHandler(
routes,
'http://localhost/api/jobs'
)
const response = await invokeJsonRoute<JobsListResponse>(listRouteHandler, {
url: 'http://localhost/api/jobs'
})
expect(response.body.jobs.map((job) => job.id)).toEqual(['job-running'])
})
it('deletes specific jobs via the history endpoint', async () => {
const { page, routes } = createPageStub()
const backend = new InMemoryJobsBackend(page as unknown as Page)
await backend.seed([
createSeededJob({ id: 'job-keep', createTime: 1_000 }),
createSeededJob({ id: 'job-delete', createTime: 2_000 })
])
const historyRouteHandler = getRouteHandler(
routes,
'http://localhost/api/history'
)
const deleteInvocation = createRouteInvocation({
url: 'http://localhost/api/history',
requestBody: { delete: ['job-delete'] }
})
await historyRouteHandler(deleteInvocation.route)
const listRouteHandler = getRouteHandler(
routes,
'http://localhost/api/jobs'
)
const response = await invokeJsonRoute<JobsListResponse>(listRouteHandler, {
url: 'http://localhost/api/jobs'
})
expect(response.body.jobs.map((job) => job.id)).toEqual(['job-keep'])
})
it('falls through non-POST history requests', async () => {
const { page, routes } = createPageStub()
const backend = new InMemoryJobsBackend(page as unknown as Page)
await backend.seed([createSeededJob({ id: 'job-history', createTime: 1 })])
const historyRouteHandler = getRouteHandler(
routes,
'http://localhost/api/history'
)
const invocation = createRouteInvocation({
url: 'http://localhost/api/history',
method: 'GET'
})
await historyRouteHandler(invocation.route)
expect(invocation.continued).toHaveBeenCalledTimes(1)
expect(invocation.getFulfilled()).toBeUndefined()
})
})

View File

@@ -0,0 +1,213 @@
import type { Page, Route } from '@playwright/test'
import type {
JobDetailResponse,
JobEntry,
JobsListResponse
} from '@comfyorg/ingest-types'
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
const jobDetailRoutePattern = /\/api\/jobs\/[^/?#]+(?:\?.*)?$/
const historyRoutePattern = /\/api\/history(?:\?.*)?$/
export type SeededJob = {
listItem: JobEntry
detail: JobDetailResponse
}
function parseLimit(url: URL, total: number): number {
const value = Number(url.searchParams.get('limit'))
if (!Number.isInteger(value) || value <= 0) {
return total
}
return value
}
function parseOffset(url: URL): number {
const value = Number(url.searchParams.get('offset'))
if (!Number.isInteger(value) || value < 0) {
return 0
}
return value
}
function getExecutionDuration(job: JobEntry): number {
const start = job.execution_start_time ?? 0
const end = job.execution_end_time ?? 0
return end - start
}
function getJobIdFromRequest(route: Route): string | null {
const url = new URL(route.request().url())
const jobId = url.pathname.split('/').at(-1)
return jobId ? decodeURIComponent(jobId) : null
}
export class InMemoryJobsBackend {
private listRouteHandler: ((route: Route) => Promise<void>) | null = null
private detailRouteHandler: ((route: Route) => Promise<void>) | null = null
private historyRouteHandler: ((route: Route) => Promise<void>) | null = null
private seededJobs = new Map<string, SeededJob>()
constructor(private readonly page: Page) {}
async seed(jobs: SeededJob[]): Promise<void> {
this.seededJobs = new Map(
jobs.map((job) => [job.listItem.id, job] satisfies [string, SeededJob])
)
await this.ensureRoutesRegistered()
}
async clear(): Promise<void> {
this.seededJobs.clear()
if (this.listRouteHandler) {
await this.page.unroute(jobsListRoutePattern, this.listRouteHandler)
this.listRouteHandler = null
}
if (this.detailRouteHandler) {
await this.page.unroute(jobDetailRoutePattern, this.detailRouteHandler)
this.detailRouteHandler = null
}
if (this.historyRouteHandler) {
await this.page.unroute(historyRoutePattern, this.historyRouteHandler)
this.historyRouteHandler = null
}
}
private async ensureRoutesRegistered(): Promise<void> {
if (!this.listRouteHandler) {
this.listRouteHandler = async (route: Route) => {
const url = new URL(route.request().url())
const statuses = url.searchParams
.get('status')
?.split(',')
.map((status) => status.trim())
.filter(Boolean)
const workflowId = url.searchParams.get('workflow_id')
const sortBy = url.searchParams.get('sort_by')
const sortOrder = url.searchParams.get('sort_order') === 'asc' ? 1 : -1
let filteredJobs = Array.from(
this.seededJobs.values(),
({ listItem }) => listItem
)
if (statuses?.length) {
filteredJobs = filteredJobs.filter((job) =>
statuses.includes(job.status)
)
}
if (workflowId) {
filteredJobs = filteredJobs.filter(
(job) => job.workflow_id === workflowId
)
}
filteredJobs.sort((left, right) => {
const leftValue =
sortBy === 'execution_duration'
? getExecutionDuration(left)
: left.create_time
const rightValue =
sortBy === 'execution_duration'
? getExecutionDuration(right)
: right.create_time
return (leftValue - rightValue) * sortOrder
})
const offset = parseOffset(url)
const total = filteredJobs.length
const limit = parseLimit(url, total)
const visibleJobs = filteredJobs.slice(offset, offset + limit)
const response = {
jobs: visibleJobs,
pagination: {
offset,
limit,
total,
has_more: offset + visibleJobs.length < total
}
} satisfies JobsListResponse
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
}
await this.page.route(jobsListRoutePattern, this.listRouteHandler)
}
if (!this.detailRouteHandler) {
this.detailRouteHandler = async (route: Route) => {
const jobId = getJobIdFromRequest(route)
const job = jobId ? this.seededJobs.get(jobId) : undefined
if (!job) {
await route.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ error: 'Job not found' })
})
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(job.detail)
})
}
await this.page.route(jobDetailRoutePattern, this.detailRouteHandler)
}
if (!this.historyRouteHandler) {
this.historyRouteHandler = async (route: Route) => {
const request = route.request()
if (request.method() !== 'POST') {
await route.continue()
return
}
const requestBody = request.postDataJSON() as
| { delete?: string[]; clear?: boolean }
| undefined
if (requestBody?.clear) {
this.seededJobs = new Map(
Array.from(this.seededJobs).filter(([, job]) => {
const status = job.listItem.status
return status === 'pending' || status === 'in_progress'
})
)
}
if (requestBody?.delete?.length) {
for (const jobId of requestBody.delete) {
this.seededJobs.delete(jobId)
}
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({})
})
}
await this.page.route(historyRoutePattern, this.historyRouteHandler)
}
}
}

View File

@@ -0,0 +1,50 @@
import type { JobDetailResponse, JobEntry } from '@comfyorg/ingest-types'
import type { SeededJob } from '@e2e/fixtures/helpers/InMemoryJobsBackend'
export function createMockJob(
overrides: Partial<JobEntry> & { id: string }
): JobEntry {
const now = Date.now()
return {
status: 'completed',
create_time: now,
execution_start_time: now,
execution_end_time: now + 5_000,
preview_output: {
filename: `output_${overrides.id}.png`,
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1,
...overrides
}
}
function isTerminalStatus(status: JobEntry['status']) {
return status === 'completed' || status === 'failed' || status === 'cancelled'
}
function createSeededJob(listItem: JobEntry): SeededJob {
const updateTime =
listItem.execution_end_time ??
listItem.execution_start_time ??
listItem.create_time
const detail: JobDetailResponse = {
...listItem,
update_time: updateTime,
...(isTerminalStatus(listItem.status) ? { outputs: {} } : {})
}
return {
listItem,
detail
}
}
export function createSeededJobs(listItems: readonly JobEntry[]): SeededJob[] {
return listItems.map(createSeededJob)
}

View File

@@ -0,0 +1,15 @@
import { test as base } from '@playwright/test'
import { InMemoryJobsBackend } from '@e2e/fixtures/helpers/InMemoryJobsBackend'
export const jobsBackendFixture = base.extend<{
jobsBackend: InMemoryJobsBackend
}>({
jobsBackend: async ({ page }, use) => {
const jobsBackend = new InMemoryJobsBackend(page)
await use(jobsBackend)
await jobsBackend.clear()
}
})

View File

@@ -1,13 +1,19 @@
import { expect } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import type { JobEntry } from '@comfyorg/ingest-types'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { jobsBackendFixture } from '@e2e/fixtures/jobsBackendFixture'
import {
createMockJob,
createSeededJobs
} from '@e2e/fixtures/helpers/jobFixtures'
import { TestIds } from '@e2e/fixtures/selectors'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
const test = mergeTests(comfyPageFixture, jobsBackendFixture)
const now = Date.now()
const MOCK_JOBS: RawJobListItem[] = [
const MOCK_JOBS: JobEntry[] = [
createMockJob({
id: 'job-completed-1',
status: 'completed',
@@ -35,16 +41,14 @@ const MOCK_JOBS: RawJobListItem[] = [
]
test.describe('Queue overlay', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(MOCK_JOBS)
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', false)
test.beforeEach(async ({ comfyPage, jobsBackend }) => {
await jobsBackend.seed(createSeededJobs(MOCK_JOBS))
await comfyPage.setupSettings({
'Comfy.Queue.QPOV2': false
})
await comfyPage.setup()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('Toggle button opens expanded queue overlay', async ({ comfyPage }) => {
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
await toggle.click()

View File

@@ -17,7 +17,8 @@ The ComfyUI Frontend project uses **colocated tests** - test files are placed al
- **Component Tests**: Located directly alongside their components (e.g., `MyComponent.test.ts` next to `MyComponent.vue`)
- **Unit Tests**: Located alongside their source files (e.g., `myUtil.test.ts` next to `myUtil.ts`)
- **Store Tests**: Located in `src/stores/` alongside their store files
- **Browser Tests**: Located in the `browser_tests/` directory (see dedicated README there)
- **Browser Tests**: Playwright specs live in `browser_tests/tests/**/*.spec.ts` (see dedicated README there)
- **Browser Test Helper Unit Tests**: Vitest tests for browser fixtures/helpers may be colocated in `browser_tests/` as `*.test.ts`, outside `browser_tests/tests/`
### Test File Naming

View File

@@ -20,6 +20,7 @@ const maybeLocalOptions: PlaywrightTestConfig = process.env.PLAYWRIGHT_LOCAL
export default defineConfig({
testDir: './browser_tests',
testMatch: ['tests/**/*.spec.ts'],
fullyParallel: true,
forbidOnly: !!process.env.CI,
reporter: 'html',

View File

@@ -634,6 +634,7 @@ export default defineConfig({
'@/utils/formatUtil': '/packages/shared-frontend-utils/src/formatUtil.ts',
'@/utils/networkUtil':
'/packages/shared-frontend-utils/src/networkUtil.ts',
'@e2e': '/browser_tests',
'@': '/src'
}
},
@@ -650,6 +651,7 @@ export default defineConfig({
setupFiles: ['./vitest.setup.ts'],
retry: process.env.CI ? 2 : 0,
include: [
'browser_tests/**/*.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
'src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
'packages/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
'scripts/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'