Compare commits

...

6 Commits

Author SHA1 Message Date
Benjamin Lu
82aa046dbd test: remove asset fixture unit tests 2026-04-15 21:05:21 -07:00
Benjamin Lu
b3f039b1e6 test: add asset scenario fixture and browsing coverage 2026-04-15 21:04:56 -07:00
Benjamin Lu
699e6995a9 test: remove fixture unit test scaffolding 2026-04-15 21:04:30 -07:00
Benjamin Lu
ea35401536 test: align in-memory jobs limit handling 2026-04-15 20:58:29 -07:00
Benjamin Lu
f257b7136e test: add in-memory jobs backend fixture 2026-04-15 15:13:55 -07:00
Benjamin Lu
0e62ef0cbc test: extract asset api browser fixture 2026-04-15 15:09:40 -07:00
15 changed files with 1042 additions and 65 deletions

View File

@@ -30,8 +30,6 @@ import {
} from '@e2e/fixtures/components/SidebarTab'
import { Topbar } from '@e2e/fixtures/components/Topbar'
import { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
import type { AssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
import { createAssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
import { AssetsHelper } from '@e2e/fixtures/helpers/AssetsHelper'
import { CanvasHelper } from '@e2e/fixtures/helpers/CanvasHelper'
import { ClipboardHelper } from '@e2e/fixtures/helpers/ClipboardHelper'
@@ -179,7 +177,6 @@ export class ComfyPage {
public readonly queuePanel: QueuePanel
public readonly perf: PerformanceHelper
public readonly assets: AssetsHelper
public readonly assetApi: AssetHelper
public readonly modelLibrary: ModelLibraryHelper
public readonly cloudAuth: CloudAuthHelper
public readonly visibleToasts: Locator
@@ -233,7 +230,6 @@ export class ComfyPage {
this.queuePanel = new QueuePanel(page)
this.perf = new PerformanceHelper(page)
this.assets = new AssetsHelper(page)
this.assetApi = createAssetHelper(page)
this.modelLibrary = new ModelLibraryHelper(page)
this.cloudAuth = new CloudAuthHelper(page)
}
@@ -499,7 +495,6 @@ export const comfyPageFixture = base.extend<{
await use(comfyPage)
await comfyPage.assetApi.clearMocks()
if (needsPerf) await comfyPage.perf.dispose()
},
comfyMouse: async ({ comfyPage }, use) => {

View File

@@ -0,0 +1,16 @@
import { test as base } from '@playwright/test'
import type { AssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
import { createAssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
export const assetApiFixture = base.extend<{
assetApi: AssetHelper
}>({
assetApi: async ({ page }, use) => {
const assetApi = createAssetHelper(page)
await use(assetApi)
await assetApi.clearMocks()
}
})

View File

@@ -0,0 +1,14 @@
import { jobsBackendFixture } from '@e2e/fixtures/jobsBackendFixture'
import { AssetScenarioHelper } from '@e2e/fixtures/helpers/AssetScenarioHelper'
export const assetScenarioFixture = jobsBackendFixture.extend<{
assetScenario: AssetScenarioHelper
}>({
assetScenario: async ({ page, jobsBackend }, use) => {
const assetScenario = new AssetScenarioHelper(page, jobsBackend)
await use(assetScenario)
await assetScenario.clear()
}
})

View File

@@ -0,0 +1,275 @@
import { readFile } from 'node:fs/promises'
import type { Page, Route } from '@playwright/test'
import type { JobDetailResponse, JobEntry } from '@comfyorg/ingest-types'
import { buildMockJobOutputs } from '@e2e/fixtures/helpers/buildMockJobOutputs'
import type {
GeneratedJobFixture,
GeneratedOutputFixture,
ImportedAssetFixture
} from '@e2e/fixtures/helpers/assetScenarioTypes'
import { InMemoryJobsBackend } from '@e2e/fixtures/helpers/InMemoryJobsBackend'
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
import {
buildSeededFileKey,
buildSeededFiles,
defaultFileFor
} from '@e2e/fixtures/helpers/seededAssetFiles'
import type { SeededAssetFile } from '@e2e/fixtures/helpers/seededAssetFiles'
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
const viewRoutePattern = /\/api\/view(?:\?.*)?$/
const DEFAULT_FIXTURE_CREATE_TIME = Date.UTC(2024, 0, 1, 0, 0, 0)
type MockPreviewOutput = NonNullable<JobEntry['preview_output']> & {
filename?: string
subfolder?: string
type?: GeneratedOutputFixture['type']
nodeId: string
mediaType?: string
display_name?: string
}
function normalizeOutputFixture(
output: GeneratedOutputFixture
): GeneratedOutputFixture {
const fallback = defaultFileFor(output.filename)
return {
mediaType: 'images',
subfolder: '',
type: 'output',
...output,
filePath: output.filePath ?? fallback.filePath,
contentType: output.contentType ?? fallback.contentType
}
}
function createOutputFilename(baseFilename: string, index: number): string {
if (index === 0) {
return baseFilename
}
const extensionIndex = baseFilename.lastIndexOf('.')
if (extensionIndex === -1) {
return `${baseFilename}-${index + 1}`
}
return `${baseFilename.slice(0, extensionIndex)}-${index + 1}${baseFilename.slice(extensionIndex)}`
}
function getPreviewOutput(
previewOutput: JobEntry['preview_output'] | undefined
): MockPreviewOutput | undefined {
return previewOutput as MockPreviewOutput | undefined
}
function outputsFromJobEntry(
job: JobEntry
): [GeneratedOutputFixture, ...GeneratedOutputFixture[]] {
const previewOutput = getPreviewOutput(job.preview_output)
const outputCount = Math.max(job.outputs_count ?? 1, 1)
const baseFilename = previewOutput?.filename ?? `output_${job.id}.png`
const mediaType: GeneratedOutputFixture['mediaType'] =
previewOutput?.mediaType === 'video' || previewOutput?.mediaType === 'audio'
? previewOutput.mediaType
: 'images'
const outputs = Array.from({ length: outputCount }, (_, index) => ({
filename: createOutputFilename(baseFilename, index),
displayName: index === 0 ? previewOutput?.display_name : undefined,
mediaType,
subfolder: previewOutput?.subfolder ?? '',
type: previewOutput?.type ?? 'output'
}))
return [outputs[0], ...outputs.slice(1)]
}
function generatedJobFromJobEntry(job: JobEntry): GeneratedJobFixture {
return {
jobId: job.id,
status: job.status,
outputs: outputsFromJobEntry(job),
createTime: job.create_time,
executionStartTime: job.execution_start_time,
executionEndTime: job.execution_end_time,
workflowId: job.workflow_id
}
}
function buildSeededJob(job: GeneratedJobFixture) {
const outputs = job.outputs.map(normalizeOutputFixture)
const preview = outputs[0]
const createTime =
job.createTime ??
(job.createdAt
? new Date(job.createdAt).getTime()
: DEFAULT_FIXTURE_CREATE_TIME)
const executionStartTime = job.executionStartTime ?? createTime
const executionEndTime = job.executionEndTime ?? createTime + 2_000
const listItem: JobEntry = {
id: job.jobId,
status: job.status ?? 'completed',
create_time: createTime,
execution_start_time: executionStartTime,
execution_end_time: executionEndTime,
preview_output: {
filename: preview.filename,
subfolder: preview.subfolder ?? '',
type: preview.type ?? 'output',
nodeId: job.nodeId ?? '5',
mediaType: preview.mediaType ?? 'images',
display_name: preview.displayName
},
outputs_count: outputs.length,
...(job.workflowId ? { workflow_id: job.workflowId } : {})
}
const detail: JobDetailResponse = {
...listItem,
workflow: job.workflow,
outputs: buildMockJobOutputs(job, outputs),
update_time: executionEndTime
}
return { listItem, detail }
}
export class AssetScenarioHelper {
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
null
private viewRouteHandler: ((route: Route) => Promise<void>) | null = null
private generatedJobs: GeneratedJobFixture[] = []
private importedFiles: ImportedAssetFixture[] = []
private seededFiles = new Map<string, SeededAssetFile>()
constructor(
private readonly page: Page,
private readonly jobsBackend = new InMemoryJobsBackend(page)
) {}
async seedGeneratedHistory(jobs: readonly JobEntry[]): Promise<void> {
await this.seed({
generated: jobs.map(generatedJobFromJobEntry),
imported: this.importedFiles
})
}
async seedImportedFiles(files: readonly string[]): Promise<void> {
await this.seed({
generated: this.generatedJobs,
imported: files.map((name) => ({ name }))
})
}
async seedEmptyState(): Promise<void> {
await this.seed({ generated: [], imported: [] })
}
async clear(): Promise<void> {
this.generatedJobs = []
this.importedFiles = []
this.seededFiles.clear()
await this.jobsBackend.clear()
if (this.inputFilesRouteHandler) {
await this.page.unroute(
inputFilesRoutePattern,
this.inputFilesRouteHandler
)
this.inputFilesRouteHandler = null
}
if (this.viewRouteHandler) {
await this.page.unroute(viewRoutePattern, this.viewRouteHandler)
this.viewRouteHandler = null
}
}
private async seed({
generated,
imported
}: {
generated: GeneratedJobFixture[]
imported: ImportedAssetFixture[]
}): Promise<void> {
this.generatedJobs = [...generated]
this.importedFiles = [...imported]
this.seededFiles = buildSeededFiles({
generated: this.generatedJobs,
imported: this.importedFiles
})
await this.jobsBackend.seed(this.generatedJobs.map(buildSeededJob))
await this.ensureInputFilesRoute()
await this.ensureViewRoute()
}
private async ensureInputFilesRoute(): Promise<void> {
if (this.inputFilesRouteHandler) {
return
}
this.inputFilesRouteHandler = async (route: Route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(this.importedFiles.map((asset) => asset.name))
})
}
await this.page.route(inputFilesRoutePattern, this.inputFilesRouteHandler)
}
private async ensureViewRoute(): Promise<void> {
if (this.viewRouteHandler) {
return
}
this.viewRouteHandler = async (route: Route) => {
const url = new URL(route.request().url())
const filename = url.searchParams.get('filename')
const type = url.searchParams.get('type') ?? 'output'
const subfolder = url.searchParams.get('subfolder') ?? ''
if (!filename) {
await route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({ error: 'Missing filename' })
})
return
}
const seededFile =
this.seededFiles.get(
buildSeededFileKey({
filename,
type,
subfolder
})
) ?? defaultFileFor(filename)
if (seededFile.filePath) {
const body = await readFile(seededFile.filePath)
await route.fulfill({
status: 200,
contentType: seededFile.contentType ?? getMimeType(filename),
body
})
return
}
await route.fulfill({
status: 200,
contentType: seededFile.contentType ?? getMimeType(filename),
body: seededFile.textContent ?? ''
})
}
await this.page.route(viewRoutePattern, this.viewRouteHandler)
}
}

View File

@@ -0,0 +1,239 @@
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
}
type JobsListFixtureResponse = Omit<JobsListResponse, 'pagination'> & {
pagination: Omit<JobsListResponse['pagination'], 'limit'> & {
limit: number | null
}
}
function parseLimit(url: URL): { error?: string; limit?: number } {
if (!url.searchParams.has('limit')) {
return {}
}
const value = Number(url.searchParams.get('limit'))
if (!Number.isInteger(value)) {
return { error: 'limit must be an integer' }
}
if (value <= 0) {
return { error: 'limit must be a positive integer' }
}
return { limit: 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 { error: limitError, limit } = parseLimit(url)
if (limitError) {
await route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({ error: limitError })
})
return
}
const total = filteredJobs.length
const visibleJobs =
limit === undefined
? filteredJobs.slice(offset)
: filteredJobs.slice(offset, offset + limit)
const response = {
jobs: visibleJobs,
pagination: {
offset,
limit: limit ?? null,
total,
has_more: offset + visibleJobs.length < total
}
} satisfies JobsListFixtureResponse
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,32 @@
import type { JobDetailResponse, JobEntry } from '@comfyorg/ingest-types'
import type { ResultItemType } from '@/schemas/apiSchema'
export type ImportedAssetFixture = {
name: string
filePath?: string
contentType?: string
}
export type GeneratedOutputFixture = {
filename: string
displayName?: string
filePath?: string
contentType?: string
mediaType?: 'images' | 'video' | 'audio'
subfolder?: string
type?: ResultItemType
}
export type GeneratedJobFixture = {
jobId: string
status?: JobEntry['status']
outputs: [GeneratedOutputFixture, ...GeneratedOutputFixture[]]
createdAt?: string
createTime?: number
executionStartTime?: number
executionEndTime?: number
workflowId?: string
workflow?: JobDetailResponse['workflow']
nodeId?: string
}

View File

@@ -0,0 +1,34 @@
import type { JobDetailResponse } from '@comfyorg/ingest-types'
import type { TaskOutput } from '@/schemas/apiSchema'
import type {
GeneratedJobFixture,
GeneratedOutputFixture
} from '@e2e/fixtures/helpers/assetScenarioTypes'
export function buildMockJobOutputs(
job: GeneratedJobFixture,
outputs: GeneratedOutputFixture[]
): NonNullable<JobDetailResponse['outputs']> {
const nodeId = job.nodeId ?? '5'
const nodeOutputs: Pick<TaskOutput[string], 'audio' | 'images' | 'video'> = {}
for (const output of outputs) {
const mediaType = output.mediaType ?? 'images'
nodeOutputs[mediaType] = [
...(nodeOutputs[mediaType] ?? []),
{
filename: output.filename,
subfolder: output.subfolder ?? '',
type: output.type ?? 'output',
display_name: output.displayName
}
]
}
const taskOutput = { [nodeId]: nodeOutputs } satisfies TaskOutput
return taskOutput
}

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,142 @@
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import type {
GeneratedJobFixture,
GeneratedOutputFixture,
ImportedAssetFixture
} from '@e2e/fixtures/helpers/assetScenarioTypes'
import { getMimeType } from '@e2e/fixtures/helpers/mimeTypeUtil'
const helperDir = path.dirname(fileURLToPath(import.meta.url))
export type SeededAssetFile = {
filePath?: string
contentType?: string
textContent?: string
}
export type SeededFileLocation = {
filename: string
type: string
subfolder: string
}
function getFixturePath(relativePath: string): string {
return path.resolve(helperDir, '../../assets', relativePath)
}
export function buildSeededFileKey({
filename,
type,
subfolder
}: SeededFileLocation): string {
return new URLSearchParams({
filename,
type,
subfolder
}).toString()
}
export function defaultFileFor(filename: string): SeededAssetFile {
const normalized = filename.toLowerCase()
if (normalized.endsWith('.png')) {
return {
filePath: getFixturePath('workflowInMedia/workflow_itxt.png'),
contentType: 'image/png'
}
}
if (normalized.endsWith('.webp')) {
return {
filePath: getFixturePath('example.webp'),
contentType: 'image/webp'
}
}
if (normalized.endsWith('.webm')) {
return {
filePath: getFixturePath('workflowInMedia/workflow.webm'),
contentType: 'video/webm'
}
}
if (normalized.endsWith('.mp4')) {
return {
filePath: getFixturePath('workflowInMedia/workflow.mp4'),
contentType: 'video/mp4'
}
}
if (normalized.endsWith('.glb')) {
return {
filePath: getFixturePath('workflowInMedia/workflow.glb'),
contentType: 'model/gltf-binary'
}
}
if (normalized.endsWith('.json')) {
return {
textContent: JSON.stringify({ mocked: true }, null, 2),
contentType: 'application/json'
}
}
return {
textContent: 'mocked asset content',
contentType: getMimeType(filename)
}
}
function outputLocation(output: GeneratedOutputFixture): SeededFileLocation {
return {
filename: output.filename,
type: output.type ?? 'output',
subfolder: output.subfolder ?? ''
}
}
function importedAssetLocation(
asset: ImportedAssetFixture
): SeededFileLocation {
return {
filename: asset.name,
type: 'input',
subfolder: ''
}
}
export function buildSeededFiles({
generated,
imported
}: {
generated: readonly GeneratedJobFixture[]
imported: readonly ImportedAssetFixture[]
}): Map<string, SeededAssetFile> {
const seededFiles = new Map<string, SeededAssetFile>()
for (const job of generated) {
for (const output of job.outputs) {
const fallback = defaultFileFor(output.filename)
seededFiles.set(buildSeededFileKey(outputLocation(output)), {
filePath: output.filePath ?? fallback.filePath,
contentType: output.contentType ?? fallback.contentType,
textContent: fallback.textContent
})
}
}
for (const asset of imported) {
const fallback = defaultFileFor(asset.name)
seededFiles.set(buildSeededFileKey(importedAssetLocation(asset)), {
filePath: asset.filePath ?? fallback.filePath,
contentType: asset.contentType ?? fallback.contentType,
textContent: fallback.textContent
})
}
return seededFiles
}

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,6 +1,7 @@
import { expect } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { assetApiFixture } from '@e2e/fixtures/assetApiFixture'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import {
createAssetHelper,
withModels,
@@ -17,6 +18,8 @@ import {
STABLE_OUTPUT
} from '@e2e/fixtures/data/assetFixtures'
const test = mergeTests(comfyPageFixture, assetApiFixture)
test.describe('AssetHelper', () => {
test.describe('operators and configuration', () => {
test('creates helper with models via withModels operator', async ({
@@ -66,8 +69,7 @@ test.describe('AssetHelper', () => {
})
test.describe('mock API routes', () => {
test('GET /assets returns all assets', async ({ comfyPage }) => {
const { assetApi } = comfyPage
test('GET /assets returns all assets', async ({ comfyPage, assetApi }) => {
assetApi.configure(
withAsset(STABLE_CHECKPOINT),
withAsset(STABLE_INPUT_IMAGE)
@@ -87,12 +89,12 @@ test.describe('AssetHelper', () => {
expect(data.assets).toHaveLength(2)
expect(data.total).toBe(2)
expect(data.has_more).toBe(false)
await assetApi.clearMocks()
})
test('GET /assets respects pagination params', async ({ comfyPage }) => {
const { assetApi } = comfyPage
test('GET /assets respects pagination params', async ({
comfyPage,
assetApi
}) => {
assetApi.configure(
withModels(5),
withPagination({ total: 10, hasMore: true })
@@ -110,12 +112,12 @@ test.describe('AssetHelper', () => {
expect(data.assets).toHaveLength(2)
expect(data.total).toBe(10)
expect(data.has_more).toBe(true)
await assetApi.clearMocks()
})
test('GET /assets filters by include_tags', async ({ comfyPage }) => {
const { assetApi } = comfyPage
test('GET /assets filters by include_tags', async ({
comfyPage,
assetApi
}) => {
assetApi.configure(
withAsset(STABLE_CHECKPOINT),
withAsset(STABLE_LORA),
@@ -129,14 +131,12 @@ test.describe('AssetHelper', () => {
const data = body as { assets: Array<{ id: string }> }
expect(data.assets).toHaveLength(1)
expect(data.assets[0].id).toBe(STABLE_CHECKPOINT.id)
await assetApi.clearMocks()
})
test('GET /assets/:id returns single asset or 404', async ({
comfyPage
comfyPage,
assetApi
}) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
@@ -151,12 +151,12 @@ test.describe('AssetHelper', () => {
`${comfyPage.url}/api/assets/nonexistent-id`
)
expect(notFound.status).toBe(404)
await assetApi.clearMocks()
})
test('PUT /assets/:id updates asset in store', async ({ comfyPage }) => {
const { assetApi } = comfyPage
test('PUT /assets/:id updates asset in store', async ({
comfyPage,
assetApi
}) => {
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
@@ -175,14 +175,12 @@ test.describe('AssetHelper', () => {
expect(assetApi.getAsset(STABLE_CHECKPOINT.id)?.name).toBe(
'renamed.safetensors'
)
await assetApi.clearMocks()
})
test('DELETE /assets/:id removes asset from store', async ({
comfyPage
comfyPage,
assetApi
}) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT), withAsset(STABLE_LORA))
await assetApi.mock()
@@ -193,11 +191,12 @@ test.describe('AssetHelper', () => {
expect(status).toBe(204)
expect(assetApi.assetCount).toBe(1)
expect(assetApi.getAsset(STABLE_CHECKPOINT.id)).toBeUndefined()
await assetApi.clearMocks()
})
test('POST /assets returns upload response', async ({ comfyPage }) => {
test('POST /assets returns upload response', async ({
comfyPage,
assetApi
}) => {
const customUpload = {
id: 'custom-upload-001',
name: 'custom.safetensors',
@@ -205,7 +204,6 @@ test.describe('AssetHelper', () => {
created_at: '2025-01-01T00:00:00Z',
created_new: true
}
const { assetApi } = comfyPage
assetApi.configure(withUploadResponse(customUpload))
await assetApi.mock()
@@ -217,14 +215,12 @@ test.describe('AssetHelper', () => {
const data = body as { id: string; name: string }
expect(data.id).toBe('custom-upload-001')
expect(data.name).toBe('custom.safetensors')
await assetApi.clearMocks()
})
test('POST /assets/download returns async download response', async ({
comfyPage
comfyPage,
assetApi
}) => {
const { assetApi } = comfyPage
await assetApi.mock()
const { status, body } = await assetApi.fetch(
@@ -235,14 +231,14 @@ test.describe('AssetHelper', () => {
const data = body as { task_id: string; status: string }
expect(data.task_id).toBe('download-task-001')
expect(data.status).toBe('created')
await assetApi.clearMocks()
})
})
test.describe('mutation tracking', () => {
test('tracks POST, PUT, DELETE mutations', async ({ comfyPage }) => {
const { assetApi } = comfyPage
test('tracks POST, PUT, DELETE mutations', async ({
comfyPage,
assetApi
}) => {
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
@@ -265,12 +261,12 @@ test.describe('AssetHelper', () => {
expect(mutations[0].method).toBe('POST')
expect(mutations[1].method).toBe('PUT')
expect(mutations[2].method).toBe('DELETE')
await assetApi.clearMocks()
})
test('GET requests are not tracked as mutations', async ({ comfyPage }) => {
const { assetApi } = comfyPage
test('GET requests are not tracked as mutations', async ({
comfyPage,
assetApi
}) => {
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()
@@ -280,14 +276,14 @@ test.describe('AssetHelper', () => {
)
expect(assetApi.getMutations()).toHaveLength(0)
await assetApi.clearMocks()
})
})
test.describe('mockError', () => {
test('returns error status for all asset routes', async ({ comfyPage }) => {
const { assetApi } = comfyPage
test('returns error status for all asset routes', async ({
comfyPage,
assetApi
}) => {
await assetApi.mockError(503, 'Service Unavailable')
const { status, body } = await assetApi.fetch(
@@ -296,16 +292,14 @@ test.describe('AssetHelper', () => {
expect(status).toBe(503)
const data = body as { error: string }
expect(data.error).toBe('Service Unavailable')
await assetApi.clearMocks()
})
})
test.describe('clearMocks', () => {
test('resets store, mutations, and unroutes handlers', async ({
comfyPage
comfyPage,
assetApi
}) => {
const { assetApi } = comfyPage
assetApi.configure(withAsset(STABLE_CHECKPOINT))
await assetApi.mock()

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

@@ -0,0 +1,165 @@
import { expect, mergeTests } from '@playwright/test'
import type { JobEntry } from '@comfyorg/ingest-types'
import { assetScenarioFixture } from '@e2e/fixtures/assetScenarioFixture'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { createMockJob } from '@e2e/fixtures/helpers/jobFixtures'
const test = mergeTests(comfyPageFixture, assetScenarioFixture)
const GENERATED_JOBS: JobEntry[] = [
createMockJob({
id: 'job-landscape',
create_time: 1_000_000,
execution_start_time: 1_000_000,
execution_end_time: 1_010_000,
preview_output: {
filename: 'landscape.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
outputs_count: 1
}),
createMockJob({
id: 'job-portrait',
create_time: 2_000_000,
execution_start_time: 2_000_000,
execution_end_time: 2_008_000,
preview_output: {
filename: 'portrait.webp',
subfolder: '',
type: 'output',
nodeId: '2',
mediaType: 'images'
},
outputs_count: 1
}),
createMockJob({
id: 'job-gallery',
create_time: 3_000_000,
execution_start_time: 3_000_000,
execution_end_time: 3_015_000,
preview_output: {
filename: 'gallery.png',
subfolder: '',
type: 'output',
nodeId: '3',
mediaType: 'images'
},
outputs_count: 3
})
]
const IMPORTED_FILES = ['reference_photo.png', 'background.jpg', 'notes.txt']
test.describe('Assets sidebar browsing', () => {
test.beforeEach(async ({ comfyPage, assetScenario }) => {
await assetScenario.seedGeneratedHistory(GENERATED_JOBS)
await assetScenario.seedImportedFiles(IMPORTED_FILES)
await comfyPage.setup()
})
test('shows seeded generated and imported assets', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await expect(tab.getAssetCardByName('gallery.png')).toBeVisible()
await tab.switchToImported()
await expect(tab.getAssetCardByName('reference_photo.png')).toBeVisible()
})
test('switches between grid and list views with seeded results', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.openSettingsMenu()
await tab.listViewOption.click()
await expect(tab.listViewItems.first()).toBeVisible()
await tab.openSettingsMenu()
await tab.gridViewOption.click()
await tab.waitForAssets()
await expect(tab.getAssetCardByName('landscape.png')).toBeVisible()
})
test('clears search when switching tabs', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.searchInput.fill('landscape')
await expect(tab.searchInput).toHaveValue('landscape')
await tab.switchToImported()
await expect(tab.searchInput).toHaveValue('')
})
test('opens folder view for multi-output jobs and returns to all assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab
.getAssetCardByName('gallery.png')
.getByRole('button', { name: 'See more outputs' })
.click()
await expect(tab.backToAssetsButton).toBeVisible()
await expect(
comfyPage.page.getByRole('button', { name: 'Copy job ID' })
).toBeVisible()
await expect(tab.getAssetCardByName('gallery-2.png')).toBeVisible()
await comfyPage.page.getByRole('button', { name: 'Copy job ID' }).click()
await expect(
comfyPage.page.locator('.p-toast-message-success')
).toBeVisible()
await tab.backToAssetsButton.click()
await expect(tab.getAssetCardByName('gallery.png')).toBeVisible()
})
test('opens the preview lightbox for generated assets', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.getAssetCardByName('landscape.png').dblclick()
await expect(comfyPage.mediaLightbox.root).toBeVisible()
})
})
test.describe('Assets sidebar empty states', () => {
test.beforeEach(async ({ comfyPage, assetScenario }) => {
await assetScenario.seedEmptyState()
await comfyPage.setup()
})
test('shows empty generated state', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await expect(tab.emptyStateTitle('No generated files found')).toBeVisible()
await expect(tab.emptyStateMessage).toBeVisible()
})
test('shows empty imported state', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.switchToImported()
await expect(tab.emptyStateTitle('No imported files found')).toBeVisible()
await expect(tab.emptyStateMessage).toBeVisible()
})
})

View File

@@ -14,6 +14,7 @@
<button
class="m-0 cursor-pointer border-0 bg-transparent p-0 outline-0"
role="button"
:aria-label="$t('mediaAsset.actions.copyJobId')"
@click="copyJobId"
>
<i class="icon-[lucide--copy] text-sm"></i>

View File

@@ -123,6 +123,7 @@
$t('mediaAsset.actions.seeMoreOutputs')
"
variant="secondary"
:aria-label="$t('mediaAsset.actions.seeMoreOutputs')"
@click.stop="handleOutputCountClick"
>
<i class="icon-[lucide--layers] size-4" />