import { expect, mergeTests } from '@playwright/test' import type { Page, Route } from '@playwright/test' import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types' import { assetRequestIncludesTag, createCloudAssetsFixture } from '@e2e/fixtures/assetApiFixture' import { comfyPageFixture } from '@e2e/fixtures/ComfyPage' import type { ComfyPage } from '@e2e/fixtures/ComfyPage' import { createRouteMockJob, jobsRouteFixture } from '@e2e/fixtures/jobsRouteFixture' import { TestIds } from '@e2e/fixtures/selectors' import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper' import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes' const ossTest = mergeTests(comfyPageFixture, jobsRouteFixture) const outputHash = '147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png' const plainVideoFileName = 'plain_video.mp4' const graphDropPosition = { x: 500, y: 300 } const missingMediaUploadObservationMs = 1_000 const missingMediaUploadPollMs = 100 const emptyMediaLoaderNodes = [ { nodeType: 'LoadImage', widgetName: 'image', serverOnlyOption: 'server-only-image.png', position: { x: 150, y: 150 } }, { nodeType: 'LoadVideo', widgetName: 'file', serverOnlyOption: 'server-only-video.mp4', position: { x: 450, y: 150 } }, { nodeType: 'LoadAudio', widgetName: 'audio', serverOnlyOption: 'server-only-audio.wav', position: { x: 750, y: 150 } } ] const cloudOutputAsset: Asset & { hash?: string } = { id: 'test-output-hash-001', name: 'ComfyUI_00001_.png', hash: outputHash, size: 4_194_304, mime_type: 'image/png', tags: ['output'], created_at: '2026-05-01T00:00:00Z', updated_at: '2026-05-01T00:00:00Z', last_access_time: '2026-05-01T00:00:00Z' } const cloudUploadedVideoAsset: Asset & { hash?: string } = { id: 'test-uploaded-video-001', name: plainVideoFileName, hash: plainVideoFileName, size: 1_024, mime_type: 'video/mp4', tags: ['input'], created_at: '2026-05-01T00:00:00Z', updated_at: '2026-05-01T00:00:00Z', last_access_time: '2026-05-01T00:00:00Z' } // The Cloud test app starts with a default LoadImage node. Keep that baseline // input resolvable so this spec only observes the media it creates. const cloudDefaultGraphInputAsset: Asset & { hash?: string } = { id: 'test-default-input-001', name: '00000000000000000000000Aexample.png', hash: '00000000000000000000000Aexample.png', size: 1_024, mime_type: 'image/png', tags: ['input'], created_at: '2026-05-01T00:00:00Z', updated_at: '2026-05-01T00:00:00Z', last_access_time: '2026-05-01T00:00:00Z' } interface CloudUploadAssetState { isUploadedAssetAvailable: boolean } type ObjectInfoResponse = Record< string, { input?: { required?: Record } } > function setComboInputOptions( objectInfo: ObjectInfoResponse, nodeType: string, inputName: string, values: string[] ) { const nodeInfo = objectInfo[nodeType] if (!nodeInfo) { throw new Error(`Missing object_info entry for ${nodeType}`) } const requiredInputs = nodeInfo.input?.required if (!requiredInputs) { throw new Error(`Missing required inputs for ${nodeType}`) } const input = requiredInputs[inputName] if (!Array.isArray(input)) { throw new Error(`Expected ${nodeType}.${inputName} to be a combo input`) } const [valuesOrType, options] = input const optionsObject = options && typeof options === 'object' && !Array.isArray(options) if (Array.isArray(valuesOrType)) { input[0] = values } else if (valuesOrType !== 'COMBO') { throw new Error(`Expected ${nodeType}.${inputName} to have combo options`) } if (optionsObject) { Object.assign(options, { options: values }) } else if (!Array.isArray(valuesOrType)) { throw new Error( `Expected ${nodeType}.${inputName} to have options metadata` ) } } async function routeCloudBootstrapApis(page: Page) { await page.route('**/api/settings**', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) }) }) await page.route('**/api/userdata**', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) }) }) await page.route('**/i18n', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) }) }) await page.route('**/customers/cloud-subscription-status', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ is_active: true }) }) }) } async function routeSetupObjectInfo( page: Page, customize?: (objectInfo: ObjectInfoResponse) => void ) { const setupApiUrl = process.env.PLAYWRIGHT_SETUP_API_URL ?? 'http://127.0.0.1:8188' const objectInfoUrl = new URL('/object_info', setupApiUrl).toString() const objectInfoRouteHandler = async (route: Route) => { try { const response = await fetch(objectInfoUrl, { signal: AbortSignal.timeout(5_000) }) if (!response.ok) { await route.fulfill({ status: response.status, contentType: response.headers.get('content-type') ?? 'text/plain', body: await response.text() }) return } const objectInfo = (await response.json()) as ObjectInfoResponse customize?.(objectInfo) await route.fulfill({ status: response.status, contentType: 'application/json', body: JSON.stringify(objectInfo) }) } catch (error) { const message = error instanceof Error ? error.message : String(error) await route.fulfill({ status: 502, contentType: 'application/json', body: JSON.stringify({ error: `Failed to fetch setup object_info from ${objectInfoUrl}: ${message}` }) }) } } await page.route('**/object_info', objectInfoRouteHandler) return async () => await page.unroute('**/object_info', objectInfoRouteHandler) } const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset]).extend({ page: async ({ page }, use) => { await routeCloudBootstrapApis(page) const unrouteObjectInfo = await routeSetupObjectInfo(page) try { await use(page) } finally { await unrouteObjectInfo() } } }) const cloudEmptyMediaInputsTest = createCloudAssetsFixture([]).extend({ page: async ({ page }, use) => { await routeCloudBootstrapApis(page) const unrouteObjectInfo = await routeSetupObjectInfo(page, (objectInfo) => { for (const node of emptyMediaLoaderNodes) { setComboInputOptions(objectInfo, node.nodeType, node.widgetName, [ node.serverOnlyOption ]) } }) try { await use(page) } finally { await unrouteObjectInfo() } } }) const cloudUploadAssetStateByPage = new WeakMap() const cloudUploadRaceTest = comfyPageFixture.extend<{ markUploadedCloudAssetAvailable: () => void }>({ page: async ({ page }, use) => { await routeCloudBootstrapApis(page) const unrouteObjectInfo = await routeSetupObjectInfo(page) const state: CloudUploadAssetState = { isUploadedAssetAvailable: false } cloudUploadAssetStateByPage.set(page, state) const assetsRouteHandler = async (route: Route) => { const allAssets = [ cloudDefaultGraphInputAsset, ...(state.isUploadedAssetAvailable ? [cloudUploadedVideoAsset] : []) ] const includeTags = new URL(route.request().url()).searchParams .get('include_tags') ?.split(',') .filter(Boolean) ?? [] const assets = includeTags.length ? allAssets.filter((asset) => asset.tags?.some((tag) => includeTags.includes(tag)) ) : allAssets const response: ListAssetsResponse = { assets, total: assets.length, has_more: false } await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(response) }) } await page.route(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler) try { await use(page) } finally { await page.unroute(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler) await unrouteObjectInfo() cloudUploadAssetStateByPage.delete(page) } }, markUploadedCloudAssetAvailable: async ({ page }, use) => { await use(() => { const state = cloudUploadAssetStateByPage.get(page) if (state) state.isUploadedAssetAvailable = true }) } }) async function enableErrorsTab(comfyPage: ComfyPage) { await comfyPage.settings.setSetting( 'Comfy.RightSidePanel.ShowErrorsTab', true ) } function getErrorOverlay(comfyPage: ComfyPage) { return comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay) } async function expectNoErrorsTab(comfyPage: ComfyPage) { await expect(getErrorOverlay(comfyPage)).toBeHidden() const panel = new PropertiesPanelHelper(comfyPage.page) await panel.open(comfyPage.actionbar.propertiesButton) await expect( panel.root.getByTestId(TestIds.propertiesPanel.errorsTab) ).toBeHidden() } async function closeTemplatesDialogIfOpen(comfyPage: ComfyPage) { const templatesDialog = comfyPage.page.getByRole('dialog').filter({ has: comfyPage.templates.content }) const closeButton = templatesDialog.getByRole('button', { name: 'Close dialog' }) await closeButton .waitFor({ state: 'visible', timeout: 1_000 }) .catch(() => undefined) if (await closeButton.isVisible()) { await closeButton.click() await expect(templatesDialog).toBeHidden() } } async function getMediaLoaderWidgetValues(comfyPage: ComfyPage) { return await comfyPage.page.evaluate((nodes) => { return nodes.map(({ nodeType, widgetName }) => { const node = window.app!.graph.nodes.find( (graphNode) => graphNode.type === nodeType ) const widget = node?.widgets?.find( (candidate) => candidate.name === widgetName ) return widget?.value ?? null }) }, emptyMediaLoaderNodes) } async function delayNextUpload( comfyPage: ComfyPage, uploadResult?: { name: string; subfolder: string; type: 'input' } ) { let releaseUpload!: () => void let resolveUploadStarted!: () => void const uploadStarted = new Promise((resolve) => { resolveUploadStarted = resolve }) const release = new Promise((resolve) => { releaseUpload = resolve }) const uploadRouteHandler = async (route: Route) => { resolveUploadStarted() await release if (uploadResult) { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(uploadResult) }) return } await route.continue() } await comfyPage.page.route('**/upload/image', uploadRouteHandler) return { waitForUploadStarted: () => uploadStarted, finishUpload: async () => { const uploadResponse = comfyPage.page.waitForResponse( (response) => response.url().includes('/upload/image') && response.status() === 200, { timeout: 10_000 } ) releaseUpload() try { await uploadResponse } finally { await comfyPage.page.unroute('**/upload/image', uploadRouteHandler) } } } } async function expectLoadVideoUploading(comfyPage: ComfyPage) { await expect .poll( () => comfyPage.page.evaluate(() => window.app!.graph.nodes.some( (node) => node.type === 'LoadVideo' && node.isUploading ) ), { timeout: 5_000 } ) .toBe(true) } async function expectNoMissingMediaDuringUpload(comfyPage: ComfyPage) { await comfyPage.nextFrame() await comfyPage.nextFrame() let sawErrorOverlay = false const startedAt = Date.now() await expect .poll( async () => { sawErrorOverlay = sawErrorOverlay || (await getErrorOverlay(comfyPage).isVisible()) return ( !sawErrorOverlay && Date.now() - startedAt >= missingMediaUploadObservationMs ) }, { timeout: missingMediaUploadObservationMs + missingMediaUploadPollMs * 5, intervals: [missingMediaUploadPollMs] } ) .toBe(true) } function outputHistoryJobs(): RawJobListItem[] { return [ createRouteMockJob({ id: 'history-output-image', preview_output: { filename: 'ComfyUI_00001_.png', subfolder: '', type: 'output', nodeId: '1', mediaType: 'images' } }), createRouteMockJob({ id: 'history-output-video', preview_output: { filename: 'clip.mp4', subfolder: '', type: 'output', nodeId: '2', mediaType: 'video' } }), createRouteMockJob({ id: 'history-output-audio', preview_output: { filename: 'sound.wav', subfolder: '', type: 'output', nodeId: '3', mediaType: 'audio' } }) ] } ossTest.describe( 'Errors tab - OSS missing media runtime sources', { tag: '@ui' }, () => { ossTest.beforeEach(async ({ comfyPage }) => { await enableErrorsTab(comfyPage) }) ossTest( 'resolves annotated output media from job history', async ({ comfyPage, jobsRoutes }) => { await jobsRoutes.mockJobsHistory(outputHistoryJobs()) await jobsRoutes.mockJobsQueue([]) await comfyPage.workflow.loadWorkflow( 'missing/missing_media_output_annotations' ) await expectNoErrorsTab(comfyPage) } ) ossTest( 'does not surface missing media while dropped video upload is in progress', async ({ comfyFiles, comfyPage }) => { await comfyPage.nodeOps.clearGraph() const delayedUpload = await delayNextUpload(comfyPage) await comfyPage.dragDrop.dragAndDropFile(plainVideoFileName, { dropPosition: graphDropPosition }) await delayedUpload.waitForUploadStarted() comfyFiles.deleteAfterTest({ filename: plainVideoFileName, type: 'input' }) await expectLoadVideoUploading(comfyPage) await expectNoMissingMediaDuringUpload(comfyPage) await delayedUpload.finishUpload() await expect(getErrorOverlay(comfyPage)).toBeHidden() } ) } ) cloudEmptyMediaInputsTest.describe( 'Errors tab - Cloud empty media loader inputs', { tag: '@cloud' }, () => { cloudEmptyMediaInputsTest.beforeEach(async ({ comfyPage }) => { await enableErrorsTab(comfyPage) await closeTemplatesDialogIfOpen(comfyPage) }) cloudEmptyMediaInputsTest( 'does not surface missing inputs after adding LoadImage, LoadVideo, and LoadAudio nodes with no cloud input assets', async ({ cloudAssetRequests, comfyPage }) => { await comfyPage.nodeOps.clearGraph() for (const node of emptyMediaLoaderNodes) { await comfyPage.nodeOps.addNode( node.nodeType, undefined, node.position ) } await expect .poll(() => cloudAssetRequests.some((url) => assetRequestIncludesTag(url, 'input') ) ) .toBe(true) await expect .poll(() => getMediaLoaderWidgetValues(comfyPage)) .toEqual(['', '', '']) await expectNoErrorsTab(comfyPage) } ) } ) cloudOutputTest.describe( 'Errors tab - Cloud missing media runtime sources', { tag: '@cloud' }, () => { cloudOutputTest.beforeEach(async ({ comfyPage }) => { await enableErrorsTab(comfyPage) await closeTemplatesDialogIfOpen(comfyPage) }) cloudOutputTest( 'resolves compact annotated output media from output assets', async ({ cloudAssetRequests, comfyPage }) => { await comfyPage.workflow.loadWorkflow( 'missing/missing_media_cloud_output_annotation' ) await expect .poll(() => cloudAssetRequests.some((url) => assetRequestIncludesTag(url, 'output') ) ) .toBe(true) await expectNoErrorsTab(comfyPage) } ) } ) cloudUploadRaceTest.describe( 'Errors tab - Cloud missing media upload race', { tag: '@cloud' }, () => { cloudUploadRaceTest.beforeEach(async ({ comfyPage }) => { await enableErrorsTab(comfyPage) await closeTemplatesDialogIfOpen(comfyPage) }) cloudUploadRaceTest( 'does not surface missing media while dropped video upload is in progress', async ({ comfyFiles, comfyPage, markUploadedCloudAssetAvailable }) => { await comfyPage.nodeOps.clearGraph() const delayedUpload = await delayNextUpload(comfyPage, { name: plainVideoFileName, subfolder: '', type: 'input' }) await comfyPage.dragDrop.dragAndDropFile(plainVideoFileName, { dropPosition: graphDropPosition }) await delayedUpload.waitForUploadStarted() comfyFiles.deleteAfterTest({ filename: plainVideoFileName, type: 'input' }) await expectLoadVideoUploading(comfyPage) await expectNoMissingMediaDuringUpload(comfyPage) markUploadedCloudAssetAvailable() await delayedUpload.finishUpload() await expect(getErrorOverlay(comfyPage)).toBeHidden() } ) } )