Files
ComfyUI_frontend/src/platform/missingMedia/missingMediaScan.test.ts
jaeone94 04918360eb Use hash lookup for missing asset detection (#11873)
## Summary

Use exact BLAKE3 hash lookups first for missing model/media detection,
and add a separate public-inclusive input asset cache so public input
assets are considered missing-detection candidates without changing the
user-only input assets shown in the UI.

## Changes

- **What**:
- Added `assetService.checkAssetHash()` for `HEAD
/api/assets/hash/{hash}` status-only existence checks.
- Added strict BLAKE3 hash helpers so only `blake3:<64 hex>` media
values and raw 64-hex BLAKE3 model metadata are sent to the hash
endpoint.
- Updated missing media detection to group BLAKE3 candidates by hash,
resolve them through the hash endpoint, and fall back to the legacy
asset list path for invalid/unverifiable/non-hash values.
- Updated missing model detection to use hash lookup for BLAKE3-backed
asset-supported candidates before falling back to the existing node-type
asset matching path.
- Added `assetService.getInputAssetsIncludingPublic()` backed by a
dedicated cache that fetches input assets with `include_public=true` for
missing media fallback checks.
- Kept `assetsStore.inputAssets` user-only for widget/UI display, while
invalidating the public-inclusive missing-detection cache when input
assets may change.
- Added abort handling for paginated asset fetches and shared
public-input cache callers so one aborted caller does not cancel the
shared fetch for other callers.
- Added regression coverage for hash lookup, fallback behavior, abort
paths, public input fallback detection, and cache invalidation.
- **Dependencies**: None.
- **Change size**:
  - Production code: 4 files, 400 insertions, 24 deletions, net +376.
  - Test code: 4 files, 806 insertions, 59 deletions, net +747.
  - Total: 8 files, 1206 insertions, 83 deletions, net +1123.

## Review Focus

- The public-inclusive input asset cache is intentionally separate from
`assetsStore.inputAssets`. The existing store data is user-only and
drives the asset widgets/sidebar, so using it for missing input
detection misses public assets. Making that store public-inclusive would
change UI data semantics; this PR instead keeps the UI dataset unchanged
and adds a missing-detection-specific cache in `assetService`.
- Hash lookup is only used when the workflow exposes a valid BLAKE3
hash. Filename-like values and invalid hash values still use the legacy
fallback path.
- Missing model detection keeps the existing fallback behavior for
non-hash candidates and for hash checks that are invalid or fail
transiently.
- Async model download cache refresh behavior is left unchanged; this PR
avoids coupling model download completion to input asset cache
invalidation.
- No browser/e2e test was added because this changes the missing asset
detection data path, not UI interaction or rendering. The behavioral
coverage is in unit tests for the asset service and the missing
media/model scanners.

## Follow-up Items

- Fix `assetsStore.updateAssetTags()` partial-failure recovery. If
`removeAssetTags()` succeeds and `addAssetTags()` fails, the local model
asset cache can roll back to tags that the backend has already removed;
this should be handled in a focused model asset cache PR.
- Consider extracting shared hash-verification flow used by missing
media and missing model scans after this behavior stabilizes.
- Consider adding a concurrency cap or short-lived request cache for
large workflows with many unique hash lookups.
- Consider splitting `assetService.ts` further, e.g. hash helpers, abort
utilities, and the public-inclusive input asset cache.
- Consider tightening the asset hash service API shape so callers do not
directly depend on HTTP-oriented statuses such as `invalid`.
- Consider adding broader mutation-path coverage for public-inclusive
input cache invalidation once the cache has more consumers.

Linear: FE-534

## Screenshots (if applicable)

Before <false positive / missing image / public asset>


https://github.com/user-attachments/assets/db7ce2a9-b169-4fae-bf9f-98bb93d3ee6d

After 


https://github.com/user-attachments/assets/29af9f9e-b536-4fcd-a426-3add40bcb165



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11873-Use-hash-lookup-for-missing-asset-detection-3556d73d36508165babafb16614be0d8)
by [Unito](https://www.unito.io)
2026-05-04 03:59:54 +00:00

531 lines
15 KiB
TypeScript

import { fromAny } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type * as AssetServiceModule from '@/platform/assets/services/assetService'
import {
scanAllMediaCandidates,
scanNodeMediaCandidates,
verifyCloudMediaCandidates,
groupCandidatesByName,
groupCandidatesByMediaType
} from './missingMediaScan'
import type { MissingMediaCandidate } from './types'
const { mockCheckAssetHash, mockGetInputAssetsIncludingPublic } = vi.hoisted(
() => ({
mockCheckAssetHash: vi.fn(),
mockGetInputAssetsIncludingPublic: vi.fn()
})
)
vi.mock('@/utils/graphTraversalUtil', () => ({
collectAllNodes: (graph: { _testNodes: LGraphNode[] }) => graph._testNodes,
getExecutionIdByNode: (
_graph: unknown,
node: { _testExecutionId?: string; id: number }
) => node._testExecutionId ?? String(node.id)
}))
vi.mock('@/platform/assets/services/assetService', async () => {
const actual = await vi.importActual<typeof AssetServiceModule>(
'@/platform/assets/services/assetService'
)
return {
...actual,
assetService: {
...actual.assetService,
checkAssetHash: mockCheckAssetHash,
getInputAssetsIncludingPublic: mockGetInputAssetsIncludingPublic
}
}
})
function makeCandidate(
nodeId: string,
name: string,
overrides: Partial<MissingMediaCandidate> = {}
): MissingMediaCandidate {
return {
nodeId,
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name,
isMissing: true,
...overrides
}
}
function makeMediaCombo(
name: string,
value: string,
options: string[] = []
): IComboWidget {
return fromAny<IComboWidget, unknown>({
type: 'combo',
name,
value,
options: { values: options }
})
}
function makeMediaNode(
id: number,
type: string,
widgets: IComboWidget[],
mode: number = 0,
executionId?: string
): LGraphNode {
return fromAny<LGraphNode, unknown>({
id,
type,
widgets,
mode,
_testExecutionId: executionId ?? String(id)
})
}
function makeGraph(nodes: LGraphNode[]): LGraph {
return fromAny<LGraph, unknown>({ _testNodes: nodes })
}
function makeAsset(name: string, assetHash: string | null = null): AssetItem {
return {
id: name,
name,
asset_hash: assetHash,
mime_type: null,
tags: ['input']
}
}
describe('scanNodeMediaCandidates', () => {
it('returns candidate for a LoadImage node with missing image', () => {
const graph = makeGraph([])
const node = makeMediaNode(
1,
'LoadImage',
[makeMediaCombo('image', 'photo.png', ['other.png'])],
0
)
const result = scanNodeMediaCandidates(graph, node, false)
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'photo.png',
isMissing: true
})
})
it('returns empty for non-media node types', () => {
const graph = makeGraph([])
const node = makeMediaNode(
1,
'KSampler',
[makeMediaCombo('sampler', 'euler', ['euler', 'dpm'])],
0
)
const result = scanNodeMediaCandidates(graph, node, false)
expect(result).toEqual([])
})
it('returns empty for node with no widgets', () => {
const graph = makeGraph([])
const node = makeMediaNode(1, 'LoadImage', [], 0)
const result = scanNodeMediaCandidates(graph, node, false)
expect(result).toEqual([])
})
})
describe('scanAllMediaCandidates', () => {
it('skips muted nodes (mode === NEVER)', () => {
const node = makeMediaNode(
1,
'LoadImage',
[makeMediaCombo('image', 'photo.png', ['other.png'])],
2 // NEVER
)
const result = scanAllMediaCandidates(makeGraph([node]), false)
expect(result).toHaveLength(0)
})
it('skips bypassed nodes (mode === BYPASS)', () => {
const node = makeMediaNode(
2,
'LoadImage',
[makeMediaCombo('image', 'photo.png', ['other.png'])],
4 // BYPASS
)
const result = scanAllMediaCandidates(makeGraph([node]), false)
expect(result).toHaveLength(0)
})
it('includes active nodes (mode === ALWAYS)', () => {
const node = makeMediaNode(
3,
'LoadImage',
[makeMediaCombo('image', 'photo.png', ['other.png'])],
0 // ALWAYS
)
const result = scanAllMediaCandidates(makeGraph([node]), false)
expect(result).toHaveLength(1)
expect(result[0].isMissing).toBe(true)
})
})
describe('groupCandidatesByName', () => {
it('groups candidates with the same name', () => {
const candidates = [
makeCandidate('1', 'photo.png'),
makeCandidate('2', 'photo.png'),
makeCandidate('3', 'other.png')
]
const result = groupCandidatesByName(candidates)
expect(result).toHaveLength(2)
const photoGroup = result.find((g) => g.name === 'photo.png')
expect(photoGroup?.referencingNodes).toHaveLength(2)
expect(photoGroup?.mediaType).toBe('image')
const otherGroup = result.find((g) => g.name === 'other.png')
expect(otherGroup?.referencingNodes).toHaveLength(1)
})
it('returns empty array for empty input', () => {
expect(groupCandidatesByName([])).toEqual([])
})
})
describe('groupCandidatesByMediaType', () => {
it('groups by media type in order: image, video, audio', () => {
const candidates = [
makeCandidate('1', 'sound.mp3', {
nodeType: 'LoadAudio',
widgetName: 'audio',
mediaType: 'audio'
}),
makeCandidate('2', 'photo.png'),
makeCandidate('3', 'clip.mp4', {
nodeType: 'LoadVideo',
widgetName: 'file',
mediaType: 'video'
})
]
const result = groupCandidatesByMediaType(candidates)
expect(result).toHaveLength(3)
expect(result[0].mediaType).toBe('image')
expect(result[1].mediaType).toBe('video')
expect(result[2].mediaType).toBe('audio')
})
it('omits media types with no candidates', () => {
const candidates = [
makeCandidate('1', 'clip.mp4', {
nodeType: 'LoadVideo',
widgetName: 'file',
mediaType: 'video'
})
]
const result = groupCandidatesByMediaType(candidates)
expect(result).toHaveLength(1)
expect(result[0].mediaType).toBe('video')
})
it('groups multiple names within the same media type', () => {
const candidates = [
makeCandidate('1', 'a.png'),
makeCandidate('2', 'b.png'),
makeCandidate('3', 'a.png')
]
const result = groupCandidatesByMediaType(candidates)
expect(result).toHaveLength(1)
expect(result[0].mediaType).toBe('image')
expect(result[0].items).toHaveLength(2)
expect(
result[0].items.find((i) => i.name === 'a.png')?.referencingNodes
).toHaveLength(2)
})
})
describe('verifyCloudMediaCandidates', () => {
const existingHash =
'blake3:1111111111111111111111111111111111111111111111111111111111111111'
const missingHash =
'blake3:2222222222222222222222222222222222222222222222222222222222222222'
beforeEach(() => {
vi.clearAllMocks()
mockCheckAssetHash.mockResolvedValue('missing')
mockGetInputAssetsIncludingPublic.mockResolvedValue([])
})
it('marks candidates missing when the asset hash is not found', async () => {
const candidates = [
makeCandidate('1', missingHash, { isMissing: undefined }),
makeCandidate('2', existingHash, { isMissing: undefined })
]
const checkAssetHash = vi.fn(async (assetHash: string) =>
assetHash === existingHash ? ('exists' as const) : ('missing' as const)
)
await verifyCloudMediaCandidates(candidates, undefined, checkAssetHash)
expect(candidates[0].isMissing).toBe(true)
expect(candidates[1].isMissing).toBe(false)
})
it('uses assetService.checkAssetHash by default', async () => {
const candidates = [
makeCandidate('1', existingHash, { isMissing: undefined })
]
mockCheckAssetHash.mockResolvedValue('exists')
await verifyCloudMediaCandidates(candidates)
expect(candidates[0].isMissing).toBe(false)
expect(mockCheckAssetHash).toHaveBeenCalledWith(existingHash, undefined)
})
it('respects abort signal before execution', async () => {
const controller = new AbortController()
controller.abort()
const candidates = [
makeCandidate('1', missingHash, { isMissing: undefined })
]
await verifyCloudMediaCandidates(candidates, controller.signal)
expect(candidates[0].isMissing).toBeUndefined()
expect(mockCheckAssetHash).not.toHaveBeenCalled()
})
it('respects abort signal after hash verification', async () => {
const controller = new AbortController()
const candidates = [
makeCandidate('1', existingHash, { isMissing: undefined })
]
const checkAssetHash = vi.fn(async () => {
controller.abort()
return 'exists' as const
})
await verifyCloudMediaCandidates(
candidates,
controller.signal,
checkAssetHash
)
expect(candidates[0].isMissing).toBeUndefined()
})
it('skips candidates already resolved as true', async () => {
const candidates = [makeCandidate('1', missingHash, { isMissing: true })]
await verifyCloudMediaCandidates(candidates)
expect(candidates[0].isMissing).toBe(true)
expect(mockCheckAssetHash).not.toHaveBeenCalled()
})
it('skips candidates already resolved as false', async () => {
const candidates = [makeCandidate('1', existingHash, { isMissing: false })]
await verifyCloudMediaCandidates(candidates)
expect(candidates[0].isMissing).toBe(false)
expect(mockCheckAssetHash).not.toHaveBeenCalled()
})
it('skips entirely when no pending candidates', async () => {
const candidates = [makeCandidate('1', missingHash, { isMissing: true })]
await verifyCloudMediaCandidates(candidates)
expect(mockCheckAssetHash).not.toHaveBeenCalled()
})
it('falls back to input assets for non-blake3 candidate names', async () => {
const candidates = [
makeCandidate('1', 'photo.png', { isMissing: undefined }),
makeCandidate('2', 'missing.png', { isMissing: undefined })
]
const fetchInputAssets = vi.fn(async () => [
makeAsset('stored-photo.png', 'photo.png')
])
await verifyCloudMediaCandidates(
candidates,
undefined,
undefined,
fetchInputAssets
)
expect(mockCheckAssetHash).not.toHaveBeenCalled()
expect(fetchInputAssets).toHaveBeenCalledOnce()
expect(candidates[0].isMissing).toBe(false)
expect(candidates[1].isMissing).toBe(true)
})
it('uses public input assets for default legacy fallback', async () => {
const candidates = [
makeCandidate('1', 'public-photo.png', { isMissing: undefined })
]
const inputAssets = Array.from({ length: 500 }, (_, index) =>
makeAsset(`asset-${index}.png`)
)
inputAssets[42] = makeAsset('public-asset-record', 'public-photo.png')
mockGetInputAssetsIncludingPublic.mockResolvedValue(inputAssets)
await verifyCloudMediaCandidates(candidates)
expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(undefined)
expect(candidates[0].isMissing).toBe(false)
})
it('silences aborts while loading legacy fallback input assets', async () => {
const abortError = new Error('aborted')
abortError.name = 'AbortError'
const controller = new AbortController()
const candidates = [
makeCandidate('1', 'photo.png', { isMissing: undefined })
]
const fetchInputAssets = vi.fn(async () => {
controller.abort()
throw abortError
})
await expect(
verifyCloudMediaCandidates(
candidates,
controller.signal,
undefined,
fetchInputAssets
)
).resolves.toBeUndefined()
expect(candidates[0].isMissing).toBeUndefined()
})
it('silences aborts from the default legacy fallback input asset store path', async () => {
const abortError = new Error('aborted')
abortError.name = 'AbortError'
const controller = new AbortController()
const candidates = [
makeCandidate('1', 'photo.png', { isMissing: undefined })
]
mockGetInputAssetsIncludingPublic.mockImplementationOnce(async () => {
controller.abort()
throw abortError
})
await expect(
verifyCloudMediaCandidates(candidates, controller.signal)
).resolves.toBeUndefined()
expect(mockGetInputAssetsIncludingPublic).toHaveBeenCalledWith(
controller.signal
)
expect(candidates[0].isMissing).toBeUndefined()
})
it('falls back to input assets when the hash endpoint returns 400', async () => {
const candidates = [
makeCandidate('1', existingHash, { isMissing: undefined })
]
mockCheckAssetHash.mockResolvedValue('invalid')
const fetchInputAssets = vi.fn(async () => [
makeAsset('photo.png', existingHash)
])
await verifyCloudMediaCandidates(
candidates,
undefined,
undefined,
fetchInputAssets
)
expect(mockCheckAssetHash).toHaveBeenCalledWith(existingHash, undefined)
expect(fetchInputAssets).toHaveBeenCalledOnce()
expect(candidates[0].isMissing).toBe(false)
})
it('falls back to input assets when hash verification fails', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const candidates = [
makeCandidate('1', existingHash, { isMissing: undefined })
]
const checkAssetHash = vi.fn(async () => {
throw new Error('network failed')
})
const fetchInputAssets = vi.fn(async () => [
makeAsset('photo.png', existingHash)
])
await verifyCloudMediaCandidates(
candidates,
undefined,
checkAssetHash,
fetchInputAssets
)
expect(fetchInputAssets).toHaveBeenCalledOnce()
expect(candidates[0].isMissing).toBe(false)
expect(warn).toHaveBeenCalledOnce()
warn.mockRestore()
})
it('does not call the hash endpoint for malformed blake3-looking values', async () => {
const malformedHash = 'blake3:abc'
const candidates = [
makeCandidate('1', malformedHash, { isMissing: undefined })
]
const fetchInputAssets = vi.fn(async () => [
makeAsset('legacy.png', malformedHash)
])
await verifyCloudMediaCandidates(
candidates,
undefined,
undefined,
fetchInputAssets
)
expect(mockCheckAssetHash).not.toHaveBeenCalled()
expect(fetchInputAssets).toHaveBeenCalledOnce()
expect(candidates[0].isMissing).toBe(false)
})
it('deduplicates checks for repeated candidate names', async () => {
const candidates = [
makeCandidate('1', missingHash, { isMissing: undefined }),
makeCandidate('2', missingHash, { isMissing: undefined })
]
await verifyCloudMediaCandidates(candidates)
expect(mockCheckAssetHash).toHaveBeenCalledOnce()
expect(candidates[0].isMissing).toBe(true)
expect(candidates[1].isMissing).toBe(true)
})
})