mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-14 01:36:14 +00:00
Follow-up to #10856. Four correctness issues and their regression tests. ## Bugs fixed ### 1. ErrorOverlay model count reflected node selection `useErrorGroups` exposed `filteredMissingModelGroups` under the public name `missingModelGroups`. `ErrorOverlay.vue` read that alias to compute its model count label, so selecting a node shrank the overlay total. The overlay must always show the whole workflow's errors. Exposed both shapes explicitly: `missingModelGroups` / `missingMediaGroups` (unfiltered totals) and `filteredMissingModelGroups` / `filteredMissingMediaGroups` (selection-scoped). `TabErrors.vue` destructures the filtered variant with an alias. Before https://github.com/user-attachments/assets/eb848c5f-d092-4a4f-b86f-d22bb4408003 After https://github.com/user-attachments/assets/75e67819-c9f2-45ec-9241-74023eca6120 ### 2. Bypass → un-bypass dropped url/hash metadata Realtime `scanNodeModelCandidates` only reads widget values, so un-bypass produced a fresh candidate without the url that `enrichWithEmbeddedMetadata` had previously attached from `graphData.models`. `MissingModelRow`'s download/copy-url buttons disappeared after a bypass/un-bypass cycle. Added `enrichCandidateFromNodeProperties` that copies `url`/`hash`/`directory` from the node's own `properties.models` — which persists across mode toggles — into each scanned candidate. Applied to every call site of the per-node scan. A later fix in the same branch also enforces directory agreement to prevent a same-name / different-directory collision from stamping the wrong metadata. Before https://github.com/user-attachments/assets/39039d83-4d55-41a9-9d01-dec40843741b After https://github.com/user-attachments/assets/047a603b-fb52-4320-886d-dfeed457d833 ### 3. Initial full scan surfaced interior errors of a muted/bypassed subgraph container `scanAllModelCandidates`, `scanAllMediaCandidates`, and the JSON-based missing-node scan only check each node's own mode. Interior nodes whose parent container was bypassed passed the filter. Added `isAncestorPathActive(rootGraph, executionId)` to `graphTraversalUtil` and post-filter the three pipelines in `app.ts` after the live rootGraph is configured. The filter uses the execution-ID path (`"65:63"` → check node 65's mode) so it handles both live-scan-produced and JSON-enrichment-produced candidates. Before https://github.com/user-attachments/assets/3032d46b-81cd-420e-ab8e-f58392267602 After https://github.com/user-attachments/assets/02a01931-951d-4a48-986c-06424044fbf8 ### 4. Bypassed subgraph entry re-surfaced interior errors `useGraphNodeManager` replays `graph.onNodeAdded` for each existing interior node when the Vue node manager initializes on subgraph entry. That chain reached `scanSingleNodeErrors` via `installErrorClearingHooks`' `onNodeAdded` override. Each interior node's own mode was active, so the caller guards passed and the scan re-introduced the error that the initial pipeline had correctly suppressed. Added an ancestor-activity gate at the top of `scanSingleNodeErrors`, the single entry point shared by paste, un-bypass, subgraph entry, and subgraph container activation. A later commit also hardens this guard against detached nodes (null execution ID → skip) and applies the same ancestor check to `isCandidateStillActive` in the realtime verification callback. Before https://github.com/user-attachments/assets/fe44862d-f1d6-41ed-982d-614a7e83d441 After https://github.com/user-attachments/assets/497a76ce-3caa-479f-9024-4cd0f7bd20a4 ## Tests - 6 unit tests for `isAncestorPathActive` (root, active, immediate-bypass, deep-nested mute, unresolvable ancestor, null rootGraph) - 4 unit tests for `enrichCandidateFromNodeProperties` (enrichment, no-overwrite, name mismatch, directory mismatch) - 1 unit test for `scanSingleNodeErrors` ancestor guard (subgraph entry replaying onNodeAdded) - 2 unit tests for `useErrorGroups` dual export + ErrorOverlay contract - 4 E2E tests: - ErrorOverlay model count stays constant when a node is selected (new fixture `missing_models_distinct.json`) - Bypass/un-bypass cycle preserves Copy URL button (uses `missing_models_from_node_properties`) - Loading a workflow with bypassed subgraph suppresses interior missing model error (new fixture `missing_models_in_bypassed_subgraph.json`) - Entering a bypassed subgraph does not resurface interior missing model error (shares the above fixture) `pnpm typecheck`, `pnpm lint`, 206 related unit tests passing. ## Follow-up Several items raised by code review are deferred as pre-existing tech debt or scope-avoided refactors. Tracked via comments on #11215 and #11216. --- Follows up on #10856.
This commit is contained in:
@@ -15,6 +15,8 @@ import {
|
||||
verifyAssetSupportedCandidates,
|
||||
MODEL_FILE_EXTENSIONS
|
||||
} from '@/platform/missingModel/missingModelScan'
|
||||
import activeSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/activeSubgraphUnmatchedModel.json' with { type: 'json' }
|
||||
import bypassedSubgraphUnmatchedModel from '@/platform/missingModel/__fixtures__/bypassedSubgraphUnmatchedModel.json' with { type: 'json' }
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
@@ -156,6 +158,134 @@ describe('scanNodeModelCandidates', () => {
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('enriches candidates with url/hash/directory from node.properties.models', () => {
|
||||
// Regression: bypass/un-bypass cycle previously lost url metadata
|
||||
// because realtime scan only reads widget values. Per-node embedded
|
||||
// metadata in `properties.models` persists across mode toggles, so
|
||||
// the scan now enriches candidates from that source.
|
||||
const graph = makeGraph([])
|
||||
const node = fromAny<LGraphNode, unknown>({
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
widgets: [
|
||||
makeComboWidget('ckpt_name', 'missing_model.safetensors', [
|
||||
'other_model.safetensors'
|
||||
])
|
||||
],
|
||||
properties: {
|
||||
models: [
|
||||
{
|
||||
name: 'missing_model.safetensors',
|
||||
url: 'https://example.com/missing_model',
|
||||
directory: 'checkpoints',
|
||||
hash: 'abc123',
|
||||
hash_type: 'sha256'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const result = scanNodeModelCandidates(graph, node, noAssetSupport)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].url).toBe('https://example.com/missing_model')
|
||||
expect(result[0].directory).toBe('checkpoints')
|
||||
expect(result[0].hash).toBe('abc123')
|
||||
expect(result[0].hashType).toBe('sha256')
|
||||
})
|
||||
|
||||
it('preserves existing candidate fields when enriching (no overwrite)', () => {
|
||||
const graph = makeGraph([])
|
||||
const node = fromAny<LGraphNode, unknown>({
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
widgets: [makeComboWidget('ckpt_name', 'missing_model.safetensors', [])],
|
||||
properties: {
|
||||
models: [
|
||||
{
|
||||
name: 'missing_model.safetensors',
|
||||
url: 'https://example.com/new_url',
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const result = scanNodeModelCandidates(
|
||||
graph,
|
||||
node,
|
||||
noAssetSupport,
|
||||
() => 'checkpoints'
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
// scanComboWidget already sets directory via getDirectory; enrichment
|
||||
// does not overwrite it.
|
||||
expect(result[0].directory).toBe('checkpoints')
|
||||
// url was not set by scan, so enrichment fills it in.
|
||||
expect(result[0].url).toBe('https://example.com/new_url')
|
||||
})
|
||||
|
||||
it('skips enrichment when candidate and embedded model directories differ', () => {
|
||||
// A node can list the same model name under multiple directories
|
||||
// (e.g. a LoRA present in both `loras` and `loras/subdir`). Name-only
|
||||
// matching would stamp the wrong url/hash onto the candidate, so
|
||||
// enrichment must agree on directory when the candidate already has
|
||||
// one.
|
||||
const graph = makeGraph([])
|
||||
const node = fromAny<LGraphNode, unknown>({
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
widgets: [
|
||||
makeComboWidget('ckpt_name', 'collision_model.safetensors', [])
|
||||
],
|
||||
properties: {
|
||||
models: [
|
||||
{
|
||||
name: 'collision_model.safetensors',
|
||||
url: 'https://example.com/wrong_dir_url',
|
||||
directory: 'wrong_dir'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const result = scanNodeModelCandidates(
|
||||
graph,
|
||||
node,
|
||||
noAssetSupport,
|
||||
() => 'checkpoints'
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].directory).toBe('checkpoints')
|
||||
// Directory mismatch — enrichment should not stamp the wrong url.
|
||||
expect(result[0].url).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not enrich candidates with mismatched model names', () => {
|
||||
const graph = makeGraph([])
|
||||
const node = fromAny<LGraphNode, unknown>({
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
widgets: [makeComboWidget('ckpt_name', 'missing_model.safetensors', [])],
|
||||
properties: {
|
||||
models: [
|
||||
{
|
||||
name: 'different_model.safetensors',
|
||||
url: 'https://example.com/different',
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const result = scanNodeModelCandidates(graph, node, noAssetSupport)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].url).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('scanAllModelCandidates', () => {
|
||||
@@ -925,6 +1055,86 @@ describe('enrichWithEmbeddedMetadata', () => {
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('drops workflow-level entries when only reference is in a bypassed subgraph interior', async () => {
|
||||
// Interior properties.models references the workflow-level model
|
||||
// but its widget value does not — forcing the workflow-level entry
|
||||
// down the unmatched path where isModelReferencedByActiveNode
|
||||
// decides. Previously the helper ignored the bypassed container.
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
[],
|
||||
fromAny<ComfyWorkflowJSON, unknown>(bypassedSubgraphUnmatchedModel),
|
||||
alwaysMissing
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps workflow-level entries when reference is in an active subgraph interior', async () => {
|
||||
// Positive control for the bypassed case above: identical fixture
|
||||
// with container mode=0 must still surface the unmatched workflow-
|
||||
// level model. Guards against a regression where the ancestor gate
|
||||
// drops every workflow-level entry regardless of context.
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
[],
|
||||
fromAny<ComfyWorkflowJSON, unknown>(activeSubgraphUnmatchedModel),
|
||||
alwaysMissing
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toBe('rare_model.safetensors')
|
||||
})
|
||||
|
||||
it('drops workflow-level entries when interior reference is under a different directory', async () => {
|
||||
// Same name, different directory: the interior's properties.models
|
||||
// entry is not the same asset as the workflow-level entry, so the
|
||||
// fallback helper must not treat it as a reference that keeps the
|
||||
// workflow-level model alive.
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
properties: {
|
||||
models: [
|
||||
{
|
||||
name: 'collide_model.safetensors',
|
||||
directory: 'loras'
|
||||
}
|
||||
]
|
||||
},
|
||||
widgets_values: ['unrelated_widget.safetensors']
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4,
|
||||
models: [
|
||||
{
|
||||
name: 'collide_model.safetensors',
|
||||
url: 'https://example.com/collide',
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
[],
|
||||
graphData,
|
||||
alwaysMissing
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('OSS missing model detection (non-Cloud path)', () => {
|
||||
|
||||
Reference in New Issue
Block a user