mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
test: cover in-memory jobs backend routes
This commit is contained in:
377
scripts/browser_tests/InMemoryJobsBackend.test.ts
Normal file
377
scripts/browser_tests/InMemoryJobsBackend.test.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
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 '../../browser_tests/fixtures/helpers/InMemoryJobsBackend';
|
||||
import type { SeededJob } from '../../browser_tests/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,
|
||||
requestBody
|
||||
}: {
|
||||
url: string
|
||||
requestBody?: unknown
|
||||
}): {
|
||||
route: Route
|
||||
getFulfilled: () => FulfillOptions | undefined
|
||||
} {
|
||||
let fulfilled: FulfillOptions | undefined
|
||||
|
||||
const route = {
|
||||
request: () =>
|
||||
({
|
||||
url: () => url,
|
||||
postDataJSON: () => requestBody
|
||||
}) as ReturnType<Route['request']>,
|
||||
fulfill: vi.fn(async (options?: FulfillOptions) => {
|
||||
if (!options) {
|
||||
throw new Error('Expected route to be fulfilled with options')
|
||||
}
|
||||
|
||||
fulfilled = options
|
||||
})
|
||||
} satisfies Pick<Route, 'request' | 'fulfill'>
|
||||
|
||||
return {
|
||||
route: route as unknown as Route,
|
||||
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: 2_900
|
||||
}),
|
||||
createSeededJob({
|
||||
id: 'job-failed',
|
||||
status: 'failed',
|
||||
workflowId: 'wf-1',
|
||||
createTime: 3_000,
|
||||
executionEndTime: 3_400
|
||||
}),
|
||||
createSeededJob({
|
||||
id: 'job-other-workflow',
|
||||
status: 'completed',
|
||||
workflowId: 'wf-2',
|
||||
createTime: 4_000,
|
||||
executionEndTime: 4_050
|
||||
})
|
||||
])
|
||||
|
||||
const listRouteHandler = getRouteHandler(
|
||||
routes,
|
||||
'http://localhost/api/jobs'
|
||||
)
|
||||
|
||||
const firstPage = await invokeJsonRoute<JobsListResponse>(
|
||||
listRouteHandler,
|
||||
{
|
||||
url: 'http://localhost/api/jobs?status=completed&workflow_id=wf-1&sort_by=execution_duration&sort_order=asc&offset=0&limit=1'
|
||||
}
|
||||
)
|
||||
expect(firstPage.body.jobs.map((job) => job.id)).toEqual(['job-fast'])
|
||||
expect(firstPage.body.pagination).toEqual({
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
total: 2,
|
||||
has_more: true
|
||||
})
|
||||
|
||||
const secondPage = 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(secondPage.body.jobs.map((job) => job.id)).toEqual(['job-slow'])
|
||||
expect(secondPage.body.pagination).toEqual({
|
||||
offset: 1,
|
||||
limit: 1,
|
||||
total: 2,
|
||||
has_more: false
|
||||
})
|
||||
})
|
||||
|
||||
it('returns job detail by id and 404 for unknown jobs', async () => {
|
||||
const { page, routes } = createPageStub()
|
||||
const backend = new InMemoryJobsBackend(page as unknown as Page)
|
||||
|
||||
await backend.seed([createSeededJob({ id: 'job-123', createTime: 1_000 })])
|
||||
|
||||
const detailRouteHandler = getRouteHandler(
|
||||
routes,
|
||||
'http://localhost/api/jobs/job-123'
|
||||
)
|
||||
|
||||
const found = await invokeJsonRoute<JobDetailResponse>(detailRouteHandler, {
|
||||
url: 'http://localhost/api/jobs/job-123'
|
||||
})
|
||||
expect(found.status).toBe(200)
|
||||
expect(found.body).toMatchObject({
|
||||
id: 'job-123',
|
||||
status: 'completed',
|
||||
create_time: 1_000
|
||||
})
|
||||
|
||||
const missing = await invokeJsonRoute<{ error: string }>(
|
||||
detailRouteHandler,
|
||||
{
|
||||
url: 'http://localhost/api/jobs/missing-job'
|
||||
}
|
||||
)
|
||||
expect(missing.status).toBe(404)
|
||||
expect(missing.body).toEqual({ error: 'Job not found' })
|
||||
})
|
||||
|
||||
it('deletes only the requested history ids', async () => {
|
||||
const { page, routes } = createPageStub()
|
||||
const backend = new InMemoryJobsBackend(page as unknown as Page)
|
||||
|
||||
await backend.seed([
|
||||
createSeededJob({ id: 'job-a', createTime: 1_000 }),
|
||||
createSeededJob({ id: 'job-b', createTime: 2_000 }),
|
||||
createSeededJob({ id: 'job-c', createTime: 3_000 })
|
||||
])
|
||||
|
||||
const historyRouteHandler = getRouteHandler(
|
||||
routes,
|
||||
'http://localhost/api/history'
|
||||
)
|
||||
await invokeJsonRoute<Record<string, never>>(historyRouteHandler, {
|
||||
url: 'http://localhost/api/history',
|
||||
requestBody: { delete: ['job-a', 'job-c'] }
|
||||
})
|
||||
|
||||
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-b'])
|
||||
})
|
||||
|
||||
it('clears terminal history while preserving pending and in_progress jobs', async () => {
|
||||
const { page, routes } = createPageStub()
|
||||
const backend = new InMemoryJobsBackend(page as unknown as Page)
|
||||
|
||||
await backend.seed([
|
||||
createSeededJob({
|
||||
id: 'job-pending',
|
||||
status: 'pending',
|
||||
createTime: 1_000
|
||||
}),
|
||||
createSeededJob({
|
||||
id: 'job-in-progress',
|
||||
status: 'in_progress',
|
||||
createTime: 2_000
|
||||
}),
|
||||
createSeededJob({
|
||||
id: 'job-completed',
|
||||
status: 'completed',
|
||||
createTime: 3_000
|
||||
}),
|
||||
createSeededJob({
|
||||
id: 'job-failed',
|
||||
status: 'failed',
|
||||
createTime: 4_000
|
||||
})
|
||||
])
|
||||
|
||||
const historyRouteHandler = getRouteHandler(
|
||||
routes,
|
||||
'http://localhost/api/history'
|
||||
)
|
||||
await invokeJsonRoute<Record<string, never>>(historyRouteHandler, {
|
||||
url: 'http://localhost/api/history',
|
||||
requestBody: { clear: true }
|
||||
})
|
||||
|
||||
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-in-progress',
|
||||
'job-pending'
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user