mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-06 07:51:57 +00:00
## Summary The assets API exposes an asset's content hash as `hash`. An older `asset_hash` field was a deprecated alias carrying the same value. This PR moves the frontend fully onto `hash` and removes `asset_hash` from the frontend entirely. ## Changes - Read `asset.hash` (no `?? asset_hash` fallback) across the asset consumers: - `useMediaAssetActions` — widget-value variants + cloud-mode stored-filename resolution - `assetsStore` — input-asset-by-filename map - `assetMetadataUtils.getAssetUrlFilename` - `missingMedia` resolver/scan and `missingModel` scan hash matching - `useComboWidget` / `useWidgetSelectItems` - `assetPreviewUtil.findOutputAsset` now queries `/assets?hash=` instead of the deprecated `?asset_hash=` param and matches on `a.hash`. - Removed `asset_hash` from the zod asset schema and the local `AssetRecord` type. Responses that still include the alias parse cleanly — zod strips unknown keys — so the declared field protected nothing once the reads were gone. - Purged `asset_hash` from all test fixtures/mocks; tests key on the canonical `hash`. ## Safety / rollout The API currently emits **both** `hash` and `asset_hash` with identical values, so reading `hash` is safe today. This is the frontend half of retiring the alias; the backend stops emitting `asset_hash` only after this ships and old bundles age out, so there is no window where the field the UI reads is absent. ## Verification - `pnpm typecheck`: clean. - Affected unit tests pass (asset utils, store, media/model scans, widget composables). - `grep -rn asset_hash src/`: zero matches.
625 lines
17 KiB
TypeScript
625 lines
17 KiB
TypeScript
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<string, unknown> } }
|
|
>
|
|
|
|
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<Page, CloudUploadAssetState>()
|
|
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<void>((resolve) => {
|
|
resolveUploadStarted = resolve
|
|
})
|
|
const release = new Promise<void>((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()
|
|
}
|
|
)
|
|
}
|
|
)
|