Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions browser_tests/assets/missing/fe746_load_image_bare_filename.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"last_node_id": 10,
"last_link_id": 0,
"nodes": [
{
"id": 10,
"type": "LoadImage",
"pos": [50, 200],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["fe746_photo.png", "image"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}
197 changes: 197 additions & 0 deletions browser_tests/tests/missingMediaAssetUnion.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import type { Page } from '@playwright/test'
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'

import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import type { WorkspaceStore } from '@e2e/types/globals'

// BE-933 / BE-934 add `file_path` (and BE-933 also `display_name`) to the asset
// wire shape. `@comfyorg/ingest-types` is not yet regenerated from the updated
// OpenAPI (tracked under BE-932); extend the local type so mocks can carry the
// post-BE field without an `any` cast.
type PostBEAsset = Asset & {
file_path?: string | null
display_name?: string | null
}

const WORKFLOW_WIDGET_VALUE = 'fe746_photo.png'

async function mockAssetListing(
page: Page,
assets: PostBEAsset[]
): Promise<void> {
await page.route(/\/api\/assets(?=\?|$)/, async (route) => {
const response: ListAssetsResponse = {
assets: assets as Asset[],
total: assets.length,
has_more: false
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
})
}

async function mockAssetListingFailure(
page: Page,
status: number
): Promise<void> {
await page.route(/\/api\/assets(?=\?|$)/, async (route) => {
await route.fulfill({
status,
contentType: 'application/json',
body: JSON.stringify({ detail: `forced ${status} for FE-746 test` })
})
})
}

async function getCachedMissingMediaNames(
comfyPage: ComfyPage
): Promise<string[] | null> {
return await comfyPage.page.evaluate(() => {
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow
if (!workflow) return null
return (
workflow.pendingWarnings?.missingMediaCandidates?.map(
(candidate) => candidate.name
) ?? []
)
})
}

test.describe(
'Missing media detection — file_path union (FE-746)',
{ tag: '@cloud' },
() => {
test.use({
initialSettings: {
'Comfy.RightSidePanel.ShowErrorsTab': true
}
})

test('does not surface missing media when a post-BE asset emits file_path that diverges from the workflow widget value (Case B regression)', async ({
comfyPage
}) => {
// BE-933 / BE-934 post-deploy shape: asset emits a namespace-rooted
// file_path that differs from the bare `name` the user originally chose.
// The workflow widget value (`fe746_photo.png`) predates the rollout, so
// it must still match via the `name` arm of the detection-key union.
// Case A (file_path-only early return) would mark this as missing.
await mockAssetListing(comfyPage.page, [
{
id: 'fe746-asset-1',
name: WORKFLOW_WIDGET_VALUE,
hash: 'blake3:fe7460000000000000000000000000000',
file_path: 'input/sub/fe746_photo.png',
size: 1024,
mime_type: 'image/png',
tags: ['input'],
created_at: '2026-05-22T00:00:00Z',
updated_at: '2026-05-22T00:00:00Z',
last_access_time: '2026-05-22T00:00:00Z'
}
])

await comfyPage.workflow.loadWorkflow(
'missing/fe746_load_image_bare_filename'
)

await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).toBeHidden()
await expect.poll(() => getCachedMissingMediaNames(comfyPage)).toEqual([])
})

test('matches via legacy `name` fallback when the asset has no file_path (BE-933 hash-only registration)', async ({
comfyPage
}) => {
// BE-933 hash-only null case: asset registered via POST /assets/from-hash
// has no on-disk path, so `file_path` (and `display_name`) come back null.
// Detection must still succeed via the legacy `name` arm.
await mockAssetListing(comfyPage.page, [
{
id: 'fe746-asset-hash-only',
name: WORKFLOW_WIDGET_VALUE,
hash: 'blake3:fe7460000000000000000000000000001',
file_path: null,
display_name: null,
size: 1024,
mime_type: 'image/png',
tags: ['input'],
created_at: '2026-05-22T00:00:00Z',
updated_at: '2026-05-22T00:00:00Z',
last_access_time: '2026-05-22T00:00:00Z'
}
])

await comfyPage.workflow.loadWorkflow(
'missing/fe746_load_image_bare_filename'
)

await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).toBeHidden()
await expect.poll(() => getCachedMissingMediaNames(comfyPage)).toEqual([])
})

test('surfaces missing media when no asset in the listing covers the widget value', async ({
comfyPage
}) => {
// Sanity: with the union still in place, an asset listing that does not
// include the widget value via any key (file_path / hash / name)
// must still report missing. Guards against accidental "match
// everything" regressions when the early-return was removed.
await mockAssetListing(comfyPage.page, [
{
id: 'fe746-unrelated-asset',
name: 'unrelated.png',
hash: 'blake3:fe7460000000000000000000000000002',
file_path: 'input/unrelated.png',
size: 1024,
mime_type: 'image/png',
tags: ['input'],
created_at: '2026-05-22T00:00:00Z',
updated_at: '2026-05-22T00:00:00Z',
last_access_time: '2026-05-22T00:00:00Z'
}
])

await comfyPage.workflow.loadWorkflow(
'missing/fe746_load_image_bare_filename'
)

await expect
.poll(() => getCachedMissingMediaNames(comfyPage))
.toContain(WORKFLOW_WIDGET_VALUE)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).toBeVisible()
})

test('soft-degrades when /api/assets fails so verification does not deadlock pending candidates', async ({
comfyPage
}) => {
// Promise.allSettled + per-branch soft-degrade (Finding 2): when the
// input-asset oracle fails (pre-BE-786 OSS without /api/assets, partial
// BE-934 deploys, transient network errors), the verifier must finish
// — marking the candidate missing — rather than leaving isMissing
// stuck at undefined behind a silent toast.
await mockAssetListingFailure(comfyPage.page, 500)

await comfyPage.workflow.loadWorkflow(
'missing/fe746_load_image_bare_filename'
)

await expect
.poll(() => getCachedMissingMediaNames(comfyPage))
.toContain(WORKFLOW_WIDGET_VALUE)
})
}
)
8 changes: 2 additions & 6 deletions src/composables/graph/useErrorClearingHooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -937,12 +937,8 @@ describe('scan skips interior of bypassed subgraph containers', () => {
expect.any(Function),
expect.any(Function)
)
expect(mediaScanSpy).toHaveBeenCalledWith(rootGraph, leafNode, false)
expect(mediaScanSpy).not.toHaveBeenCalledWith(
rootGraph,
innerSubgraphNode,
false
)
expect(mediaScanSpy).toHaveBeenCalledWith(rootGraph, leafNode)
expect(mediaScanSpy).not.toHaveBeenCalledWith(rootGraph, innerSubgraphNode)
})
})

