diff --git a/browser_tests/fixtures/ComfyPage.ts b/browser_tests/fixtures/ComfyPage.ts index d95bb4bba7..49ca34b989 100644 --- a/browser_tests/fixtures/ComfyPage.ts +++ b/browser_tests/fixtures/ComfyPage.ts @@ -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) => { diff --git a/browser_tests/fixtures/data/assetFixtures.ts b/browser_tests/fixtures/data/assetFixtures.ts new file mode 100644 index 0000000000..2b8c4bdc13 --- /dev/null +++ b/browser_tests/fixtures/data/assetFixtures.ts @@ -0,0 +1,306 @@ +import type { Asset } from '@comfyorg/ingest-types' +function createModelAsset(overrides: Partial = {}): 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 { + 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 { + 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 = { + 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` + }) + ) +} diff --git a/browser_tests/fixtures/helpers/AssetHelper.ts b/browser_tests/fixtures/helpers/AssetHelper.ts new file mode 100644 index 0000000000..5e3bcabcfe --- /dev/null +++ b/browser_tests/fixtures/helpers/AssetHelper.ts @@ -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 + readonly pagination: PaginationOptions | null + readonly uploadResponse: Record | 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 +): AssetOperator { + return (config) => ({ ...config, uploadResponse: response }) +} +export class AssetHelper { + private store: Map + private paginationOptions: PaginationOptions | null + private routeHandlers: Array<{ + pattern: string + handler: (route: Route) => Promise + }> = [] + private mutations: MutationRecord[] = [] + private uploadResponse: Record | 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 { + 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 | 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 { + 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( + (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), + 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 { + 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( + (cfg, op) => op(cfg), + emptyConfig() + ) + return new AssetHelper(page, config) +} diff --git a/browser_tests/tests/assetHelper.spec.ts b/browser_tests/tests/assetHelper.spec.ts new file mode 100644 index 0000000000..4d1dfe1d5e --- /dev/null +++ b/browser_tests/tests/assetHelper.spec.ts @@ -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') + }) + }) +}) diff --git a/package.json b/package.json index cebc7a8a77..119dc40db6 100644 --- a/package.json +++ b/package.json @@ -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:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40c86a67b4..4302e89877 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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