mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
test(infra): AssetHelper with builder pattern + deterministic fixtures (#10545)
## What Adds `AssetHelper` — a builder-pattern helper for mocking asset-related API endpoints in Playwright E2E tests, plus deterministic fixture data. ## Why 12+ asset-related API endpoints need mocking for asset browser tests (PNL-02), cloud dialog testing (DLG-08), and other asset-dependent E2E scenarios. Random mock data from existing `createMockAssets()` is unsuitable for deterministic E2E assertions. ## What's included ### `AssetHelper.ts` (307 LOC) - Fluent builder API: `assetHelper.withModels(3).withImages(5).mock()` - Stateful mock store (Map) for upload→verify→delete flows - Endpoint coverage: GET/POST/PUT/DELETE `/assets`, download progress - `mockError()` for error state testing - `clearMocks()` cleanup matching QueueHelper/FeatureFlagHelper pattern ### `assetFixtures.ts` (304 LOC) - 11 stable named constants (checkpoints, loras, VAE, embedding, inputs, outputs) - Factory functions: `generateModels()`, `generateInputFiles()`, `generateOutputAssets()` - Fixed IDs/dates/sizes — no randomness, safe for screenshot comparisons ### ComfyPage integration - Available as `comfyPage.assets` in all tests ## Testing - TypeScript compiles clean - Follows existing QueueHelper/FeatureFlagHelper conventions ## Unblocks - PNL-02: Asset browser tests (@Jaewon Yoon) - DLG-08: Assets modal / cloud dialog testing Part of: Test Coverage Q2 Overhaul ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10545-test-infra-AssetHelper-with-builder-pattern-deterministic-fixtures-32f6d73d365081d3985ef079ff3dbede) by [Unito](https://www.unito.io) --------- Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
@@ -29,6 +29,8 @@ 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'
|
||||
@@ -177,6 +179,7 @@ export class ComfyPage {
|
||||
public readonly queuePanel: QueuePanel
|
||||
public readonly perf: PerformanceHelper
|
||||
public readonly assets: AssetsHelper
|
||||
public readonly assetApi: AssetHelper
|
||||
public readonly modelLibrary: ModelLibraryHelper
|
||||
|
||||
/** Worker index to test user ID */
|
||||
@@ -227,6 +230,7 @@ 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)
|
||||
}
|
||||
|
||||
@@ -448,6 +452,7 @@ export const comfyPageFixture = base.extend<{
|
||||
|
||||
await use(comfyPage)
|
||||
|
||||
await comfyPage.assetApi.clearMocks()
|
||||
if (needsPerf) await comfyPage.perf.dispose()
|
||||
},
|
||||
comfyMouse: async ({ comfyPage }, use) => {
|
||||
|
||||
306
browser_tests/fixtures/data/assetFixtures.ts
Normal file
306
browser_tests/fixtures/data/assetFixtures.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
function createModelAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
return {
|
||||
id: 'test-model-001',
|
||||
name: 'model.safetensors',
|
||||
asset_hash:
|
||||
'blake3:0000000000000000000000000000000000000000000000000000000000000000',
|
||||
size: 2_147_483_648,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'checkpoints'],
|
||||
created_at: '2025-01-15T10:00:00Z',
|
||||
updated_at: '2025-01-15T10:00:00Z',
|
||||
last_access_time: '2025-01-15T10:00:00Z',
|
||||
user_metadata: { base_model: 'sd15' },
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function createInputAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
return {
|
||||
id: 'test-input-001',
|
||||
name: 'input.png',
|
||||
asset_hash:
|
||||
'blake3:1111111111111111111111111111111111111111111111111111111111111111',
|
||||
size: 2_048_576,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: '2025-03-01T09:00:00Z',
|
||||
updated_at: '2025-03-01T09:00:00Z',
|
||||
last_access_time: '2025-03-01T09:00:00Z',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function createOutputAsset(overrides: Partial<Asset> = {}): Asset {
|
||||
return {
|
||||
id: 'test-output-001',
|
||||
name: 'output_00001.png',
|
||||
asset_hash:
|
||||
'blake3:2222222222222222222222222222222222222222222222222222222222222222',
|
||||
size: 4_194_304,
|
||||
mime_type: 'image/png',
|
||||
tags: ['output'],
|
||||
created_at: '2025-03-10T12:00:00Z',
|
||||
updated_at: '2025-03-10T12:00:00Z',
|
||||
last_access_time: '2025-03-10T12:00:00Z',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
export const STABLE_CHECKPOINT: Asset = createModelAsset({
|
||||
id: 'test-checkpoint-001',
|
||||
name: 'sd_xl_base_1.0.safetensors',
|
||||
size: 6_938_078_208,
|
||||
tags: ['models', 'checkpoints'],
|
||||
user_metadata: {
|
||||
base_model: 'sdxl',
|
||||
description: 'Stable Diffusion XL Base 1.0'
|
||||
},
|
||||
created_at: '2025-01-15T10:30:00Z',
|
||||
updated_at: '2025-01-15T10:30:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_CHECKPOINT_2: Asset = createModelAsset({
|
||||
id: 'test-checkpoint-002',
|
||||
name: 'v1-5-pruned-emaonly.safetensors',
|
||||
size: 4_265_146_304,
|
||||
tags: ['models', 'checkpoints'],
|
||||
user_metadata: {
|
||||
base_model: 'sd15',
|
||||
description: 'Stable Diffusion 1.5 Pruned EMA-Only'
|
||||
},
|
||||
created_at: '2025-01-20T08:00:00Z',
|
||||
updated_at: '2025-01-20T08:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_LORA: Asset = createModelAsset({
|
||||
id: 'test-lora-001',
|
||||
name: 'detail_enhancer_v1.2.safetensors',
|
||||
size: 184_549_376,
|
||||
tags: ['models', 'loras'],
|
||||
user_metadata: {
|
||||
base_model: 'sdxl',
|
||||
description: 'Detail Enhancement LoRA'
|
||||
},
|
||||
created_at: '2025-02-20T14:00:00Z',
|
||||
updated_at: '2025-02-20T14:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_LORA_2: Asset = createModelAsset({
|
||||
id: 'test-lora-002',
|
||||
name: 'add_detail_v2.safetensors',
|
||||
size: 226_492_416,
|
||||
tags: ['models', 'loras'],
|
||||
user_metadata: {
|
||||
base_model: 'sd15',
|
||||
description: 'Add Detail LoRA v2'
|
||||
},
|
||||
created_at: '2025-02-25T11:00:00Z',
|
||||
updated_at: '2025-02-25T11:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_VAE: Asset = createModelAsset({
|
||||
id: 'test-vae-001',
|
||||
name: 'sdxl_vae.safetensors',
|
||||
size: 334_641_152,
|
||||
tags: ['models', 'vae'],
|
||||
user_metadata: {
|
||||
base_model: 'sdxl',
|
||||
description: 'SDXL VAE'
|
||||
},
|
||||
created_at: '2025-01-18T16:00:00Z',
|
||||
updated_at: '2025-01-18T16:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_EMBEDDING: Asset = createModelAsset({
|
||||
id: 'test-embedding-001',
|
||||
name: 'bad_prompt_v2.pt',
|
||||
size: 32_768,
|
||||
mime_type: 'application/x-pytorch',
|
||||
tags: ['models', 'embeddings'],
|
||||
user_metadata: {
|
||||
base_model: 'sd15',
|
||||
description: 'Negative Embedding: Bad Prompt v2'
|
||||
},
|
||||
created_at: '2025-02-01T09:30:00Z',
|
||||
updated_at: '2025-02-01T09:30:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_INPUT_IMAGE: Asset = createInputAsset({
|
||||
id: 'test-input-001',
|
||||
name: 'reference_photo.png',
|
||||
size: 2_048_576,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: '2025-03-01T09:00:00Z',
|
||||
updated_at: '2025-03-01T09:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_INPUT_IMAGE_2: Asset = createInputAsset({
|
||||
id: 'test-input-002',
|
||||
name: 'mask_layer.png',
|
||||
size: 1_048_576,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: '2025-03-05T10:00:00Z',
|
||||
updated_at: '2025-03-05T10:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_INPUT_VIDEO: Asset = createInputAsset({
|
||||
id: 'test-input-003',
|
||||
name: 'clip_720p.mp4',
|
||||
size: 15_728_640,
|
||||
mime_type: 'video/mp4',
|
||||
tags: ['input'],
|
||||
created_at: '2025-03-08T14:30:00Z',
|
||||
updated_at: '2025-03-08T14:30:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_OUTPUT: Asset = createOutputAsset({
|
||||
id: 'test-output-001',
|
||||
name: 'ComfyUI_00001_.png',
|
||||
size: 4_194_304,
|
||||
mime_type: 'image/png',
|
||||
tags: ['output'],
|
||||
created_at: '2025-03-10T12:00:00Z',
|
||||
updated_at: '2025-03-10T12:00:00Z'
|
||||
})
|
||||
|
||||
export const STABLE_OUTPUT_2: Asset = createOutputAsset({
|
||||
id: 'test-output-002',
|
||||
name: 'ComfyUI_00002_.png',
|
||||
size: 3_670_016,
|
||||
mime_type: 'image/png',
|
||||
tags: ['output'],
|
||||
created_at: '2025-03-10T12:05:00Z',
|
||||
updated_at: '2025-03-10T12:05:00Z'
|
||||
})
|
||||
export const ALL_MODEL_FIXTURES: Asset[] = [
|
||||
STABLE_CHECKPOINT,
|
||||
STABLE_CHECKPOINT_2,
|
||||
STABLE_LORA,
|
||||
STABLE_LORA_2,
|
||||
STABLE_VAE,
|
||||
STABLE_EMBEDDING
|
||||
]
|
||||
|
||||
export const ALL_INPUT_FIXTURES: Asset[] = [
|
||||
STABLE_INPUT_IMAGE,
|
||||
STABLE_INPUT_IMAGE_2,
|
||||
STABLE_INPUT_VIDEO
|
||||
]
|
||||
|
||||
export const ALL_OUTPUT_FIXTURES: Asset[] = [STABLE_OUTPUT, STABLE_OUTPUT_2]
|
||||
const CHECKPOINT_NAMES = [
|
||||
'sd_xl_base_1.0.safetensors',
|
||||
'v1-5-pruned-emaonly.safetensors',
|
||||
'sd_xl_refiner_1.0.safetensors',
|
||||
'dreamshaper_8.safetensors',
|
||||
'realisticVision_v51.safetensors',
|
||||
'deliberate_v3.safetensors',
|
||||
'anything_v5.safetensors',
|
||||
'counterfeit_v3.safetensors',
|
||||
'revAnimated_v122.safetensors',
|
||||
'majicmixRealistic_v7.safetensors'
|
||||
]
|
||||
|
||||
const LORA_NAMES = [
|
||||
'detail_enhancer_v1.2.safetensors',
|
||||
'add_detail_v2.safetensors',
|
||||
'epi_noiseoffset_v2.safetensors',
|
||||
'lcm_lora_sdxl.safetensors',
|
||||
'film_grain_v1.safetensors',
|
||||
'sharpness_fix_v2.safetensors',
|
||||
'better_hands_v1.safetensors',
|
||||
'smooth_skin_v3.safetensors',
|
||||
'color_pop_v1.safetensors',
|
||||
'bokeh_effect_v2.safetensors'
|
||||
]
|
||||
|
||||
const INPUT_NAMES = [
|
||||
'reference_photo.png',
|
||||
'mask_layer.png',
|
||||
'clip_720p.mp4',
|
||||
'depth_map.png',
|
||||
'control_pose.png',
|
||||
'sketch_input.jpg',
|
||||
'inpainting_mask.png',
|
||||
'style_reference.png',
|
||||
'batch_001.png',
|
||||
'batch_002.png'
|
||||
]
|
||||
|
||||
const EXTENSION_MIME_MAP: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
mp4: 'video/mp4',
|
||||
webm: 'video/webm',
|
||||
mov: 'video/quicktime',
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
ogg: 'audio/ogg',
|
||||
flac: 'audio/flac'
|
||||
}
|
||||
|
||||
function getMimeType(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
|
||||
return EXTENSION_MIME_MAP[ext] ?? 'application/octet-stream'
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate N deterministic model assets of a given category.
|
||||
* Uses sequential IDs and fixed names for screenshot stability.
|
||||
*/
|
||||
export function generateModels(
|
||||
count: number,
|
||||
category: 'checkpoints' | 'loras' | 'vae' | 'embeddings' = 'checkpoints'
|
||||
): Asset[] {
|
||||
const names = category === 'loras' ? LORA_NAMES : CHECKPOINT_NAMES
|
||||
return Array.from({ length: Math.min(count, names.length) }, (_, i) =>
|
||||
createModelAsset({
|
||||
id: `gen-${category}-${String(i + 1).padStart(3, '0')}`,
|
||||
name: names[i % names.length],
|
||||
size: 2_000_000_000 + i * 500_000_000,
|
||||
tags: ['models', category],
|
||||
user_metadata: { base_model: i % 2 === 0 ? 'sdxl' : 'sd15' },
|
||||
created_at: `2025-01-${String(15 + i).padStart(2, '0')}T10:00:00Z`,
|
||||
updated_at: `2025-01-${String(15 + i).padStart(2, '0')}T10:00:00Z`
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate N deterministic input file assets.
|
||||
*/
|
||||
export function generateInputFiles(count: number): Asset[] {
|
||||
return Array.from({ length: Math.min(count, INPUT_NAMES.length) }, (_, i) => {
|
||||
const name = INPUT_NAMES[i % INPUT_NAMES.length]
|
||||
return createInputAsset({
|
||||
id: `gen-input-${String(i + 1).padStart(3, '0')}`,
|
||||
name,
|
||||
size: 1_000_000 + i * 500_000,
|
||||
mime_type: getMimeType(name),
|
||||
tags: ['input'],
|
||||
created_at: `2025-03-${String(1 + i).padStart(2, '0')}T09:00:00Z`,
|
||||
updated_at: `2025-03-${String(1 + i).padStart(2, '0')}T09:00:00Z`
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate N deterministic output assets.
|
||||
*/
|
||||
export function generateOutputAssets(count: number): Asset[] {
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
createOutputAsset({
|
||||
id: `gen-output-${String(i + 1).padStart(3, '0')}`,
|
||||
name: `ComfyUI_${String(i + 1).padStart(5, '0')}_.png`,
|
||||
size: 3_000_000 + i * 200_000,
|
||||
mime_type: 'image/png',
|
||||
tags: ['output'],
|
||||
created_at: `2025-03-10T${String((12 + Math.floor(i / 60)) % 24).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}:00Z`,
|
||||
updated_at: `2025-03-10T${String((12 + Math.floor(i / 60)) % 24).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}:00Z`
|
||||
})
|
||||
)
|
||||
}
|
||||
309
browser_tests/fixtures/helpers/AssetHelper.ts
Normal file
309
browser_tests/fixtures/helpers/AssetHelper.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
import {
|
||||
generateModels,
|
||||
generateInputFiles,
|
||||
generateOutputAssets
|
||||
} from '../data/assetFixtures'
|
||||
|
||||
export interface MutationRecord {
|
||||
endpoint: string
|
||||
method: string
|
||||
url: string
|
||||
body: unknown
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
interface PaginationOptions {
|
||||
total: number
|
||||
hasMore: boolean
|
||||
}
|
||||
export interface AssetConfig {
|
||||
readonly assets: ReadonlyMap<string, Asset>
|
||||
readonly pagination: PaginationOptions | null
|
||||
readonly uploadResponse: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
function emptyConfig(): AssetConfig {
|
||||
return { assets: new Map(), pagination: null, uploadResponse: null }
|
||||
}
|
||||
|
||||
export type AssetOperator = (config: AssetConfig) => AssetConfig
|
||||
|
||||
function addAssets(config: AssetConfig, newAssets: Asset[]): AssetConfig {
|
||||
const merged = new Map(config.assets)
|
||||
for (const asset of newAssets) {
|
||||
merged.set(asset.id, asset)
|
||||
}
|
||||
return { ...config, assets: merged }
|
||||
}
|
||||
export function withModels(
|
||||
countOrAssets: number | Asset[],
|
||||
category: 'checkpoints' | 'loras' | 'vae' | 'embeddings' = 'checkpoints'
|
||||
): AssetOperator {
|
||||
return (config) => {
|
||||
const assets =
|
||||
typeof countOrAssets === 'number'
|
||||
? generateModels(countOrAssets, category)
|
||||
: countOrAssets
|
||||
return addAssets(config, assets)
|
||||
}
|
||||
}
|
||||
|
||||
export function withInputFiles(countOrAssets: number | Asset[]): AssetOperator {
|
||||
return (config) => {
|
||||
const assets =
|
||||
typeof countOrAssets === 'number'
|
||||
? generateInputFiles(countOrAssets)
|
||||
: countOrAssets
|
||||
return addAssets(config, assets)
|
||||
}
|
||||
}
|
||||
|
||||
export function withOutputAssets(
|
||||
countOrAssets: number | Asset[]
|
||||
): AssetOperator {
|
||||
return (config) => {
|
||||
const assets =
|
||||
typeof countOrAssets === 'number'
|
||||
? generateOutputAssets(countOrAssets)
|
||||
: countOrAssets
|
||||
return addAssets(config, assets)
|
||||
}
|
||||
}
|
||||
|
||||
export function withAsset(asset: Asset): AssetOperator {
|
||||
return (config) => addAssets(config, [asset])
|
||||
}
|
||||
|
||||
export function withPagination(options: PaginationOptions): AssetOperator {
|
||||
return (config) => ({ ...config, pagination: options })
|
||||
}
|
||||
|
||||
export function withUploadResponse(
|
||||
response: Record<string, unknown>
|
||||
): AssetOperator {
|
||||
return (config) => ({ ...config, uploadResponse: response })
|
||||
}
|
||||
export class AssetHelper {
|
||||
private store: Map<string, Asset>
|
||||
private paginationOptions: PaginationOptions | null
|
||||
private routeHandlers: Array<{
|
||||
pattern: string
|
||||
handler: (route: Route) => Promise<void>
|
||||
}> = []
|
||||
private mutations: MutationRecord[] = []
|
||||
private uploadResponse: Record<string, unknown> | null
|
||||
|
||||
constructor(
|
||||
private readonly page: Page,
|
||||
config: AssetConfig = emptyConfig()
|
||||
) {
|
||||
this.store = new Map(config.assets)
|
||||
this.paginationOptions = config.pagination
|
||||
this.uploadResponse = config.uploadResponse
|
||||
}
|
||||
async mock(): Promise<void> {
|
||||
const handler = async (route: Route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const method = route.request().method()
|
||||
const path = url.pathname
|
||||
const isMutation = ['POST', 'PUT', 'DELETE'].includes(method)
|
||||
let body: Record<string, unknown> | null = null
|
||||
if (isMutation) {
|
||||
try {
|
||||
body = route.request().postDataJSON()
|
||||
} catch {
|
||||
body = null
|
||||
}
|
||||
}
|
||||
|
||||
if (isMutation) {
|
||||
this.mutations.push({
|
||||
endpoint: path,
|
||||
method,
|
||||
url: route.request().url(),
|
||||
body,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
if (method === 'GET' && /\/assets\/?$/.test(path))
|
||||
return this.handleListAssets(route, url)
|
||||
if (method === 'GET' && /\/assets\/[^/]+$/.test(path))
|
||||
return this.handleGetAsset(route, path)
|
||||
if (method === 'PUT' && /\/assets\/[^/]+$/.test(path))
|
||||
return this.handleUpdateAsset(route, path, body)
|
||||
if (method === 'DELETE' && /\/assets\/[^/]+$/.test(path))
|
||||
return this.handleDeleteAsset(route, path)
|
||||
if (method === 'POST' && /\/assets\/?$/.test(path))
|
||||
return this.handleUploadAsset(route)
|
||||
if (method === 'POST' && path.endsWith('/assets/download'))
|
||||
return this.handleDownloadAsset(route)
|
||||
|
||||
return route.fallback()
|
||||
}
|
||||
|
||||
const pattern = '**/assets**'
|
||||
this.routeHandlers.push({ pattern, handler })
|
||||
await this.page.route(pattern, handler)
|
||||
}
|
||||
|
||||
async mockError(
|
||||
statusCode: number,
|
||||
error: string = 'Internal Server Error'
|
||||
): Promise<void> {
|
||||
const handler = async (route: Route) => {
|
||||
return route.fulfill({
|
||||
status: statusCode,
|
||||
json: { error }
|
||||
})
|
||||
}
|
||||
|
||||
const pattern = '**/assets**'
|
||||
this.routeHandlers.push({ pattern, handler })
|
||||
await this.page.route(pattern, handler)
|
||||
}
|
||||
async fetch(
|
||||
path: string,
|
||||
init?: RequestInit
|
||||
): Promise<{ status: number; body: unknown }> {
|
||||
return this.page.evaluate(
|
||||
async ([fetchUrl, fetchInit]) => {
|
||||
const res = await fetch(fetchUrl, fetchInit)
|
||||
const text = await res.text()
|
||||
let body: unknown
|
||||
try {
|
||||
body = JSON.parse(text)
|
||||
} catch {
|
||||
body = text
|
||||
}
|
||||
return { status: res.status, body }
|
||||
},
|
||||
[path, init] as const
|
||||
)
|
||||
}
|
||||
|
||||
configure(...operators: AssetOperator[]): void {
|
||||
const config = operators.reduce<AssetConfig>(
|
||||
(cfg, op) => op(cfg),
|
||||
emptyConfig()
|
||||
)
|
||||
this.store = new Map(config.assets)
|
||||
this.paginationOptions = config.pagination
|
||||
this.uploadResponse = config.uploadResponse
|
||||
}
|
||||
|
||||
getMutations(): MutationRecord[] {
|
||||
return [...this.mutations]
|
||||
}
|
||||
|
||||
getAssets(): Asset[] {
|
||||
return [...this.store.values()]
|
||||
}
|
||||
|
||||
getAsset(id: string): Asset | undefined {
|
||||
return this.store.get(id)
|
||||
}
|
||||
|
||||
get assetCount(): number {
|
||||
return this.store.size
|
||||
}
|
||||
private handleListAssets(route: Route, url: URL) {
|
||||
const includeTags = url.searchParams.get('include_tags')?.split(',') ?? []
|
||||
const limit = parseInt(url.searchParams.get('limit') ?? '0', 10)
|
||||
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10)
|
||||
|
||||
let filtered = this.getFilteredAssets(includeTags)
|
||||
if (limit > 0) {
|
||||
filtered = filtered.slice(offset, offset + limit)
|
||||
}
|
||||
|
||||
const response: ListAssetsResponse = {
|
||||
assets: filtered,
|
||||
total: this.paginationOptions?.total ?? this.store.size,
|
||||
has_more: this.paginationOptions?.hasMore ?? false
|
||||
}
|
||||
return route.fulfill({ json: response })
|
||||
}
|
||||
|
||||
private handleGetAsset(route: Route, path: string) {
|
||||
const id = path.split('/').pop()!
|
||||
const asset = this.store.get(id)
|
||||
if (asset) return route.fulfill({ json: asset })
|
||||
return route.fulfill({ status: 404, json: { error: 'Not found' } })
|
||||
}
|
||||
|
||||
private handleUpdateAsset(route: Route, path: string, body: unknown) {
|
||||
const id = path.split('/').pop()!
|
||||
const asset = this.store.get(id)
|
||||
if (asset) {
|
||||
const updated = {
|
||||
...asset,
|
||||
...(body as Record<string, unknown>),
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
this.store.set(id, updated)
|
||||
return route.fulfill({ json: updated })
|
||||
}
|
||||
return route.fulfill({ status: 404, json: { error: 'Not found' } })
|
||||
}
|
||||
|
||||
private handleDeleteAsset(route: Route, path: string) {
|
||||
const id = path.split('/').pop()!
|
||||
this.store.delete(id)
|
||||
return route.fulfill({ status: 204, body: '' })
|
||||
}
|
||||
|
||||
private handleUploadAsset(route: Route) {
|
||||
const response = this.uploadResponse ?? {
|
||||
id: `upload-${Date.now()}`,
|
||||
name: 'uploaded_file.safetensors',
|
||||
tags: ['models', 'checkpoints'],
|
||||
created_at: new Date().toISOString(),
|
||||
created_new: true
|
||||
}
|
||||
return route.fulfill({ status: 201, json: response })
|
||||
}
|
||||
|
||||
private handleDownloadAsset(route: Route) {
|
||||
return route.fulfill({
|
||||
status: 202,
|
||||
json: {
|
||||
task_id: 'download-task-001',
|
||||
status: 'created',
|
||||
message: 'Download started'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
for (const { pattern, handler } of this.routeHandlers) {
|
||||
await this.page.unroute(pattern, handler)
|
||||
}
|
||||
this.routeHandlers = []
|
||||
this.store.clear()
|
||||
this.mutations = []
|
||||
this.paginationOptions = null
|
||||
this.uploadResponse = null
|
||||
}
|
||||
private getFilteredAssets(tags: string[]): Asset[] {
|
||||
const assets = [...this.store.values()]
|
||||
if (tags.length === 0) return assets
|
||||
|
||||
return assets.filter((asset) =>
|
||||
tags.every((tag) => (asset.tags ?? []).includes(tag))
|
||||
)
|
||||
}
|
||||
}
|
||||
export function createAssetHelper(
|
||||
page: Page,
|
||||
...operators: AssetOperator[]
|
||||
): AssetHelper {
|
||||
const config = operators.reduce<AssetConfig>(
|
||||
(cfg, op) => op(cfg),
|
||||
emptyConfig()
|
||||
)
|
||||
return new AssetHelper(page, config)
|
||||
}
|
||||
382
browser_tests/tests/assetHelper.spec.ts
Normal file
382
browser_tests/tests/assetHelper.spec.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import {
|
||||
createAssetHelper,
|
||||
withModels,
|
||||
withInputFiles,
|
||||
withOutputAssets,
|
||||
withAsset,
|
||||
withPagination,
|
||||
withUploadResponse
|
||||
} from '../fixtures/helpers/AssetHelper'
|
||||
import {
|
||||
STABLE_CHECKPOINT,
|
||||
STABLE_LORA,
|
||||
STABLE_INPUT_IMAGE,
|
||||
STABLE_OUTPUT
|
||||
} from '../fixtures/data/assetFixtures'
|
||||
|
||||
test.describe('AssetHelper', () => {
|
||||
test.describe('operators and configuration', () => {
|
||||
test('creates helper with models via withModels operator', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const helper = createAssetHelper(
|
||||
comfyPage.page,
|
||||
withModels(3, 'checkpoints')
|
||||
)
|
||||
expect(helper.assetCount).toBe(3)
|
||||
expect(
|
||||
helper.getAssets().every((a) => a.tags?.includes('checkpoints'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('composes multiple operators', async ({ comfyPage }) => {
|
||||
const helper = createAssetHelper(
|
||||
comfyPage.page,
|
||||
withModels(2, 'checkpoints'),
|
||||
withInputFiles(2),
|
||||
withOutputAssets(1)
|
||||
)
|
||||
expect(helper.assetCount).toBe(5)
|
||||
})
|
||||
|
||||
test('adds individual assets via withAsset', async ({ comfyPage }) => {
|
||||
const helper = createAssetHelper(
|
||||
comfyPage.page,
|
||||
withAsset(STABLE_CHECKPOINT),
|
||||
withAsset(STABLE_LORA)
|
||||
)
|
||||
expect(helper.assetCount).toBe(2)
|
||||
expect(helper.getAsset(STABLE_CHECKPOINT.id)).toMatchObject({
|
||||
id: STABLE_CHECKPOINT.id,
|
||||
name: STABLE_CHECKPOINT.name
|
||||
})
|
||||
})
|
||||
|
||||
test('withPagination sets pagination options', async ({ comfyPage }) => {
|
||||
const helper = createAssetHelper(
|
||||
comfyPage.page,
|
||||
withModels(2),
|
||||
withPagination({ total: 100, hasMore: true })
|
||||
)
|
||||
expect(helper.assetCount).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('mock API routes', () => {
|
||||
test('GET /assets returns all assets', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(
|
||||
withAsset(STABLE_CHECKPOINT),
|
||||
withAsset(STABLE_INPUT_IMAGE)
|
||||
)
|
||||
await assetApi.mock()
|
||||
|
||||
const { status, body } = await assetApi.fetch(
|
||||
`${comfyPage.url}/api/assets`
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
|
||||
const data = body as {
|
||||
assets: unknown[]
|
||||
total: number
|
||||
has_more: boolean
|
||||
}
|
||||
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
|
||||
assetApi.configure(
|
||||
withModels(5),
|
||||
withPagination({ total: 10, hasMore: true })
|
||||
)
|
||||
await assetApi.mock()
|
||||
|
||||
const { body } = await assetApi.fetch(
|
||||
`${comfyPage.url}/api/assets?limit=2&offset=0`
|
||||
)
|
||||
const data = body as {
|
||||
assets: unknown[]
|
||||
total: number
|
||||
has_more: boolean
|
||||
}
|
||||
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
|
||||
assetApi.configure(
|
||||
withAsset(STABLE_CHECKPOINT),
|
||||
withAsset(STABLE_LORA),
|
||||
withAsset(STABLE_INPUT_IMAGE)
|
||||
)
|
||||
await assetApi.mock()
|
||||
|
||||
const { body } = await assetApi.fetch(
|
||||
`${comfyPage.url}/api/assets?include_tags=models,checkpoints`
|
||||
)
|
||||
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
|
||||
}) => {
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
const found = await assetApi.fetch(
|
||||
`${comfyPage.url}/api/assets/${STABLE_CHECKPOINT.id}`
|
||||
)
|
||||
expect(found.status).toBe(200)
|
||||
const asset = found.body as { id: string }
|
||||
expect(asset.id).toBe(STABLE_CHECKPOINT.id)
|
||||
|
||||
const notFound = await assetApi.fetch(
|
||||
`${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
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
const { status, body } = await assetApi.fetch(
|
||||
`${comfyPage.url}/api/assets/${STABLE_CHECKPOINT.id}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'renamed.safetensors' })
|
||||
}
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
|
||||
const updated = body as { name: string }
|
||||
expect(updated.name).toBe('renamed.safetensors')
|
||||
expect(assetApi.getAsset(STABLE_CHECKPOINT.id)?.name).toBe(
|
||||
'renamed.safetensors'
|
||||
)
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
|
||||
test('DELETE /assets/:id removes asset from store', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT), withAsset(STABLE_LORA))
|
||||
await assetApi.mock()
|
||||
|
||||
const { status } = await assetApi.fetch(
|
||||
`${comfyPage.url}/api/assets/${STABLE_CHECKPOINT.id}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
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 }) => {
|
||||
const customUpload = {
|
||||
id: 'custom-upload-001',
|
||||
name: 'custom.safetensors',
|
||||
tags: ['models'],
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
created_new: true
|
||||
}
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(withUploadResponse(customUpload))
|
||||
await assetApi.mock()
|
||||
|
||||
const { status, body } = await assetApi.fetch(
|
||||
`${comfyPage.url}/api/assets`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
expect(status).toBe(201)
|
||||
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
|
||||
}) => {
|
||||
const { assetApi } = comfyPage
|
||||
await assetApi.mock()
|
||||
|
||||
const { status, body } = await assetApi.fetch(
|
||||
`${comfyPage.url}/api/assets/download`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
expect(status).toBe(202)
|
||||
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
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
await assetApi.fetch(`${comfyPage.url}/api/assets`, { method: 'POST' })
|
||||
await assetApi.fetch(
|
||||
`${comfyPage.url}/api/assets/${STABLE_CHECKPOINT.id}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'updated.safetensors' })
|
||||
}
|
||||
)
|
||||
await assetApi.fetch(
|
||||
`${comfyPage.url}/api/assets/${STABLE_CHECKPOINT.id}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
|
||||
const mutations = assetApi.getMutations()
|
||||
expect(mutations).toHaveLength(3)
|
||||
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
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
await assetApi.fetch(`${comfyPage.url}/api/assets`)
|
||||
await assetApi.fetch(
|
||||
`${comfyPage.url}/api/assets/${STABLE_CHECKPOINT.id}`
|
||||
)
|
||||
|
||||
expect(assetApi.getMutations()).toHaveLength(0)
|
||||
|
||||
await assetApi.clearMocks()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('mockError', () => {
|
||||
test('returns error status for all asset routes', async ({ comfyPage }) => {
|
||||
const { assetApi } = comfyPage
|
||||
await assetApi.mockError(503, 'Service Unavailable')
|
||||
|
||||
const { status, body } = await assetApi.fetch(
|
||||
`${comfyPage.url}/api/assets`
|
||||
)
|
||||
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
|
||||
}) => {
|
||||
const { assetApi } = comfyPage
|
||||
assetApi.configure(withAsset(STABLE_CHECKPOINT))
|
||||
await assetApi.mock()
|
||||
|
||||
await assetApi.fetch(`${comfyPage.url}/api/assets`, { method: 'POST' })
|
||||
expect(assetApi.getMutations()).toHaveLength(1)
|
||||
expect(assetApi.assetCount).toBe(1)
|
||||
|
||||
await assetApi.clearMocks()
|
||||
expect(assetApi.getMutations()).toHaveLength(0)
|
||||
expect(assetApi.assetCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('fixture generators', () => {
|
||||
test('generateModels produces deterministic assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const helper = createAssetHelper(comfyPage.page, withModels(3, 'loras'))
|
||||
const assets = helper.getAssets()
|
||||
|
||||
expect(assets).toHaveLength(3)
|
||||
expect(assets.every((a) => a.tags?.includes('loras'))).toBe(true)
|
||||
expect(assets.every((a) => a.tags?.includes('models'))).toBe(true)
|
||||
|
||||
const ids = assets.map((a) => a.id)
|
||||
expect(new Set(ids).size).toBe(3)
|
||||
})
|
||||
|
||||
test('generateInputFiles produces deterministic input assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const helper = createAssetHelper(comfyPage.page, withInputFiles(3))
|
||||
const assets = helper.getAssets()
|
||||
|
||||
expect(assets).toHaveLength(3)
|
||||
expect(assets.every((a) => a.tags?.includes('input'))).toBe(true)
|
||||
})
|
||||
|
||||
test('generateOutputAssets produces deterministic output assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const helper = createAssetHelper(comfyPage.page, withOutputAssets(5))
|
||||
const assets = helper.getAssets()
|
||||
|
||||
expect(assets).toHaveLength(5)
|
||||
expect(assets.every((a) => a.tags?.includes('output'))).toBe(true)
|
||||
expect(assets.every((a) => a.name.startsWith('ComfyUI_'))).toBe(true)
|
||||
})
|
||||
|
||||
test('stable fixtures have expected properties', async ({ comfyPage }) => {
|
||||
const helper = createAssetHelper(
|
||||
comfyPage.page,
|
||||
withAsset(STABLE_CHECKPOINT),
|
||||
withAsset(STABLE_LORA),
|
||||
withAsset(STABLE_INPUT_IMAGE),
|
||||
withAsset(STABLE_OUTPUT)
|
||||
)
|
||||
|
||||
const checkpoint = helper.getAsset(STABLE_CHECKPOINT.id)!
|
||||
expect(checkpoint.tags).toContain('checkpoints')
|
||||
expect(checkpoint.size).toBeGreaterThan(0)
|
||||
expect(checkpoint.created_at).toBeTruthy()
|
||||
|
||||
const lora = helper.getAsset(STABLE_LORA.id)!
|
||||
expect(lora.tags).toContain('loras')
|
||||
|
||||
const input = helper.getAsset(STABLE_INPUT_IMAGE.id)!
|
||||
expect(input.tags).toContain('input')
|
||||
|
||||
const output = helper.getAsset(STABLE_OUTPUT.id)!
|
||||
expect(output.tags).toContain('output')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -58,7 +58,6 @@
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "catalog:",
|
||||
"@comfyorg/design-system": "workspace:*",
|
||||
"@comfyorg/ingest-types": "workspace:*",
|
||||
"@comfyorg/registry-types": "workspace:*",
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
"@comfyorg/tailwind-utils": "workspace:*",
|
||||
@@ -123,6 +122,7 @@
|
||||
"zod-validation-error": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@comfyorg/ingest-types": "workspace:*",
|
||||
"@eslint/js": "catalog:",
|
||||
"@intlify/eslint-plugin-vue-i18n": "catalog:",
|
||||
"@lobehub/i18n-cli": "catalog:",
|
||||
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -419,9 +419,6 @@ importers:
|
||||
'@comfyorg/design-system':
|
||||
specifier: workspace:*
|
||||
version: link:packages/design-system
|
||||
'@comfyorg/ingest-types':
|
||||
specifier: workspace:*
|
||||
version: link:packages/ingest-types
|
||||
'@comfyorg/registry-types':
|
||||
specifier: workspace:*
|
||||
version: link:packages/registry-types
|
||||
@@ -609,6 +606,9 @@ importers:
|
||||
specifier: 'catalog:'
|
||||
version: 3.3.0(zod@3.25.76)
|
||||
devDependencies:
|
||||
'@comfyorg/ingest-types':
|
||||
specifier: workspace:*
|
||||
version: link:packages/ingest-types
|
||||
'@eslint/js':
|
||||
specifier: 'catalog:'
|
||||
version: 9.39.1
|
||||
|
||||
Reference in New Issue
Block a user