Expand Down
4 changes: 2 additions & 2 deletions src/composables/graph/useErrorClearingHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ function scanSingleNodeMedia(node: LGraphNode): void {
if (!app.rootGraph) return
if (!getActiveExecutionId(node)) return

const mediaCandidates = scanNodeMediaCandidates(app.rootGraph, node, isCloud)
const mediaCandidates = scanNodeMediaCandidates(app.rootGraph, node)
const confirmedMedia = mediaCandidates.filter((c) => c.isMissing === true)
if (confirmedMedia.length) {
useMissingMediaStore().addMissingMedia(confirmedMedia)
Expand Down Expand Up @@ -276,7 +276,7 @@ async function verifyAndAddPendingMedia(
): Promise<void> {
const rootGraphAtScan = app.rootGraph
try {
await verifyMediaCandidates(pending, { isCloud })
await verifyMediaCandidates(pending, { allowCompactSuffix: isCloud })
if (app.rootGraph !== rootGraphAtScan) return
const verified = pending.filter(
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)
Expand Down
6 changes: 6 additions & 0 deletions src/platform/assets/schemas/assetSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ const zAsset = z.object({
id: z.string(),
name: z.string(),
hash: z.string().nullish(),
// Namespace-rooted locator/display string, e.g. `input/sub/image.png` or
// `models/checkpoints/flux.safetensors`. Emitted on a best-effort basis
// (nullable). Identity is `id`, not `file_path`. Consumers must not assume
// `file_path` is populated and must degrade gracefully — see
// missingMediaAssetResolver.getAssetDetectionNames.
file_path: z.string().nullish(),
size: z.number().optional(), // TBD: Will be provided by history API in the future
mime_type: z.string().nullish(),
tags: z.array(z.string()).optional().default([]),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,6 @@ import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'

import { markDeletedAssetsAsMissingMedia } from './markDeletedAssetsAsMissingMedia'

vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))

const mockScanNodeMediaCandidates = vi.hoisted(() => vi.fn())
vi.mock('@/platform/missingMedia/missingMediaScan', () => ({
scanNodeMediaCandidates: mockScanNodeMediaCandidates
Expand Down Expand Up @@ -94,8 +90,7 @@ describe('FE-230 markDeletedAssetsAsMissingMedia', () => {
expect(mockScanNodeMediaCandidates).toHaveBeenCalledTimes(1)
expect(mockScanNodeMediaCandidates).toHaveBeenCalledWith(
expect.anything(),
inputNode,
true
inputNode
)
})

Expand Down
3 changes: 1 addition & 2 deletions src/platform/assets/utils/markDeletedAssetsAsMissingMedia.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { isCloud } from '@/platform/distribution/types'
import { scanNodeMediaCandidates } from '@/platform/missingMedia/missingMediaScan'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
Expand Down Expand Up @@ -38,7 +37,7 @@ export function markDeletedAssetsAsMissingMedia(
node.mode === LGraphEventMode.BYPASS
)
continue
for (const candidate of scanNodeMediaCandidates(rootGraph, node, isCloud)) {
for (const candidate of scanNodeMediaCandidates(rootGraph, node)) {
if (!deletedValues.has(candidate.name)) continue
candidates.push({ ...candidate, isMissing: true })
}
Expand Down
Loading
Loading