Compare commits

...

19 Commits

Author SHA1 Message Date
Benjamin Lu
366907f0f1 test: type asset helper update payload 2026-03-30 11:53:04 -07:00
GitHub Action
11e35c9167 [automated] Apply ESLint and Oxfmt fixes 2026-03-29 07:29:58 +00:00
bymyself
926628cd59 fix: use comfyPage.assetApi fixture and add fetch/configure helpers
- Added fetch() method to AssetHelper for page-context requests
- Added configure() method for reconfiguring an existing helper
- Refactored tests to use comfyPage.assetApi instead of creating
  standalone helpers for mock API tests
- Removed standalone pageFetch helper from test file
2026-03-29 00:27:01 -07:00
bymyself
bc52d164e2 fix: track mutations by method, not body presence
POST/DELETE without a body were not recorded because
postDataJSON() returns null. Track based on HTTP method instead.
2026-03-28 23:20:54 -07:00
GitHub Action
71e9e7c057 [automated] Apply ESLint and Oxfmt fixes 2026-03-29 06:08:28 +00:00
bymyself
f5ccdbda5b fix: use page.evaluate for fetch in assetHelper tests
page.request (APIRequestContext) bypasses page.route() interception.
Switch to page.evaluate(fetch) so mocked routes are intercepted.
2026-03-28 23:05:22 -07:00
GitHub Action
b2f168bb67 [automated] Apply ESLint and Oxfmt fixes 2026-03-29 05:49:45 +00:00
bymyself
b2a8f6e1f5 test: add test cases for AssetHelper to validate DX and correctness
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10545#pullrequestreview-4026381035
2026-03-28 22:46:25 -07:00
bymyself
cd0113aad7 fix: extract route handlers from mock() into private methods
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10545#discussion_r3005739560
2026-03-28 22:44:43 -07:00
GitHub Action
bac671e20b [automated] Apply ESLint and Oxfmt fixes 2026-03-28 23:12:17 +00:00
bymyself
517a511131 fix: move @comfyorg/ingest-types to devDependencies 2026-03-28 16:08:59 -07:00
bymyself
0a81361dab fix: remove duplicate lockfile entries from rebase 2026-03-28 16:05:21 -07:00
bymyself
c0624b778e fix: remove section/HR comments and resolve duplicate assets property
Remove all section/HR comment dividers from assetFixtures.ts and
AssetHelper.ts. Rename AssetHelper property to assetApi to avoid
conflict with existing AssetsHelper. Wire clearMocks() into fixture
teardown.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10545#discussion_r3005416403
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10545#discussion_r3003581474
2026-03-28 16:02:46 -07:00
bymyself
9424b89893 fix: integrate AssetHelper cleanup into Playwright fixture teardown
Adds automatic clearMocks() in the comfyPageFixture teardown phase so
test cleanup is handled by the fixture lifecycle instead of requiring
manual calls inside test cases.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10545#discussion_r3003581474
2026-03-28 16:02:46 -07:00
bymyself
802c52ae1f refactor: replace builder pattern with functional pipeable operators
Replaces mutable builder methods (.withModels().withInputFiles()) with
composable operator functions (withModels(), withInputFiles()) applied
via createAssetHelper(page, ...operators). This makes invalid state
transitions like calling withEmptyState() mid-chain impossible and
improves type safety.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10545#discussion_r3003567072
2026-03-28 16:02:46 -07:00
bymyself
7afceb465e fix: centralize MIME type mapping in asset fixtures
Replaces hardcoded INPUT_MIMES record with extensible EXTENSION_MIME_MAP
and getMimeType() helper for consistent MIME type resolution.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10545#discussion_r3003548994
2026-03-28 16:02:46 -07:00
bymyself
036eb79be8 fix: use canonical Asset types from ingest-types package
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10545#discussion_r3003546791
2026-03-28 16:02:46 -07:00
bymyself
ddec74d162 fix: address CodeRabbit review — response shape, defensive tags, timestamp overflow 2026-03-28 16:02:46 -07:00
bymyself
7febc38cfd test(infra): add AssetHelper with builder pattern + deterministic fixtures
- AssetHelper: fluent API for mocking asset endpoints in Playwright E2E tests
  - .withModels(n).withInputFiles(n).withOutputAssets(n).mock()
  - Stateful mock store for upload→verify→delete flows
  - Covers: GET/POST/PUT/DELETE /assets, download progress
  - mockError() for error state testing
  - clearMocks() cleanup matching QueueHelper/FeatureFlagHelper pattern
- assetFixtures: 11 stable named constants (checkpoints, loras, VAE, etc.)
  - Factory functions: generateModels(), generateInputFiles(), generateOutputAssets()
  - Fixed IDs/dates/sizes — deterministic for screenshot comparisons
- Integrated into ComfyPage as comfyPage.assets

Unblocks: PNL-02 (asset browser), DLG-08 (cloud dialog)

Part of: Test Coverage Q2 Overhaul (UTIL-06)
2026-03-28 16:02:46 -07:00
6 changed files with 1014 additions and 0 deletions

View File

@@ -26,6 +26,8 @@ import {
import { Topbar } from './components/Topbar'
import { AssetsHelper } from './helpers/AssetsHelper'
import { CanvasHelper } from './helpers/CanvasHelper'
import type { AssetHelper } from './helpers/AssetHelper'
import { createAssetHelper } from './helpers/AssetHelper'
import { PerformanceHelper } from './helpers/PerformanceHelper'
import { QueueHelper } from './helpers/QueueHelper'
import { ClipboardHelper } from './helpers/ClipboardHelper'
@@ -201,6 +203,7 @@ export class ComfyPage {
public readonly bottomPanel: BottomPanel
public readonly perf: PerformanceHelper
public readonly assets: AssetsHelper
public readonly assetApi: AssetHelper
public readonly queue: QueueHelper
/** Worker index to test user ID */
@@ -248,6 +251,7 @@ export class ComfyPage {
this.bottomPanel = new BottomPanel(page)
this.perf = new PerformanceHelper(page)
this.assets = new AssetsHelper(page)
this.assetApi = createAssetHelper(page)
this.queue = new QueueHelper(page)
}
@@ -468,6 +472,7 @@ 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,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`
})
)
}

View File

@@ -0,0 +1,317 @@
import type { Page, Route } from '@playwright/test'
import type {
Asset,
ListAssetsResponse,
UpdateAssetData
} 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: UpdateAssetData['body'] | null
) {
const id = path.split('/').pop()!
const asset = this.store.get(id)
if (asset) {
const updated = {
...asset,
...(body ?? {}),
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)
}

View 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')
})
})
})

View File

@@ -120,6 +120,7 @@
"zod-validation-error": "catalog:"
},
"devDependencies": {
"@comfyorg/ingest-types": "workspace:*",
"@eslint/js": "catalog:",
"@intlify/eslint-plugin-vue-i18n": "catalog:",
"@lobehub/i18n-cli": "catalog:",

3
pnpm-lock.yaml generated
View File

@@ -594,6 +594,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