mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
test: add asset scenario fixture and browsing coverage
This commit is contained in:
14
browser_tests/fixtures/assetScenarioFixture.ts
Normal file
14
browser_tests/fixtures/assetScenarioFixture.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
133
browser_tests/fixtures/helpers/AssetScenarioHelper.test.ts
Normal file
133
browser_tests/fixtures/helpers/AssetScenarioHelper.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { AssetScenarioHelper } from '@e2e/fixtures/helpers/AssetScenarioHelper'
|
||||
import { createMockJob } from '@e2e/fixtures/helpers/jobFixtures'
|
||||
|
||||
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 getRouteHandler(
|
||||
routes: RegisteredRoute[],
|
||||
matcher: (pattern: string | RegExp) => boolean
|
||||
): RouteHandler {
|
||||
const registeredRoute = routes.find(({ pattern }) => matcher(pattern))
|
||||
|
||||
if (!registeredRoute) {
|
||||
throw new Error('Expected route handler to be registered')
|
||||
}
|
||||
|
||||
return registeredRoute.handler
|
||||
}
|
||||
|
||||
function createRouteInvocation(url: string): {
|
||||
route: Route
|
||||
getFulfilled: () => FulfillOptions | undefined
|
||||
} {
|
||||
let fulfilled: FulfillOptions | undefined
|
||||
|
||||
const route = {
|
||||
request: () =>
|
||||
({
|
||||
url: () => url
|
||||
}) 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 invokeViewRoute(
|
||||
handler: RouteHandler,
|
||||
url: string
|
||||
): Promise<string> {
|
||||
const invocation = createRouteInvocation(url)
|
||||
|
||||
await handler(invocation.route)
|
||||
|
||||
const fulfilled = invocation.getFulfilled()
|
||||
expect(fulfilled).toBeDefined()
|
||||
|
||||
return bodyToText(fulfilled?.body)
|
||||
}
|
||||
|
||||
describe('AssetScenarioHelper', () => {
|
||||
it('serves generated outputs and imported files through the view route', async () => {
|
||||
const { page, routes } = createPageStub()
|
||||
const helper = new AssetScenarioHelper(page as unknown as Page)
|
||||
|
||||
await helper.seedGeneratedHistory([
|
||||
createMockJob({
|
||||
id: 'job-generated',
|
||||
preview_output: {
|
||||
filename: 'generated.json',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
})
|
||||
])
|
||||
await helper.seedImportedFiles(['imported.txt'])
|
||||
|
||||
const viewRouteHandler = getRouteHandler(
|
||||
routes,
|
||||
(pattern) =>
|
||||
pattern instanceof RegExp && /api\\\/view/.test(pattern.source)
|
||||
)
|
||||
|
||||
await expect(
|
||||
invokeViewRoute(
|
||||
viewRouteHandler,
|
||||
'http://localhost/api/view?filename=generated.json&type=output&subfolder='
|
||||
)
|
||||
).resolves.toBe(JSON.stringify({ mocked: true }, null, 2))
|
||||
|
||||
await expect(
|
||||
invokeViewRoute(
|
||||
viewRouteHandler,
|
||||
'http://localhost/api/view?filename=imported.txt&type=input&subfolder='
|
||||
)
|
||||
).resolves.toBe('mocked asset content')
|
||||
})
|
||||
})
|
||||
275
browser_tests/fixtures/helpers/AssetScenarioHelper.ts
Normal file
275
browser_tests/fixtures/helpers/AssetScenarioHelper.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
32
browser_tests/fixtures/helpers/assetScenarioTypes.ts
Normal file
32
browser_tests/fixtures/helpers/assetScenarioTypes.ts
Normal 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
|
||||
}
|
||||
100
browser_tests/fixtures/helpers/buildMockJobOutputs.test.ts
Normal file
100
browser_tests/fixtures/helpers/buildMockJobOutputs.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { buildMockJobOutputs } from '@e2e/fixtures/helpers/buildMockJobOutputs'
|
||||
import type {
|
||||
GeneratedJobFixture,
|
||||
GeneratedOutputFixture
|
||||
} from '@e2e/fixtures/helpers/assetScenarioTypes'
|
||||
|
||||
describe('buildMockJobOutputs', () => {
|
||||
it('defaults nodeId, mediaType, subfolder, and type for a single output', () => {
|
||||
const job = {
|
||||
jobId: 'job-1',
|
||||
outputs: [{ filename: 'single-output.png' }]
|
||||
} satisfies GeneratedJobFixture
|
||||
const outputs = [
|
||||
{
|
||||
filename: 'single-output.png',
|
||||
displayName: 'Single output'
|
||||
}
|
||||
] satisfies GeneratedOutputFixture[]
|
||||
|
||||
expect(buildMockJobOutputs(job, outputs)).toEqual({
|
||||
'5': {
|
||||
images: [
|
||||
{
|
||||
filename: 'single-output.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
display_name: 'Single output'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('buckets outputs by media type and preserves order within each bucket', () => {
|
||||
const job = {
|
||||
jobId: 'job-2',
|
||||
nodeId: '12',
|
||||
outputs: [{ filename: 'preview.png' }]
|
||||
} satisfies GeneratedJobFixture
|
||||
const outputs = [
|
||||
{
|
||||
filename: 'image-a.png',
|
||||
mediaType: 'images',
|
||||
subfolder: 'gallery',
|
||||
type: 'temp'
|
||||
},
|
||||
{
|
||||
filename: 'clip.mp4',
|
||||
mediaType: 'video',
|
||||
displayName: 'Clip'
|
||||
},
|
||||
{
|
||||
filename: 'image-b.png',
|
||||
mediaType: 'images',
|
||||
displayName: 'Second image'
|
||||
},
|
||||
{
|
||||
filename: 'sound.wav',
|
||||
mediaType: 'audio'
|
||||
}
|
||||
] satisfies GeneratedOutputFixture[]
|
||||
|
||||
expect(buildMockJobOutputs(job, outputs)).toEqual({
|
||||
'12': {
|
||||
images: [
|
||||
{
|
||||
filename: 'image-a.png',
|
||||
subfolder: 'gallery',
|
||||
type: 'temp',
|
||||
display_name: undefined
|
||||
},
|
||||
{
|
||||
filename: 'image-b.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
display_name: 'Second image'
|
||||
}
|
||||
],
|
||||
video: [
|
||||
{
|
||||
filename: 'clip.mp4',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
display_name: 'Clip'
|
||||
}
|
||||
],
|
||||
audio: [
|
||||
{
|
||||
filename: 'sound.wav',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
display_name: undefined
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
34
browser_tests/fixtures/helpers/buildMockJobOutputs.ts
Normal file
34
browser_tests/fixtures/helpers/buildMockJobOutputs.ts
Normal 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
|
||||
}
|
||||
85
browser_tests/fixtures/helpers/seededAssetFiles.test.ts
Normal file
85
browser_tests/fixtures/helpers/seededAssetFiles.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildSeededFileKey,
|
||||
buildSeededFiles
|
||||
} from '@e2e/fixtures/helpers/seededAssetFiles'
|
||||
|
||||
describe('buildSeededFiles', () => {
|
||||
it('keys seeded files by filename, type, and subfolder', () => {
|
||||
const seededFiles = buildSeededFiles({
|
||||
generated: [
|
||||
{
|
||||
jobId: 'job-root',
|
||||
outputs: [
|
||||
{
|
||||
filename: 'shared-name.txt',
|
||||
type: 'output',
|
||||
subfolder: '',
|
||||
filePath: '/tmp/root-output.txt',
|
||||
contentType: 'text/plain'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
jobId: 'job-nested',
|
||||
outputs: [
|
||||
{
|
||||
filename: 'shared-name.txt',
|
||||
type: 'output',
|
||||
subfolder: 'nested/folder',
|
||||
filePath: '/tmp/nested-output.txt',
|
||||
contentType: 'text/plain'
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
imported: [
|
||||
{
|
||||
name: 'shared-name.txt',
|
||||
filePath: '/tmp/input-asset.txt',
|
||||
contentType: 'text/plain'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect(
|
||||
seededFiles.get(
|
||||
buildSeededFileKey({
|
||||
filename: 'shared-name.txt',
|
||||
type: 'output',
|
||||
subfolder: ''
|
||||
})
|
||||
)
|
||||
).toMatchObject({
|
||||
filePath: '/tmp/root-output.txt',
|
||||
contentType: 'text/plain'
|
||||
})
|
||||
|
||||
expect(
|
||||
seededFiles.get(
|
||||
buildSeededFileKey({
|
||||
filename: 'shared-name.txt',
|
||||
type: 'output',
|
||||
subfolder: 'nested/folder'
|
||||
})
|
||||
)
|
||||
).toMatchObject({
|
||||
filePath: '/tmp/nested-output.txt',
|
||||
contentType: 'text/plain'
|
||||
})
|
||||
|
||||
expect(
|
||||
seededFiles.get(
|
||||
buildSeededFileKey({
|
||||
filename: 'shared-name.txt',
|
||||
type: 'input',
|
||||
subfolder: ''
|
||||
})
|
||||
)
|
||||
).toMatchObject({
|
||||
filePath: '/tmp/input-asset.txt',
|
||||
contentType: 'text/plain'
|
||||
})
|
||||
})
|
||||
})
|
||||
142
browser_tests/fixtures/helpers/seededAssetFiles.ts
Normal file
142
browser_tests/fixtures/helpers/seededAssetFiles.ts
Normal 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
|
||||
}
|
||||
165
browser_tests/tests/sidebar/assetsBrowsing.spec.ts
Normal file
165
browser_tests/tests/sidebar/assetsBrowsing.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user