Files
ComfyUI_frontend/src/platform/missingModel/components/MissingModelCard.test.ts
jaeone94 2f7f3c4e56 [feat] Surface missing models in Errors tab (Cloud) (#9743)
## Summary
When a workflow is loaded with missing models, users currently have no
way to identify or resolve them from within the UI. This PR adds a full
missing-model detection and resolution pipeline that surfaces missing
models in the Errors tab, allowing users to install or import them
without leaving the editor.

## Changes

### Missing Model Detection
- Scan all COMBO widgets across root graph and subgraphs for model-like
filenames during workflow load
- Enrich candidates with embedded workflow metadata (url, hash,
directory) when available
- Verify asset-supported candidates against the asset store
asynchronously to confirm installation status
- Propagate missing model state to `executionErrorStore` alongside
existing node/prompt errors

### Errors Tab UI — Model Resolution
- Group missing models by directory (e.g. `checkpoints`, `loras`, `vae`)
with collapsible category cards
- Each model row displays:
  - Model name with copy-to-clipboard button
  - Expandable list of referencing nodes with locate-on-canvas button
- **Library selector**: Pick an alternative from the user's existing
models to substitute the missing model with one click
- **URL import**: Paste a Civitai or HuggingFace URL to import a model
directly; debounced metadata fetch shows filename and file size before
confirming; type-mismatch warnings (e.g. importing a LoRA into
checkpoints directory) are surfaced with an "Import Anyway" option
- **Upgrade prompt**: In cloud environment, free-tier subscribers are
shown an upgrade modal when attempting URL import
- Separate "Import Not Supported" section for custom-node models that
cannot be auto-resolved
- Status card with live download progress, completion, failure, and
category-mismatch states

### Canvas Integration
- Highlight nodes and widgets that reference missing models with error
indicators
- Propagate missing-model badges through subgraph containers so issues
are visible at every graph level

### Code Cleanup
- Simplify `surfacePendingWarnings` in workflowService, remove stale
widget-detected model merging logic
- Add `flattenWorkflowNodes` utility to workflowSchema for traversing
nested subgraph structures
- Extract `MissingModelUrlInput`, `MissingModelLibrarySelect`,
`MissingModelStatusCard` as focused single-responsibility components

## Testing
- Unit tests for scan pipeline (`missingModelScan.test.ts`): enrichment,
skip-installed, subgraph flattening
- Unit tests for store (`missingModelStore.test.ts`): state management,
removal helpers
- Unit tests for interactions (`useMissingModelInteractions.test.ts`):
combo select, URL input, import flow, library confirm
- Component tests for `MissingModelCard` and error grouping
(`useErrorGroups.test.ts`)
- Updated `workflowService.test.ts` and `workflowSchema.test.ts` for new
logic

## Review Focus
- Missing model scan + enrichment pipeline in `missingModelScan.ts`
- Interaction composable `useMissingModelInteractions.ts` — URL metadata
fetch, library install, upload fallback
- Store integration and canvas-level error propagation

## Screenshots 


https://github.com/user-attachments/assets/339a6d5b-93a3-43cd-98dd-0fb00681b66f



┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9743-feat-Surface-missing-models-in-Errors-tab-Cloud-3206d73d365081678326d3a16c2165d8)
by [Unito](https://www.unito.io)
2026-03-12 16:21:54 +09:00

194 lines
5.4 KiB
TypeScript

import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type {
MissingModelGroup,
MissingModelViewModel
} from '@/platform/missingModel/types'
vi.mock('./MissingModelRow.vue', () => ({
default: {
name: 'MissingModelRow',
template: '<div class="model-row" />',
props: ['model', 'directory', 'showNodeIdBadge', 'isAssetSupported'],
emits: ['locate-model']
}
}))
import MissingModelCard from './MissingModelCard.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
rightSidePanel: {
missingModels: {
importNotSupported: 'Import Not Supported',
customNodeDownloadDisabled:
'Cloud environment does not support model imports for custom nodes.',
unknownCategory: 'Unknown Category'
}
}
}
},
missingWarn: false,
fallbackWarn: false
})
function makeViewModel(
name: string,
nodeId: string = '1'
): MissingModelViewModel {
return {
name,
representative: {
name,
nodeId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: true,
isMissing: true
},
referencingNodes: [{ nodeId, widgetName: 'ckpt_name' }]
}
}
function makeGroup(
opts: {
directory?: string | null
isAssetSupported?: boolean
modelNames?: string[]
} = {}
): MissingModelGroup {
const names = opts.modelNames ?? ['model.safetensors']
return {
directory: 'directory' in opts ? (opts.directory ?? null) : 'checkpoints',
isAssetSupported: opts.isAssetSupported ?? true,
models: names.map((n, i) => makeViewModel(n, String(i + 1)))
}
}
function mountCard(
props: Partial<{
missingModelGroups: MissingModelGroup[]
showNodeIdBadge: boolean
}> = {}
) {
return mount(MissingModelCard, {
props: {
missingModelGroups: [makeGroup()],
showNodeIdBadge: false,
...props
},
global: {
plugins: [PrimeVue, i18n]
}
})
}
describe('MissingModelCard', () => {
describe('Rendering & Props', () => {
it('renders directory name in category header', () => {
const wrapper = mountCard({
missingModelGroups: [makeGroup({ directory: 'loras' })]
})
expect(wrapper.text()).toContain('loras')
})
it('renders translated unknown category when directory is null', () => {
const wrapper = mountCard({
missingModelGroups: [makeGroup({ directory: null })]
})
expect(wrapper.text()).toContain('Unknown Category')
})
it('renders model count in category header', () => {
const wrapper = mountCard({
missingModelGroups: [
makeGroup({ modelNames: ['a.safetensors', 'b.safetensors'] })
]
})
expect(wrapper.text()).toContain('(2)')
})
it('renders correct number of MissingModelRow components', () => {
const wrapper = mountCard({
missingModelGroups: [
makeGroup({
modelNames: ['a.safetensors', 'b.safetensors', 'c.safetensors']
})
]
})
expect(
wrapper.findAllComponents({ name: 'MissingModelRow' })
).toHaveLength(3)
})
it('renders multiple groups', () => {
const wrapper = mountCard({
missingModelGroups: [
makeGroup({ directory: 'checkpoints' }),
makeGroup({ directory: 'loras' })
]
})
expect(wrapper.text()).toContain('checkpoints')
expect(wrapper.text()).toContain('loras')
})
it('renders zero rows when missingModelGroups is empty', () => {
const wrapper = mountCard({ missingModelGroups: [] })
expect(
wrapper.findAllComponents({ name: 'MissingModelRow' })
).toHaveLength(0)
})
it('passes props correctly to MissingModelRow children', () => {
const wrapper = mountCard({ showNodeIdBadge: true })
const row = wrapper.findComponent({ name: 'MissingModelRow' })
expect(row.props('showNodeIdBadge')).toBe(true)
expect(row.props('isAssetSupported')).toBe(true)
expect(row.props('directory')).toBe('checkpoints')
})
})
describe('Asset Unsupported Group', () => {
it('shows "Import Not Supported" header for unsupported groups', () => {
const wrapper = mountCard({
missingModelGroups: [makeGroup({ isAssetSupported: false })]
})
expect(wrapper.text()).toContain('Import Not Supported')
})
it('shows info notice for unsupported groups', () => {
const wrapper = mountCard({
missingModelGroups: [makeGroup({ isAssetSupported: false })]
})
expect(wrapper.text()).toContain(
'Cloud environment does not support model imports'
)
})
it('hides info notice for supported groups', () => {
const wrapper = mountCard({
missingModelGroups: [makeGroup({ isAssetSupported: true })]
})
expect(wrapper.text()).not.toContain(
'Cloud environment does not support model imports'
)
})
})
describe('Event Handling', () => {
it('emits locateModel when child emits locate-model', async () => {
const wrapper = mountCard()
const row = wrapper.findComponent({ name: 'MissingModelRow' })
await row.vm.$emit('locate-model', '42')
expect(wrapper.emitted('locateModel')).toBeTruthy()
expect(wrapper.emitted('locateModel')?.[0]).toEqual(['42'])
})
})
})