[backport cloud/1.43] fix: exclude muted/bypassed nodes from missing asset detection (#10856)

Cherry-pick of 521019d17 onto cloud/1.43.

Manual conflict resolution on two files (same root cause as the
core/1.43 backport), both limited to the `cleanupFakeModel` helper
extraction:
- browser_tests/tests/errorOverlay.spec.ts
- browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts

cloud/1.43 base still had the inline `expect(cleanupOk).toBeTruthy()`
implementation; #10856 replaced both sites with `await cleanupFakeModel(comfyPage)`
calling the new helper in ErrorsTabHelper.ts. Conflict resolved by
accepting the PR version (helper call); the helper itself is added
in this same commit.
This commit is contained in:
jaeone94
2026-04-13 21:51:19 +09:00
committed by jaeone94
parent 8dd3ee072e
commit 26c1fbbf1d
45 changed files with 3588 additions and 201 deletions

View File

@@ -0,0 +1,34 @@
{
"last_node_id": 10,
"last_link_id": 0,
"nodes": [
{
"id": 10,
"type": "LoadImage",
"pos": [50, 200],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 4,
"inputs": [],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": null },
{ "name": "MASK", "type": "MASK", "links": null }
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["nonexistent_test_image_12345.png", "image"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -5,7 +5,7 @@
{
"id": 10,
"type": "LoadImage",
"pos": [50, 50],
"pos": [50, 200],
"size": [315, 314],
"flags": {},
"order": 0,
@@ -31,7 +31,7 @@
{
"id": 11,
"type": "LoadImage",
"pos": [450, 50],
"pos": [450, 200],
"size": [315, 314],
"flags": {},
"order": 1,

View File

@@ -5,7 +5,7 @@
{
"id": 10,
"type": "LoadImage",
"pos": [50, 50],
"pos": [50, 200],
"size": [315, 314],
"flags": {},
"order": 0,

View File

@@ -1,7 +1,27 @@
{
"last_node_id": 0,
"last_node_id": 1,
"last_link_id": 0,
"nodes": [],
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [256, 256],
"size": [315, 98],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": null },
{ "name": "CLIP", "type": "CLIP", "links": null },
{ "name": "VAE", "type": "VAE", "links": null }
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["fake_model.safetensors"]
}
],
"links": [],
"groups": [],
"config": {},
@@ -15,7 +35,7 @@
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "text_encoders"
"directory": "checkpoints"
}
],
"version": 0.4

View File

@@ -0,0 +1,42 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [256, 256],
"size": [315, 98],
"flags": {},
"order": 0,
"mode": 4,
"inputs": [],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": null },
{ "name": "CLIP", "type": "CLIP", "links": null },
{ "name": "VAE", "type": "VAE", "links": null }
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["fake_model.safetensors"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"models": [
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
}
],
"version": 0.4
}

View File

@@ -0,0 +1,141 @@
{
"id": "test-missing-models-in-subgraph",
"revision": 0,
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "KSampler",
"pos": [100, 100],
"size": [270, 262],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [] }],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
},
{
"id": 2,
"type": "subgraph-with-missing-model",
"pos": [450, 100],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [{ "name": "model", "type": "MODEL", "link": null }],
"outputs": [{ "name": "MODEL", "type": "MODEL", "links": null }],
"properties": {},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "subgraph-with-missing-model",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 2,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Subgraph with Missing Model",
"inputNode": {
"id": -10,
"bounding": [100, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [500, 200, 120, 60]
},
"inputs": [
{
"id": "input1-id",
"name": "model",
"type": "MODEL",
"linkIds": [1],
"pos": { "0": 150, "1": 220 }
}
],
"outputs": [
{
"id": "output1-id",
"name": "MODEL",
"type": "MODEL",
"linkIds": [2],
"pos": { "0": 520, "1": 220 }
}
],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [250, 180],
"size": [315, 98],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": [2] },
{ "name": "CLIP", "type": "CLIP", "links": null },
{ "name": "VAE", "type": "VAE", "links": null }
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["fake_model.safetensors"]
}
],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 2,
"origin_id": 1,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "MODEL"
}
]
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"models": [
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
}
],
"version": 0.4
}

View File

@@ -78,7 +78,7 @@
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "text_encoders"
"directory": "checkpoints"
}
],
"version": 0.4

View File

@@ -79,7 +79,8 @@ export const TestIds = {
bookmarksSection: 'node-library-bookmarks-section'
},
propertiesPanel: {
root: 'properties-panel'
root: 'properties-panel',
errorsTab: 'panel-tab-errors'
},
subgraphEditor: {
toggle: 'subgraph-editor-toggle',

View File

@@ -5,6 +5,7 @@ import {
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { cleanupFakeModel } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
test.describe('Error overlay', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -47,11 +48,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
test('Should display "Show missing models" button for missing model errors', async ({
comfyPage
}) => {
const cleanupOk = await comfyPage.page.evaluate(async (url: string) => {
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`)
return response.ok
}, comfyPage.url)
expect(cleanupOk).toBeTruthy()
await cleanupFakeModel(comfyPage)
await comfyPage.workflow.loadWorkflow('missing/missing_models')
@@ -95,7 +92,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
.click()
await expect(errorOverlay).not.toBeVisible()
await expect(errorOverlay).toBeHidden()
await comfyPage.canvas.click()
await comfyPage.nextFrame()
@@ -107,10 +104,37 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await comfyPage.nextFrame()
await comfyPage.keyboard.undo()
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
await expect(errorOverlay).toBeHidden()
await comfyPage.keyboard.redo()
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
await expect(errorOverlay).toBeHidden()
})
test('Does not resurface error overlay when switching back to workflow with missing nodes', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
await comfyPage.menu.workflowsTab.open()
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
const errorOverlay = getOverlay(comfyPage.page)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
.click()
await expect(errorOverlay).toBeHidden()
await comfyPage.menu.workflowsTab.open()
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')
await expect(errorOverlay).toBeHidden()
})
})
@@ -151,6 +175,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(overlay).toBeHidden()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
})
@@ -162,7 +187,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(overlay).not.toBeVisible()
await expect(overlay).toBeHidden()
})
test('"Dismiss" closes overlay without opening panel', async ({
@@ -175,10 +200,8 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await overlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
await expect(overlay).not.toBeVisible()
await expect(
comfyPage.page.getByTestId('properties-panel')
).not.toBeVisible()
await expect(overlay).toBeHidden()
await expect(comfyPage.page.getByTestId('properties-panel')).toBeHidden()
})
test('Close button (X) dismisses overlay', async ({ comfyPage }) => {
@@ -189,7 +212,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
await overlay.getByRole('button', { name: /close/i }).click()
await expect(overlay).not.toBeVisible()
await expect(overlay).toBeHidden()
})
})
})

View File

@@ -2,8 +2,9 @@ import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
export async function openErrorsTabViaSeeErrors(
export async function loadWorkflowAndOpenErrorsTab(
comfyPage: ComfyPage,
workflow: string
) {
@@ -15,3 +16,30 @@ export async function openErrorsTabViaSeeErrors(
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(errorOverlay).not.toBeVisible()
}
export async function openErrorsTab(comfyPage: ComfyPage) {
const panel = new PropertiesPanelHelper(comfyPage.page)
await panel.open(comfyPage.actionbar.propertiesButton)
const errorsTab = comfyPage.page.getByTestId(
TestIds.propertiesPanel.errorsTab
)
await expect(errorsTab).toBeVisible()
await errorsTab.click()
}
/**
* Remove the fake model file from the backend so it is detected as missing.
* Fixture URLs (e.g. http://localhost:8188/...) are not actually downloaded
* during tests — they only serve as metadata for the missing model UI.
*/
export async function cleanupFakeModel(comfyPage: ComfyPage) {
await expect
.poll(() =>
comfyPage.page.evaluate(async (url: string) => {
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`)
return response.ok
}, comfyPage.url)
)
.toBeTruthy()
}

View File

@@ -3,7 +3,7 @@ import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { openErrorsTabViaSeeErrors } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
const dropzone = comfyPage.page.getByTestId(
@@ -47,7 +47,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test.describe('Detection', () => {
test('Shows missing media group in errors tab', async ({ comfyPage }) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
@@ -57,7 +60,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test('Shows correct number of missing media rows', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_multiple'
)
@@ -68,7 +71,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test('Shows upload dropzone and library select for each missing item', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await expect(getDropzone(comfyPage)).toBeVisible()
await expect(
@@ -81,7 +87,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test('Upload via file picker shows status card then allows confirm', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
@@ -95,7 +104,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test('Selecting from library shows status card then allows confirm', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
const librarySelect = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaLibrarySelect
@@ -121,7 +133,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test('Cancelling pending selection returns to upload/library UI', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
@@ -140,7 +155,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test('Missing Inputs group disappears when all items are resolved', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await uploadFileViaDropzone(comfyPage)
await confirmPendingSelection(comfyPage)
@@ -154,7 +172,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
test('Locate button navigates canvas to the missing media node', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
const offsetBefore = await comfyPage.page.evaluate(() => {
const canvas = window['app']?.canvas

View File

@@ -6,7 +6,10 @@ import {
interceptClipboardWrite,
getClipboardText
} from '@e2e/helpers/clipboardSpy'
import { openErrorsTabViaSeeErrors } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
import {
cleanupFakeModel,
loadWorkflowAndOpenErrorsTab
} from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -15,17 +18,13 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
const cleanupOk = await comfyPage.page.evaluate(async (url: string) => {
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`)
return response.ok
}, comfyPage.url)
expect(cleanupOk).toBeTruthy()
await cleanupFakeModel(comfyPage)
})
test('Should show missing models group in errors tab', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
@@ -35,7 +34,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
test('Should display model name with referencing node count', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const modelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
@@ -46,7 +45,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
test('Should expand model row to show referencing nodes', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_models_with_nodes'
)
@@ -54,7 +53,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
const locateButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelLocate
)
await expect(locateButton.first()).not.toBeVisible()
await expect(locateButton.first()).toBeHidden()
const expandButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelExpand
@@ -66,14 +65,14 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
})
test('Should copy model name to clipboard', async ({ comfyPage }) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
await interceptClipboardWrite(comfyPage.page)
const copyButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyName
)
await expect(copyButton.first()).toBeVisible()
await copyButton.first().click()
await copyButton.first().dispatchEvent('click')
const copiedText = await getClipboardText(comfyPage.page)
expect(copiedText).toContain('fake_model.safetensors')
@@ -83,7 +82,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
test('Should show Copy URL button for non-asset models', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const copyUrlButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelCopyUrl
@@ -94,7 +93,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
test('Should show Download button for downloadable models', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const downloadButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelDownload

View File

@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { openErrorsTabViaSeeErrors } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -14,7 +14,7 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
})
test('Should show MissingNodeCard in errors tab', async ({ comfyPage }) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_nodes')
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
@@ -22,7 +22,7 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
})
test('Should show missing node packs group', async ({ comfyPage }) => {
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_nodes')
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodePacksGroup)
@@ -32,7 +32,7 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
test('Should expand pack group to reveal node type names', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_nodes_in_subgraph'
)
@@ -52,7 +52,7 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
})
test('Should collapse expanded pack group', async ({ comfyPage }) => {
await openErrorsTabViaSeeErrors(
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_nodes_in_subgraph'
)
@@ -80,7 +80,7 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
test('Locate node button is visible for expanded pack nodes', async ({
comfyPage
}) => {
await openErrorsTabViaSeeErrors(
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_nodes_in_subgraph'
)

View File

@@ -0,0 +1,519 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import {
cleanupFakeModel,
openErrorsTab,
loadWorkflowAndOpenErrorsTab
} from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
})
test.describe('Missing nodes', () => {
test('Deleting a missing node removes its error from the errors tab', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodePacksGroup
)
await expect(missingNodeGroup).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.delete()
await expect(missingNodeGroup).toBeHidden()
})
test('Undo after bypass restores error without showing overlay', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodePacksGroup
)
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(missingNodeGroup).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => node.isBypassed()).toBeTruthy()
await expect(missingNodeGroup).toBeHidden()
await comfyPage.keyboard.undo()
await expect.poll(() => node.isBypassed()).toBeFalsy()
await expect(errorOverlay).toBeHidden()
await openErrorsTab(comfyPage)
await expect(missingNodeGroup).toBeVisible()
await comfyPage.keyboard.redo()
await expect.poll(() => node.isBypassed()).toBeTruthy()
await expect(missingNodeGroup).toBeHidden()
})
})
test.describe('Missing models', () => {
test.beforeEach(async ({ comfyPage }) => {
await cleanupFakeModel(comfyPage)
})
test.afterEach(async ({ comfyPage }) => {
await cleanupFakeModel(comfyPage)
})
test('Loading a workflow with all nodes bypassed shows no errors', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_models_bypassed')
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeHidden()
await comfyPage.actionbar.propertiesButton.click()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeHidden()
})
test('Bypassing a node hides its error, un-bypassing restores it', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => node.isBypassed()).toBeTruthy()
await expect(missingModelGroup).toBeHidden()
await node.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => node.isBypassed()).toBeFalsy()
await openErrorsTab(comfyPage)
await expect(missingModelGroup).toBeVisible()
})
test('Pasting a node with missing model increases referencing node count', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toBeVisible()
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(1\)/
)
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
await comfyPage.clipboard.copy()
await comfyPage.clipboard.paste()
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
await comfyPage.canvas.click()
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(2\)/
)
})
test('Pasting a bypassed node does not add a new error', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => node.isBypassed()).toBeTruthy()
await expect(missingModelGroup).toBeHidden()
await comfyPage.clipboard.copy()
await comfyPage.clipboard.paste()
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
await expect(missingModelGroup).toBeHidden()
})
test('Deleting a node with missing model removes its error', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.delete()
await expect(missingModelGroup).toBeHidden()
})
test('Undo after bypass restores error without showing overlay', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(missingModelGroup).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => node.isBypassed()).toBeTruthy()
await expect(missingModelGroup).toBeHidden()
await comfyPage.keyboard.undo()
await expect.poll(() => node.isBypassed()).toBeFalsy()
await expect(errorOverlay).toBeHidden()
await openErrorsTab(comfyPage)
await expect(missingModelGroup).toBeVisible()
await comfyPage.keyboard.redo()
await expect.poll(() => node.isBypassed()).toBeTruthy()
await expect(missingModelGroup).toBeHidden()
})
test('Selecting a node filters errors tab to only that node', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_models_with_nodes'
)
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toContainText(/\(2\)/)
const node1 = await comfyPage.nodeOps.getNodeRefById('1')
await node1.click('title')
await expect(missingModelGroup).toContainText(/\(1\)/)
await comfyPage.canvas.click()
await expect(missingModelGroup).toContainText(/\(2\)/)
})
})
test.describe('Missing media', () => {
test('Loading a workflow with all nodes bypassed shows no errors', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_media_bypassed')
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeHidden()
await comfyPage.actionbar.propertiesButton.click()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeHidden()
})
test('Bypassing a node hides its error, un-bypassing restores it', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
const missingMediaGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaGroup
)
await expect(missingMediaGroup).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('10')
await node.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => node.isBypassed()).toBeTruthy()
await expect(missingMediaGroup).toBeHidden()
await node.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => node.isBypassed()).toBeFalsy()
await openErrorsTab(comfyPage)
await expect(missingMediaGroup).toBeVisible()
})
test('Pasting a bypassed node does not add a new error', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
const missingMediaGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaGroup
)
const node = await comfyPage.nodeOps.getNodeRefById('10')
await node.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => node.isBypassed()).toBeTruthy()
await expect(missingMediaGroup).toBeHidden()
await comfyPage.clipboard.copy()
await comfyPage.clipboard.paste()
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
await expect(missingMediaGroup).toBeHidden()
})
test('Selecting a node filters errors tab to only that node', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_media_multiple')
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
.click()
const mediaRows = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaRow
)
await openErrorsTab(comfyPage)
await expect(mediaRows).toHaveCount(2)
const node = await comfyPage.nodeOps.getNodeRefById('10')
await node.click('title')
await expect(mediaRows).toHaveCount(1)
await comfyPage.canvas.click({ position: { x: 400, y: 600 } })
await expect(mediaRows).toHaveCount(2)
})
})
test.describe('Subgraph', () => {
test.beforeEach(async ({ comfyPage }) => {
await cleanupFakeModel(comfyPage)
})
test.afterEach(async ({ comfyPage }) => {
await cleanupFakeModel(comfyPage)
})
test('Bypassing a subgraph hides interior errors, un-bypassing restores them', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'missing/missing_models_in_subgraph'
)
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
.click()
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
const errorsTab = comfyPage.page.getByTestId(
TestIds.propertiesPanel.errorsTab
)
await comfyPage.keyboard.selectAll()
await comfyPage.keyboard.bypass()
await expect.poll(() => subgraphNode.isBypassed()).toBeTruthy()
await comfyPage.actionbar.propertiesButton.click()
await expect(errorsTab).toBeHidden()
await comfyPage.keyboard.selectAll()
await comfyPage.keyboard.bypass()
await expect.poll(() => subgraphNode.isBypassed()).toBeFalsy()
await openErrorsTab(comfyPage)
await expect(missingModelGroup).toBeVisible()
})
test('Deleting a node inside a subgraph removes its missing model error', async ({
comfyPage
}) => {
// Regression: before the execId fix, onNodeRemoved fell back to the
// interior node's local id (e.g. "1") when node.graph was already
// null, so the error keyed under "2:1" was never removed.
await comfyPage.workflow.loadWorkflow(
'missing/missing_models_in_subgraph'
)
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
.click()
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await openErrorsTab(comfyPage)
await expect(missingModelGroup).toBeVisible()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
// Select-all + Delete: interior node IDs may be reassigned during
// subgraph configure when they collide with root-graph IDs, so
// looking up by static id can fail.
await comfyPage.keyboard.selectAll()
await comfyPage.page.keyboard.press('Delete')
await expect(missingModelGroup).toBeHidden()
})
test('Deleting a node inside a subgraph removes its missing node-type error', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes_in_subgraph')
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
.click()
const missingNodeGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodePacksGroup
)
await openErrorsTab(comfyPage)
await expect(missingNodeGroup).toBeVisible()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
// Select-all + Delete: interior node IDs may be reassigned during
// subgraph configure when they collide with root-graph IDs, so
// looking up by static id can fail.
await comfyPage.keyboard.selectAll()
await comfyPage.page.keyboard.press('Delete')
await expect(missingNodeGroup).toBeHidden()
})
test('Bypassing a node inside a subgraph hides its error, un-bypassing restores it', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'missing/missing_models_in_subgraph'
)
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
.click()
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.keyboard.selectAll()
await comfyPage.keyboard.bypass()
const errorsTab = comfyPage.page.getByTestId(
TestIds.propertiesPanel.errorsTab
)
await comfyPage.actionbar.propertiesButton.click()
await expect(errorsTab).toBeHidden()
await comfyPage.keyboard.selectAll()
await comfyPage.keyboard.bypass()
await openErrorsTab(comfyPage)
await expect(missingModelGroup).toBeVisible()
})
})
test.describe('Workflow switching', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
await comfyPage.menu.workflowsTab.open()
})
test('Restores missing nodes in errors tab when switching back to workflow', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
const errorOverlay = comfyPage.page.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(errorOverlay).toBeVisible()
await errorOverlay
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
.click()
const missingNodeGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodePacksGroup
)
await openErrorsTab(comfyPage)
await expect(missingNodeGroup).toBeVisible()
await comfyPage.menu.workflowsTab.open()
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await expect(missingNodeGroup).toBeHidden()
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')
await openErrorsTab(comfyPage)
await expect(missingNodeGroup).toBeVisible()
})
})
})

View File

@@ -2,6 +2,7 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { openErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
test.describe('Workflows sidebar', () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -232,7 +233,7 @@ test.describe('Workflows sidebar', () => {
.toEqual('workflow1')
})
test('Reports missing nodes warning again when switching back to workflow', async ({
test('Restores missing nodes errors silently when switching back to workflow', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
@@ -254,11 +255,17 @@ test.describe('Workflows sidebar', () => {
await comfyPage.menu.workflowsTab.open()
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
// Switch back to the missing_nodes workflow — overlay should reappear
// so users can install missing node packs without a page reload
// Switch back to the missing_nodes workflow — overlay should NOT
// reappear (silent restore), but errors tab should have content
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')
await expect(errorOverlay).toBeVisible()
await expect(errorOverlay).toBeHidden()
// Errors tab should still show missing nodes after silent restore
await openErrorsTab(comfyPage)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodePacksGroup)
).toBeVisible()
})
test('Can close saved-workflows from the open workflows section', async ({

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -16,6 +16,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
@@ -41,6 +42,7 @@ import TabErrors from './errors/TabErrors.vue'
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const missingMediaStore = useMissingMediaStore()
const missingNodesErrorStore = useMissingNodesErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
const settingStore = useSettingStore()
@@ -58,6 +60,7 @@ const activeMissingNodeGraphIds = computed<Set<string>>(() => {
})
const { activeMissingModelGraphIds } = storeToRefs(missingModelStore)
const { activeMissingMediaGraphIds } = storeToRefs(missingMediaStore)
const { findParentGroup } = useGraphHierarchy()
@@ -142,13 +145,22 @@ const hasMissingModelSelected = computed(
)
)
const hasMissingMediaSelected = computed(
() =>
hasSelection.value &&
selectedNodes.value.some((node) =>
activeMissingMediaGraphIds.value.has(String(node.id))
)
)
const hasRelevantErrors = computed(() => {
if (!hasSelection.value) return hasAnyError.value
return (
hasDirectNodeError.value ||
hasContainerInternalError.value ||
hasMissingNodeSelected.value ||
hasMissingModelSelected.value
hasMissingModelSelected.value ||
hasMissingMediaSelected.value
)
})
@@ -287,11 +299,14 @@ function handleTitleCancel() {
@cancel="handleTitleCancel"
@click="isEditing = true"
/>
<i
<button
v-if="!isEditing"
class="relative top-[2px] ml-2 icon-[lucide--pencil] size-4 shrink-0 cursor-pointer content-center text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.editTitle')"
class="relative top-[2px] ml-2 size-4 shrink-0 cursor-pointer content-center text-muted-foreground hover:text-base-foreground"
@click="isEditing = true"
/>
>
<i aria-hidden="true" class="icon-[lucide--pencil] size-4" />
</button>
</template>
<template v-else>
{{ panelTitle }}
@@ -304,6 +319,7 @@ function handleTitleCancel() {
variant="secondary"
size="icon"
data-testid="subgraph-editor-toggle"
:aria-label="t('rightSidePanel.editSubgraph')"
:class="cn(isEditingSubgraph && 'bg-secondary-background-selected')"
@click="
rightSidePanelStore.openPanel(
@@ -338,6 +354,7 @@ function handleTitleCancel() {
:key="tab.value"
class="px-2 py-1 font-inter text-sm transition-all active:scale-95"
:value="tab.value"
:data-testid="`panel-tab-${tab.value}`"
>
{{ tab.label() }}
<i

View File

@@ -104,7 +104,7 @@
<Button
v-else-if="
group.type === 'missing_model' &&
downloadableModels.length > 0
downloadableModels.length > 1
"
variant="secondary"
size="sm"

View File

@@ -660,6 +660,106 @@ export function useErrorGroups(
]
}
function isAssetErrorInSelection(executionNodeId: string): boolean {
const nodeIds = selectedNodeInfo.value.nodeIds
if (!nodeIds) return true
// Try missing node cache first
const cachedNode = missingNodeCache.value.get(executionNodeId)
if (cachedNode && nodeIds.has(String(cachedNode.id))) return true
// Resolve from graph for model/media candidates
if (app.rootGraph) {
const graphNode = getNodeByExecutionId(app.rootGraph, executionNodeId)
if (graphNode && nodeIds.has(String(graphNode.id))) return true
}
for (const containerExecId of selectedNodeInfo.value
.containerExecutionIds) {
if (executionNodeId.startsWith(`${containerExecId}:`)) return true
}
return false
}
const filteredMissingModelGroups = computed(() => {
if (!selectedNodeInfo.value.nodeIds) return missingModelGroups.value
const candidates = missingModelStore.missingModelCandidates
if (!candidates?.length) return []
const filtered = candidates.filter(
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
)
if (!filtered.length) return []
const map = new Map<
string | null | typeof UNSUPPORTED,
{ candidates: MissingModelCandidate[]; isAssetSupported: boolean }
>()
for (const c of filtered) {
const groupKey =
c.isAssetSupported || !isCloud ? c.directory || null : UNSUPPORTED
const existing = map.get(groupKey)
if (existing) {
existing.candidates.push(c)
} else {
map.set(groupKey, {
candidates: [c],
isAssetSupported: c.isAssetSupported
})
}
}
return Array.from(map.entries())
.sort(([dirA], [dirB]) => {
if (dirA === UNSUPPORTED) return 1
if (dirB === UNSUPPORTED) return -1
if (dirA === null) return 1
if (dirB === null) return -1
return dirA.localeCompare(dirB)
})
.map(([key, { candidates: groupCandidates, isAssetSupported }]) => ({
directory: typeof key === 'string' ? key : null,
models: groupCandidatesByName(groupCandidates),
isAssetSupported
}))
})
const filteredMissingMediaGroups = computed(() => {
if (!selectedNodeInfo.value.nodeIds) return missingMediaGroups.value
const candidates = missingMediaStore.missingMediaCandidates
if (!candidates?.length) return []
const filtered = candidates.filter(
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
)
if (!filtered.length) return []
return groupCandidatesByMediaType(filtered)
})
function buildMissingModelGroupsFiltered(): ErrorGroup[] {
if (!filteredMissingModelGroups.value.length) return []
return [
{
type: 'missing_model' as const,
title: `${t('rightSidePanel.missingModels.missingModelsTitle')} (${filteredMissingModelGroups.value.reduce((count, group) => count + group.models.length, 0)})`,
priority: 2
}
]
}
function buildMissingMediaGroupsFiltered(): ErrorGroup[] {
if (!filteredMissingMediaGroups.value.length) return []
const totalItems = filteredMissingMediaGroups.value.reduce(
(count, group) => count + group.items.length,
0
)
return [
{
type: 'missing_media' as const,
title: `${t('rightSidePanel.missingMedia.missingMediaTitle')} (${totalItems})`,
priority: 3
}
]
}
const allErrorGroups = computed<ErrorGroup[]>(() => {
const groupsMap = new Map<string, GroupEntry>()
@@ -686,10 +786,18 @@ export function useErrorGroups(
? toSortedGroups(regroupByErrorMessage(groupsMap))
: toSortedGroups(groupsMap)
const filterByNode = selectedNodeInfo.value.nodeIds !== null
// Missing nodes are intentionally unfiltered — they represent
// pack-level problems relevant regardless of which node is selected.
return [
...buildMissingNodeGroups(),
...buildMissingModelGroups(),
...buildMissingMediaGroups(),
...(filterByNode
? buildMissingModelGroupsFiltered()
: buildMissingModelGroups()),
...(filterByNode
? buildMissingMediaGroupsFiltered()
: buildMissingMediaGroups()),
...executionGroups
]
})
@@ -725,8 +833,8 @@ export function useErrorGroups(
missingNodeCache,
groupedErrorMessages,
missingPackGroups,
missingModelGroups,
missingMediaGroups,
missingModelGroups: filteredMissingModelGroups,
missingMediaGroups: filteredMissingMediaGroups,
swapNodeGroups
}
}

View File

@@ -9,7 +9,15 @@ import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import {
LGraphEventMode,
NodeSlotType
} from '@/lib/litegraph/src/types/globalEnums'
import * as missingMediaScan from '@/platform/missingMedia/missingMediaScan'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import * as missingModelScan from '@/platform/missingModel/missingModelScan'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
@@ -357,6 +365,371 @@ describe('installErrorClearingHooks lifecycle', () => {
})
})
describe('onNodeRemoved clears missing asset errors by execution ID', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
})
it('removes root-level node missing model error using its local id', () => {
const graph = new LGraph()
const node = new LGraphNode('CheckpointLoaderSimple')
graph.add(node)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const modelStore = useMissingModelStore()
modelStore.setMissingModels([
fromAny<
Parameters<typeof modelStore.setMissingModels>[0][number],
unknown
>({
nodeId: String(node.id),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'model.safetensors',
isMissing: true
})
])
graph.remove(node)
expect(modelStore.missingModelCandidates).toBeNull()
})
it('removes subgraph interior node missing model error using parentId:nodeId', () => {
// Regression: node.graph is nulled before onNodeRemoved fires, so
// getExecutionIdByNode returned null and removal fell back to the
// local node id. Errors stored under "parentId:nodeId" were never
// removed for subgraph interior nodes.
const subgraph = createTestSubgraph()
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
const rootGraph = subgraphNode.graph as LGraph
rootGraph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
// Hooks are installed on whichever graph is currently active in
// the canvas; when the user is inside the subgraph, that is the
// graph whose onNodeRemoved fires for interior deletions.
installErrorClearingHooks(subgraph)
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
const modelStore = useMissingModelStore()
modelStore.setMissingModels([
fromAny<
Parameters<typeof modelStore.setMissingModels>[0][number],
unknown
>({
nodeId: interiorExecId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'model.safetensors',
isMissing: true
})
])
subgraph.remove(interiorNode)
expect(modelStore.missingModelCandidates).toBeNull()
})
it('removes subgraph interior node missing media and missing node errors', () => {
const subgraph = createTestSubgraph()
const interiorNode = new LGraphNode('LoadImage')
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
const rootGraph = subgraphNode.graph as LGraph
rootGraph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
installErrorClearingHooks(subgraph)
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
const mediaStore = useMissingMediaStore()
mediaStore.setMissingMedia([
fromAny<
Parameters<typeof mediaStore.setMissingMedia>[0][number],
unknown
>({
nodeId: interiorExecId,
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'cat.png',
isMissing: true
})
])
const nodesStore = useMissingNodesErrorStore()
nodesStore.surfaceMissingNodes([
{
type: 'LoadImage',
nodeId: interiorExecId,
cnrId: undefined,
isReplaceable: false,
replacement: undefined
}
])
subgraph.remove(interiorNode)
expect(mediaStore.missingMediaCandidates).toBeNull()
expect(nodesStore.missingNodesError).toBeNull()
})
})
describe('realtime scan verifies pending cloud candidates', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
})
it('un-bypass path surfaces pending model candidates after verification', async () => {
const graph = new LGraph()
const node = new LGraphNode('CheckpointLoaderSimple')
graph.add(node)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
// Cloud mode returns candidates with isMissing: undefined until
// verifyAssetSupportedCandidates resolves them against the assets store.
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
{
nodeId: String(node.id),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: true,
name: 'cloud_model.safetensors',
isMissing: undefined
}
])
const verifySpy = vi
.spyOn(missingModelScan, 'verifyAssetSupportedCandidates')
.mockImplementation(async (candidates) => {
for (const c of candidates) c.isMissing = true
})
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
installErrorClearingHooks(graph)
// Simulate un-bypass (BYPASS → NEVER_BY_USER is not active; use 0 = active)
node.mode = LGraphEventMode.ALWAYS
graph.onTrigger?.({
type: 'node:property:changed',
nodeId: node.id,
property: 'mode',
oldValue: LGraphEventMode.BYPASS,
newValue: LGraphEventMode.ALWAYS
})
await vi.waitFor(() => {
expect(verifySpy).toHaveBeenCalledOnce()
})
await vi.waitFor(() => {
const store = useMissingModelStore()
expect(store.missingModelCandidates).toHaveLength(1)
expect(store.missingModelCandidates![0].name).toBe(
'cloud_model.safetensors'
)
})
})
it('un-bypass path surfaces pending media candidates after verification', async () => {
const graph = new LGraph()
const node = new LGraphNode('LoadImage')
graph.add(node)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([
{
nodeId: String(node.id),
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'cloud_image.png',
isMissing: undefined
}
])
const verifySpy = vi
.spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
.mockImplementation(async (candidates) => {
for (const c of candidates) c.isMissing = true
})
installErrorClearingHooks(graph)
node.mode = LGraphEventMode.ALWAYS
graph.onTrigger?.({
type: 'node:property:changed',
nodeId: node.id,
property: 'mode',
oldValue: LGraphEventMode.BYPASS,
newValue: LGraphEventMode.ALWAYS
})
await vi.waitFor(() => {
expect(verifySpy).toHaveBeenCalledOnce()
})
await vi.waitFor(() => {
const store = useMissingMediaStore()
expect(store.missingMediaCandidates).toHaveLength(1)
expect(store.missingMediaCandidates![0].name).toBe('cloud_image.png')
})
})
it('does not add candidates that remain confirmed-present after verification', async () => {
const graph = new LGraph()
const node = new LGraphNode('CheckpointLoaderSimple')
graph.add(node)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
{
nodeId: String(node.id),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: true,
name: 'present.safetensors',
isMissing: undefined
}
])
vi.spyOn(
missingModelScan,
'verifyAssetSupportedCandidates'
).mockImplementation(async (candidates) => {
for (const c of candidates) c.isMissing = false
})
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
installErrorClearingHooks(graph)
node.mode = LGraphEventMode.ALWAYS
graph.onTrigger?.({
type: 'node:property:changed',
nodeId: node.id,
property: 'mode',
oldValue: LGraphEventMode.BYPASS,
newValue: LGraphEventMode.ALWAYS
})
await new Promise((r) => setTimeout(r, 0))
expect(useMissingModelStore().missingModelCandidates).toBeNull()
})
})
describe('realtime verification staleness guards', () => {
beforeEach(() => {
vi.restoreAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
})
it('skips adding verified model when node was bypassed before verification resolved', async () => {
const graph = new LGraph()
const node = new LGraphNode('CheckpointLoaderSimple')
graph.add(node)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
{
nodeId: String(node.id),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: true,
name: 'stale_model.safetensors',
isMissing: undefined
}
])
let resolveVerify: (() => void) | undefined
const verifyPromise = new Promise<void>((r) => (resolveVerify = r))
const verifySpy = vi
.spyOn(missingModelScan, 'verifyAssetSupportedCandidates')
.mockImplementation(async (candidates) => {
await verifyPromise
for (const c of candidates) c.isMissing = true
})
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
installErrorClearingHooks(graph)
// Un-bypass: kicks off verification (still pending)
node.mode = LGraphEventMode.ALWAYS
graph.onTrigger?.({
type: 'node:property:changed',
nodeId: node.id,
property: 'mode',
oldValue: LGraphEventMode.BYPASS,
newValue: LGraphEventMode.ALWAYS
})
await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce())
// Bypass again before verification resolves
node.mode = LGraphEventMode.BYPASS
// Verification now resolves with isMissing: true, but staleness
// check must drop the add because node is currently bypassed.
resolveVerify!()
await new Promise((r) => setTimeout(r, 0))
expect(useMissingModelStore().missingModelCandidates).toBeNull()
})
it('skips adding verified media when node is deleted before verification resolved', async () => {
const graph = new LGraph()
const node = new LGraphNode('LoadImage')
graph.add(node)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([
{
nodeId: String(node.id),
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'deleted_image.png',
isMissing: undefined
}
])
let resolveVerify: (() => void) | undefined
const verifyPromise = new Promise<void>((r) => (resolveVerify = r))
const verifySpy = vi
.spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
.mockImplementation(async (candidates) => {
await verifyPromise
for (const c of candidates) c.isMissing = true
})
installErrorClearingHooks(graph)
node.mode = LGraphEventMode.ALWAYS
graph.onTrigger?.({
type: 'node:property:changed',
nodeId: node.id,
property: 'mode',
oldValue: LGraphEventMode.BYPASS,
newValue: LGraphEventMode.ALWAYS
})
await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce())
// Delete the node before verification completes
graph.remove(node)
resolveVerify!()
await new Promise((r) => setTimeout(r, 0))
expect(useMissingMediaStore().missingMediaCandidates).toBeNull()
})
})
describe('clearWidgetRelatedErrors parameter routing', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))

View File

@@ -8,12 +8,41 @@
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import {
LGraphEventMode,
NodeSlotType
} from '@/lib/litegraph/src/types/globalEnums'
import type { LGraphTriggerEvent } from '@/lib/litegraph/src/types/graphTriggers'
import { ChangeTracker } from '@/scripts/changeTracker'
import { isCloud } from '@/platform/distribution/types'
import { assetService } from '@/platform/assets/services/assetService'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import {
scanNodeModelCandidates,
verifyAssetSupportedCandidates
} from '@/platform/missingModel/missingModelScan'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import {
scanNodeMediaCandidates,
verifyCloudMediaCandidates
} from '@/platform/missingMedia/missingMediaScan'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
import { getCnrIdFromNode } from '@/platform/nodeReplacement/cnrIdUtil'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import {
collectAllNodes,
getExecutionIdByNode,
getExecutionIdForNodeInGraph,
getNodeByExecutionId
} from '@/utils/graphTraversalUtil'
function resolvePromotedExecId(
rootGraph: LGraph,
@@ -121,6 +150,189 @@ function restoreNodeHooksRecursive(node: LGraphNode): void {
}
}
function isNodeInactive(mode: number): boolean {
return mode === LGraphEventMode.NEVER || mode === LGraphEventMode.BYPASS
}
/** Scan a single node and add confirmed missing model/media to stores.
* For subgraph containers, also scans all active interior nodes. */
function scanAndAddNodeErrors(node: LGraphNode): void {
if (!app.rootGraph) return
if (node.isSubgraphNode?.() && node.subgraph) {
for (const innerNode of collectAllNodes(node.subgraph)) {
if (isNodeInactive(innerNode.mode)) continue
scanSingleNodeErrors(innerNode)
}
return
}
scanSingleNodeErrors(node)
}
function scanSingleNodeErrors(node: LGraphNode): void {
if (!app.rootGraph) return
const modelCandidates = scanNodeModelCandidates(
app.rootGraph,
node,
isCloud
? (nodeType, widgetName) =>
assetService.shouldUseAssetBrowser(nodeType, widgetName)
: () => false,
(nodeType) => useModelToNodeStore().getCategoryForNodeType(nodeType)
)
const confirmedModels = modelCandidates.filter((c) => c.isMissing === true)
if (confirmedModels.length) {
useMissingModelStore().addMissingModels(confirmedModels)
}
// Cloud scans return isMissing: undefined for asset-browser-supported
// widgets until async verification resolves. Without this, realtime
// add/un-bypass paths would silently drop those candidates.
const pendingModels = modelCandidates.filter((c) => c.isMissing === undefined)
if (pendingModels.length) {
void verifyAndAddPendingModels(pendingModels)
}
const mediaCandidates = scanNodeMediaCandidates(app.rootGraph, node, isCloud)
const confirmedMedia = mediaCandidates.filter((c) => c.isMissing === true)
if (confirmedMedia.length) {
useMissingMediaStore().addMissingMedia(confirmedMedia)
}
// Cloud media scans always return isMissing: undefined pending
// verification against the input-assets list.
const pendingMedia = mediaCandidates.filter((c) => c.isMissing === undefined)
if (pendingMedia.length) {
void verifyAndAddPendingMedia(pendingMedia)
}
// Check for missing node type
const originalType = node.last_serialization?.type ?? node.type ?? 'Unknown'
if (!(originalType in LiteGraph.registered_node_types)) {
const execId = getExecutionIdByNode(app.rootGraph, node)
if (execId) {
const nodeReplacementStore = useNodeReplacementStore()
const replacement = nodeReplacementStore.getReplacementFor(originalType)
const store = useMissingNodesErrorStore()
const existing = store.missingNodesError?.nodeTypes ?? []
store.surfaceMissingNodes([
...existing,
{
type: originalType,
nodeId: execId,
cnrId: getCnrIdFromNode(node),
isReplaceable: replacement !== null,
replacement: replacement ?? undefined
}
])
}
}
}
/**
* True when the candidate's node still exists in the current root graph
* and is active. Filters out late verification results for nodes that
* have been bypassed, deleted, or belong to a workflow that is no
* longer current — any of which would reintroduce stale errors.
*/
function isCandidateStillActive(nodeId: unknown): boolean {
if (!app.rootGraph || nodeId == null) return false
const node = getNodeByExecutionId(app.rootGraph, String(nodeId))
if (!node) return false
return !isNodeInactive(node.mode)
}
async function verifyAndAddPendingModels(
pending: MissingModelCandidate[]
): Promise<void> {
try {
await verifyAssetSupportedCandidates(pending)
const verified = pending.filter(
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)
)
if (verified.length) useMissingModelStore().addMissingModels(verified)
} catch (error: unknown) {
console.warn('[useErrorClearingHooks] model verification failed:', error)
}
}
async function verifyAndAddPendingMedia(
pending: MissingMediaCandidate[]
): Promise<void> {
try {
await verifyCloudMediaCandidates(pending)
const verified = pending.filter(
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)
)
if (verified.length) useMissingMediaStore().addMissingMedia(verified)
} catch (error: unknown) {
console.warn('[useErrorClearingHooks] media verification failed:', error)
}
}
function scanAddedNode(node: LGraphNode): void {
if (!app.rootGraph || ChangeTracker.isLoadingGraph) return
if (isNodeInactive(node.mode)) return
scanAndAddNodeErrors(node)
}
function handleNodeModeChange(
localGraph: LGraph,
nodeId: number,
oldMode: number,
newMode: number
): void {
if (!app.rootGraph) return
const wasInactive = isNodeInactive(oldMode)
const isNowInactive = isNodeInactive(newMode)
if (wasInactive === isNowInactive) return
// Find the node by local ID in the graph that fired the event,
// then compute its execution ID relative to the root graph.
const node = localGraph.getNodeById(nodeId)
if (!node) return
const execId = getExecutionIdByNode(app.rootGraph, node)
if (!execId) return
if (isNowInactive) {
removeNodeErrors(node, execId)
} else {
scanAndAddNodeErrors(node)
if (
useMissingModelStore().hasMissingModels ||
useMissingMediaStore().hasMissingMedia ||
useMissingNodesErrorStore().hasMissingNodes
) {
useExecutionErrorStore().showErrorOverlay()
}
}
}
/** Remove all missing asset errors for a node and, if it's a subgraph
* container, for all interior nodes (prefix match on execution ID). */
function removeNodeErrors(node: LGraphNode, execId: string): void {
const modelStore = useMissingModelStore()
const mediaStore = useMissingMediaStore()
const nodesStore = useMissingNodesErrorStore()
modelStore.removeMissingModelsByNodeId(execId)
mediaStore.removeMissingMediaByNodeId(execId)
nodesStore.removeMissingNodesByNodeId(execId)
// For subgraph containers, also remove errors from interior nodes.
// The trailing colon in the prefix is load-bearing: it prevents sibling
// IDs sharing a numeric prefix (e.g. "705" vs "70") from being matched.
if (node.isSubgraphNode?.() && node.subgraph) {
const prefix = `${execId}:`
modelStore.removeMissingModelsByPrefix(prefix)
mediaStore.removeMissingMediaByPrefix(prefix)
nodesStore.removeMissingNodesByPrefix(prefix)
}
}
export function installErrorClearingHooks(graph: LGraph): () => void {
for (const node of graph._nodes ?? []) {
installNodeHooksRecursive(node)
@@ -129,20 +341,54 @@ export function installErrorClearingHooks(graph: LGraph): () => void {
const originalOnNodeAdded = graph.onNodeAdded
graph.onNodeAdded = function (node: LGraphNode) {
installNodeHooksRecursive(node)
// Scan pasted/duplicated nodes for missing models/media.
// Skip during loadGraphData (undo/redo/tab switch) — those are
// handled by the full pipeline or cache restore.
// Deferred to microtask because onNodeAdded fires before
// node.configure() restores widget values.
if (!ChangeTracker.isLoadingGraph) {
queueMicrotask(() => scanAddedNode(node))
}
originalOnNodeAdded?.call(this, node)
}
const originalOnNodeRemoved = graph.onNodeRemoved
graph.onNodeRemoved = function (node: LGraphNode) {
// node.graph is already null by the time onNodeRemoved fires, so
// derive the execution ID from the graph the hook is installed on
// plus node.id. For subgraph interior nodes this yields the full
// "parentId:...:nodeId" path that matches how missing asset errors
// are keyed; without this, removal falls back to the local ID and
// misses subgraph entries.
const execId = app.rootGraph
? getExecutionIdForNodeInGraph(app.rootGraph, graph, node.id)
: String(node.id)
removeNodeErrors(node, execId)
restoreNodeHooksRecursive(node)
originalOnNodeRemoved?.call(this, node)
}
const originalOnTrigger = graph.onTrigger
graph.onTrigger = (event: LGraphTriggerEvent) => {
if (event.type === 'node:property:changed' && event.property === 'mode') {
handleNodeModeChange(
graph,
event.nodeId as number,
event.oldValue as number,
event.newValue as number
)
}
originalOnTrigger?.(event)
}
return () => {
for (const node of graph._nodes ?? []) {
restoreNodeHooksRecursive(node)
}
graph.onNodeAdded = originalOnNodeAdded || undefined
graph.onNodeRemoved = originalOnNodeRemoved || undefined
graph.onTrigger = originalOnTrigger || undefined
}
}

View File

@@ -3403,6 +3403,8 @@
},
"rightSidePanel": {
"togglePanel": "Toggle properties panel",
"editTitle": "Edit title",
"editSubgraph": "Edit subgraph",
"noSelection": "Select a node to see its properties and info.",
"workflowOverview": "Workflow Overview",
"title": "No item(s) selected | 1 item selected | {count} items selected",

View File

@@ -1,11 +1,26 @@
import { describe, expect, it } from 'vitest'
import { fromAny } from '@total-typescript/shoehorn'
import { 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 {
scanAllMediaCandidates,
scanNodeMediaCandidates,
verifyCloudMediaCandidates,
groupCandidatesByName,
groupCandidatesByMediaType
} from './missingMediaScan'
import type { MissingMediaCandidate } from './types'
vi.mock('@/utils/graphTraversalUtil', () => ({
collectAllNodes: (graph: { _testNodes: LGraphNode[] }) => graph._testNodes,
getExecutionIdByNode: (
_graph: unknown,
node: { _testExecutionId?: string; id: number }
) => node._testExecutionId ?? String(node.id)
}))
function makeCandidate(
nodeId: string,
name: string,
@@ -22,6 +37,122 @@ function makeCandidate(
}
}
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 })
}
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 = [

View File

@@ -7,6 +7,7 @@ import type {
MediaType
} from './types'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type {
IBaseWidget,
IComboWidget
@@ -15,6 +16,7 @@ import {
collectAllNodes,
getExecutionIdByNode
} from '@/utils/graphTraversalUtil'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { resolveComboValues } from '@/utils/litegraphUtil'
/** Map of node types to their media widget name and media type. */
@@ -49,38 +51,56 @@ export function scanAllMediaCandidates(
for (const node of allNodes) {
if (!node.widgets?.length) continue
if (node.isSubgraphNode?.()) continue
if (
node.mode === LGraphEventMode.NEVER ||
node.mode === LGraphEventMode.BYPASS
)
continue
const mediaInfo = MEDIA_NODE_WIDGETS[node.type]
if (!mediaInfo) continue
candidates.push(...scanNodeMediaCandidates(rootGraph, node, isCloud))
}
const executionId = getExecutionIdByNode(rootGraph, node)
if (!executionId) continue
return candidates
}
for (const widget of node.widgets) {
if (!isComboWidget(widget)) continue
if (widget.name !== mediaInfo.widgetName) continue
/** Scan a single node for missing media candidates (OSS immediate resolution). */
export function scanNodeMediaCandidates(
rootGraph: LGraph,
node: LGraphNode,
isCloud: boolean
): MissingMediaCandidate[] {
if (!node.widgets?.length) return []
const value = widget.value
if (typeof value !== 'string' || !value.trim()) continue
const mediaInfo = MEDIA_NODE_WIDGETS[node.type]
if (!mediaInfo) return []
let isMissing: boolean | undefined
if (isCloud) {
// Cloud: options may be empty initially; defer to async verification
isMissing = undefined
} else {
const options = resolveComboValues(widget)
isMissing = !options.includes(value)
}
const executionId = getExecutionIdByNode(rootGraph, node)
if (!executionId) return []
candidates.push({
nodeId: executionId as NodeId,
nodeType: node.type,
widgetName: widget.name,
mediaType: mediaInfo.mediaType,
name: value,
isMissing
})
const candidates: MissingMediaCandidate[] = []
for (const widget of node.widgets) {
if (!isComboWidget(widget)) continue
if (widget.name !== mediaInfo.widgetName) continue
const value = widget.value
if (typeof value !== 'string' || !value.trim()) continue
let isMissing: boolean | undefined
if (isCloud) {
isMissing = undefined
} else {
const options = resolveComboValues(widget)
isMissing = !options.includes(value)
}
candidates.push({
nodeId: executionId as NodeId,
nodeType: node.type,
widgetName: widget.name,
mediaType: mediaInfo.mediaType,
name: value,
isMissing
})
}
return candidates

View File

@@ -194,4 +194,224 @@ describe('useMissingMediaStore', () => {
store.createVerificationAbortController()
expect(first.signal.aborted).toBe(true)
})
describe('addMissingMedia', () => {
it('appends to existing candidates', () => {
const store = useMissingMediaStore()
store.setMissingMedia([makeCandidate('1', 'photo.png')])
store.addMissingMedia([makeCandidate('2', 'clip.mp4', 'video')])
expect(store.missingMediaCandidates).toHaveLength(2)
expect(store.missingMediaCandidates![0].name).toBe('photo.png')
expect(store.missingMediaCandidates![1].name).toBe('clip.mp4')
})
it('works when store is empty (candidates are null)', () => {
const store = useMissingMediaStore()
expect(store.missingMediaCandidates).toBeNull()
store.addMissingMedia([makeCandidate('1', 'photo.png')])
expect(store.missingMediaCandidates).toHaveLength(1)
expect(store.hasMissingMedia).toBe(true)
})
it('does nothing when given empty array', () => {
const store = useMissingMediaStore()
store.setMissingMedia([makeCandidate('1', 'photo.png')])
store.addMissingMedia([])
expect(store.missingMediaCandidates).toHaveLength(1)
})
})
describe('removeMissingMediaByNodeId', () => {
it('removes all candidates matching the nodeId', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('1', 'photo.png'),
makeCandidate('1', 'other.png'),
makeCandidate('2', 'clip.mp4', 'video')
])
store.removeMissingMediaByNodeId('1')
expect(store.missingMediaCandidates).toHaveLength(1)
expect(store.missingMediaCandidates![0].name).toBe('clip.mp4')
})
it('keeps candidates with non-matching nodeId', () => {
const store = useMissingMediaStore()
store.setMissingMedia([makeCandidate('1', 'photo.png')])
store.removeMissingMediaByNodeId('99')
expect(store.missingMediaCandidates).toHaveLength(1)
})
it('sets candidates to null when all are removed', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('1', 'photo.png'),
makeCandidate('1', 'other.png')
])
store.removeMissingMediaByNodeId('1')
expect(store.missingMediaCandidates).toBeNull()
expect(store.hasMissingMedia).toBe(false)
})
it('cleans interaction state for removed names', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('1', 'photo.png'),
makeCandidate('2', 'clip.mp4', 'video')
])
store.expandState['photo.png'] = true
store.uploadState['photo.png'] = {
fileName: 'photo.png',
status: 'uploaded'
}
store.pendingSelection['photo.png'] = 'uploaded/photo.png'
store.removeMissingMediaByNodeId('1')
expect(store.expandState['photo.png']).toBeUndefined()
expect(store.uploadState['photo.png']).toBeUndefined()
expect(store.pendingSelection['photo.png']).toBeUndefined()
})
it('preserves interaction state when other candidates share the name', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('1', 'photo.png'),
makeCandidate('2', 'photo.png')
])
store.pendingSelection['photo.png'] = 'library/photo.png'
store.removeMissingMediaByNodeId('1')
expect(store.missingMediaCandidates).toHaveLength(1)
expect(store.pendingSelection['photo.png']).toBe('library/photo.png')
})
it('does nothing when candidates are null', () => {
const store = useMissingMediaStore()
store.removeMissingMediaByNodeId('1')
expect(store.missingMediaCandidates).toBeNull()
})
})
describe('removeMissingMediaByPrefix', () => {
it('removes all candidates whose nodeId starts with the prefix', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('65:70:63', 'a.png'),
makeCandidate('65:70:64', 'b.png'),
makeCandidate('65:80:5', 'c.png')
])
store.removeMissingMediaByPrefix('65:70:')
expect(store.missingMediaCandidates).toHaveLength(1)
expect(store.missingMediaCandidates![0].nodeId).toBe('65:80:5')
})
it('removes deeply nested interior nodes under the container', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('65:70:63', 'a.png'),
makeCandidate('65:70:80:5', 'b.png'),
makeCandidate('65:71:63', 'c.png')
])
store.removeMissingMediaByPrefix('65:70:')
expect(store.missingMediaCandidates).toHaveLength(1)
expect(store.missingMediaCandidates![0].nodeId).toBe('65:71:63')
})
it('does not match siblings that share a numeric prefix (trailing colon)', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('65:70:1', 'a.png'),
makeCandidate('65:705:1', 'b.png'),
makeCandidate('65:70', 'c.png')
])
store.removeMissingMediaByPrefix('65:70:')
expect(store.missingMediaCandidates).toHaveLength(2)
const remainingIds = store.missingMediaCandidates!.map((m) =>
String(m.nodeId)
)
expect(remainingIds).toContain('65:705:1')
expect(remainingIds).toContain('65:70')
})
it('sets candidates to null when all are removed', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('65:70:63', 'a.png'),
makeCandidate('65:70:64', 'b.png')
])
store.removeMissingMediaByPrefix('65:70:')
expect(store.missingMediaCandidates).toBeNull()
expect(store.hasMissingMedia).toBe(false)
})
it('does nothing when no candidates match', () => {
const store = useMissingMediaStore()
store.setMissingMedia([makeCandidate('65:71:1', 'a.png')])
store.removeMissingMediaByPrefix('65:70:')
expect(store.missingMediaCandidates).toHaveLength(1)
})
it('does nothing when candidates are null', () => {
const store = useMissingMediaStore()
store.removeMissingMediaByPrefix('65:70:')
expect(store.missingMediaCandidates).toBeNull()
})
it('preserves candidates with a nullish nodeId (defensive)', () => {
const store = useMissingMediaStore()
const orphan = {
nodeId: undefined as unknown as string,
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image' as const,
name: 'orphan.png',
isMissing: true
}
store.setMissingMedia([makeCandidate('65:70:63', 'a.png'), orphan])
store.removeMissingMediaByPrefix('65:70:')
expect(store.missingMediaCandidates).toHaveLength(1)
expect(store.missingMediaCandidates![0].name).toBe('orphan.png')
})
it('clears interaction state for removed names not used elsewhere', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('65:70:63', 'shared.png'),
makeCandidate('65:80:5', 'shared.png'),
makeCandidate('65:70:64', 'only-interior.png')
])
store.pendingSelection['shared.png'] = 'library/shared.png'
store.pendingSelection['only-interior.png'] = 'library/interior.png'
store.removeMissingMediaByPrefix('65:70:')
expect(store.pendingSelection['only-interior.png']).toBeUndefined()
expect(store.pendingSelection['shared.png']).toBe('library/shared.png')
})
})
})

View File

@@ -121,6 +121,74 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
missingMediaCandidates.value = null
}
function removeMissingMediaByNodeId(nodeId: string) {
if (!missingMediaCandidates.value) return
const removedNames = new Set(
missingMediaCandidates.value
.filter((m) => String(m.nodeId) === nodeId)
.map((m) => m.name)
)
missingMediaCandidates.value = missingMediaCandidates.value.filter(
(m) => String(m.nodeId) !== nodeId
)
for (const name of removedNames) {
if (!missingMediaCandidates.value.some((m) => m.name === name)) {
clearInteractionStateForName(name)
}
}
if (!missingMediaCandidates.value.length)
missingMediaCandidates.value = null
}
/**
* Remove all candidates whose nodeId starts with `prefix`.
*
* Intended for clearing all interior errors when a subgraph container is
* removed. Callers are expected to pass `${execId}:` (with trailing
* colon) so that sibling IDs sharing a numeric prefix (e.g. `"705"` vs
* `"70"`) are not matched.
*/
function removeMissingMediaByPrefix(prefix: string) {
if (!missingMediaCandidates.value) return
const removedNames = new Set<string>()
const remaining: MissingMediaCandidate[] = []
for (const m of missingMediaCandidates.value) {
// Preserve candidates without a nodeId; they cannot belong to any
// subgraph scope. The type marks nodeId as required, but defensive
// handling matches the rest of the missing-media code.
if (m.nodeId == null) {
remaining.push(m)
continue
}
if (String(m.nodeId).startsWith(prefix)) {
removedNames.add(m.name)
} else {
remaining.push(m)
}
}
if (removedNames.size === 0) return
missingMediaCandidates.value = remaining.length ? remaining : null
for (const name of removedNames) {
if (!remaining.some((m) => m.name === name)) {
clearInteractionStateForName(name)
}
}
}
function addMissingMedia(media: MissingMediaCandidate[]) {
if (!media.length) return
const existing = missingMediaCandidates.value ?? []
const existingKeys = new Set(
existing.map((m) => `${String(m.nodeId)}::${m.widgetName}::${m.name}`)
)
const newMedia = media.filter(
(m) =>
!existingKeys.has(`${String(m.nodeId)}::${m.widgetName}::${m.name}`)
)
if (!newMedia.length) return
missingMediaCandidates.value = [...existing, ...newMedia]
}
function clearMissingMedia() {
_verificationAbortController?.abort()
_verificationAbortController = null
@@ -139,8 +207,11 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
activeMissingMediaGraphIds,
setMissingMedia,
addMissingMedia,
removeMissingMediaByName,
removeMissingMediaByWidget,
removeMissingMediaByNodeId,
removeMissingMediaByPrefix,
clearMissingMedia,
createVerificationAbortController,

View File

@@ -184,7 +184,7 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { cn } from '@/utils/tailwindUtil'
@@ -206,6 +206,7 @@ import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { isCloud } from '@/platform/distribution/types'
import {
downloadModel,
fetchModelMetadata,
isModelDownloadable,
toBrowsableUrl
} from '@/platform/missingModel/missingModelDownload'
@@ -244,6 +245,24 @@ const store = useMissingModelStore()
const { selectedLibraryModel, importCategoryMismatch, urlInputs } =
storeToRefs(store)
onMounted(() => {
const url = model.representative.url
if (url && !store.fileSizes[url]) {
fetchModelMetadata(url)
.then((metadata) => {
if (metadata.fileSize !== null) {
store.setFileSize(url, metadata.fileSize)
}
})
.catch((error: unknown) => {
console.warn(
`[MissingModelRow] Failed to fetch metadata for ${url}:`,
error
)
})
}
})
const downloadable = computed(() => {
const rep = model.representative
return !!(

View File

@@ -9,6 +9,7 @@ import type {
} from '@/lib/litegraph/src/types/widgets'
import {
scanAllModelCandidates,
scanNodeModelCandidates,
isModelFileName,
enrichWithEmbeddedMetadata,
verifyAssetSupportedCandidates,
@@ -111,6 +112,52 @@ describe('MODEL_FILE_EXTENSIONS', () => {
})
})
describe('scanNodeModelCandidates', () => {
it('returns candidates for a node with a missing model combo widget', () => {
const graph = makeGraph([])
const node = makeNode(1, 'CheckpointLoaderSimple', [
makeComboWidget('ckpt_name', 'missing_model.safetensors', [
'existing_model.safetensors'
])
])
const result = scanNodeModelCandidates(graph, node, noAssetSupport)
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'missing_model.safetensors',
isMissing: true
})
})
it('returns empty array for node with no widgets', () => {
const graph = makeGraph([])
const node = makeNode(1, 'EmptyNode', [])
const result = scanNodeModelCandidates(graph, node, noAssetSupport)
expect(result).toEqual([])
})
it('returns empty array when executionId is null', () => {
const graph = makeGraph([])
const node = makeNode(
1,
'CheckpointLoaderSimple',
[makeComboWidget('ckpt_name', 'model.safetensors', [])],
''
)
const result = scanNodeModelCandidates(graph, node, noAssetSupport)
expect(result).toEqual([])
})
})
describe('scanAllModelCandidates', () => {
it('should detect a missing model from a combo widget', () => {
const graph = makeGraph([
@@ -390,6 +437,58 @@ describe('scanAllModelCandidates', () => {
expect(result[1].widgetName).toBe('vae_name')
})
it('skips muted nodes (mode === NEVER)', () => {
const mutedNode = fromAny<LGraphNode, unknown>({
id: 10,
type: 'CheckpointLoaderSimple',
widgets: [
makeComboWidget('ckpt_name', 'model.safetensors', ['other.safetensors'])
],
mode: 2, // LGraphEventMode.NEVER
_testExecutionId: '10'
})
const graph = makeGraph([mutedNode])
const result = scanAllModelCandidates(graph, noAssetSupport)
expect(result).toHaveLength(0)
})
it('skips bypassed nodes (mode === BYPASS)', () => {
const bypassedNode = fromAny<LGraphNode, unknown>({
id: 11,
type: 'CheckpointLoaderSimple',
widgets: [
makeComboWidget('ckpt_name', 'model.safetensors', ['other.safetensors'])
],
mode: 4, // LGraphEventMode.BYPASS
_testExecutionId: '11'
})
const graph = makeGraph([bypassedNode])
const result = scanAllModelCandidates(graph, noAssetSupport)
expect(result).toHaveLength(0)
})
it('includes active nodes (mode === ALWAYS)', () => {
const activeNode = fromAny<LGraphNode, unknown>({
id: 12,
type: 'CheckpointLoaderSimple',
widgets: [
makeComboWidget('ckpt_name', 'model.safetensors', ['other.safetensors'])
],
mode: 0, // LGraphEventMode.ALWAYS
_testExecutionId: '12'
})
const graph = makeGraph([activeNode])
const result = scanAllModelCandidates(graph, noAssetSupport)
expect(result).toHaveLength(1)
expect(result[0].isMissing).toBe(true)
})
it('skips subgraph container nodes whose promoted widgets are already scanned via interior nodes', () => {
const containerNode = fromAny<LGraphNode, unknown>({
id: 65,
@@ -638,6 +737,194 @@ describe('enrichWithEmbeddedMetadata', () => {
expect(result).toHaveLength(0)
})
it('skips embedded models from muted nodes', async () => {
const candidates: MissingModelCandidate[] = []
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: 2, // NEVER (muted)
properties: {},
widgets_values: { ckpt_name: 'model.safetensors' }
}
],
links: [],
groups: [],
config: {},
extra: {},
version: 0.4,
models: [
{
name: 'model.safetensors',
url: 'https://example.com/model',
directory: 'checkpoints'
}
]
})
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysMissing
)
expect(result).toHaveLength(0)
})
it('drops workflow-level model entries when only referencing nodes are bypassed (other active nodes present)', async () => {
// Regression: a previous `hasActiveNodes` check kept workflow-level
// models in a mixed graph if ANY active node existed, even when every
// node that actually referenced the model was bypassed. The correct
// check drops unmatched workflow-level entries since candidates are
// derived from active-node widgets.
const candidates: MissingModelCandidate[] = []
const graphData = fromPartial<ComfyWorkflowJSON>({
last_node_id: 2,
last_link_id: 0,
nodes: [
{
id: 1,
type: 'CheckpointLoaderSimple',
pos: [0, 0],
size: [100, 100],
flags: {},
order: 0,
mode: 4, // BYPASS — only node referencing the model
properties: {},
widgets_values: { ckpt_name: 'model.safetensors' }
},
{
id: 2,
type: 'KSampler',
pos: [200, 0],
size: [100, 100],
flags: {},
order: 1,
mode: 0, // ALWAYS — unrelated active node
properties: {},
widgets_values: {}
}
],
links: [],
groups: [],
config: {},
extra: {},
version: 0.4,
models: [
{
name: 'model.safetensors',
url: 'https://example.com/model',
directory: 'checkpoints'
}
]
})
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysMissing
)
expect(result).toHaveLength(0)
})
it('keeps unmatched node-sourced entries in a mixed graph', async () => {
// A node-sourced unmatched entry (sourceNodeType !== '') must survive
// the workflow-level filter. This ensures the simplification does not
// over-filter legitimate per-node missing models.
const candidates = [
makeCandidate('node_model.safetensors', { nodeId: '1' })
]
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: 'node_model.safetensors',
url: 'https://example.com/node_model',
directory: 'checkpoints'
}
]
},
widgets_values: { ckpt_name: 'node_model.safetensors' }
}
],
links: [],
groups: [],
config: {},
extra: {},
version: 0.4,
models: []
})
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysMissing
)
expect(result).toHaveLength(1)
expect(result[0].name).toBe('node_model.safetensors')
})
it('skips embedded models from bypassed nodes', async () => {
const candidates: MissingModelCandidate[] = []
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: 4, // BYPASS
properties: {},
widgets_values: { ckpt_name: 'model.safetensors' }
}
],
links: [],
groups: [],
config: {},
extra: {},
version: 0.4,
models: [
{
name: 'model.safetensors',
url: 'https://example.com/model',
directory: 'checkpoints'
}
]
})
const result = await enrichWithEmbeddedMetadata(
candidates,
graphData,
alwaysMissing
)
expect(result).toHaveLength(0)
})
})
describe('OSS missing model detection (non-Cloud path)', () => {

View File

@@ -13,6 +13,7 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
// eslint-disable-next-line import-x/no-restricted-paths
import { getSelectedModelsMetadata } from '@/workbench/utils/modelMetadataUtil'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type {
IAssetWidget,
IBaseWidget,
@@ -22,6 +23,7 @@ import {
collectAllNodes,
getExecutionIdByNode
} from '@/utils/graphTraversalUtil'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { resolveComboValues } from '@/utils/litegraphUtil'
function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
@@ -73,27 +75,54 @@ export function scanAllModelCandidates(
// Skip subgraph container nodes: their promoted widgets are synthetic
// views of interior widgets, which are already scanned via recursion.
if (node.isSubgraphNode?.()) continue
if (
node.mode === LGraphEventMode.NEVER ||
node.mode === LGraphEventMode.BYPASS
)
continue
const executionId = getExecutionIdByNode(rootGraph, node)
if (!executionId) continue
candidates.push(
...scanNodeModelCandidates(
rootGraph,
node,
isAssetSupported,
getDirectory
)
)
}
for (const widget of node.widgets) {
let candidate: MissingModelCandidate | null = null
return candidates
}
if (isAssetWidget(widget)) {
candidate = scanAssetWidget(node, widget, executionId, getDirectory)
} else if (isComboWidget(widget)) {
candidate = scanComboWidget(
node,
widget,
executionId,
isAssetSupported,
getDirectory
)
}
/** Scan a single node's widgets for missing model candidates (OSS immediate resolution). */
export function scanNodeModelCandidates(
rootGraph: LGraph,
node: LGraphNode,
isAssetSupported: (nodeType: string, widgetName: string) => boolean,
getDirectory?: (nodeType: string) => string | undefined
): MissingModelCandidate[] {
if (!node.widgets?.length) return []
if (candidate) candidates.push(candidate)
const executionId = getExecutionIdByNode(rootGraph, node)
if (!executionId) return []
const candidates: MissingModelCandidate[] = []
for (const widget of node.widgets) {
let candidate: MissingModelCandidate | null = null
if (isAssetWidget(widget)) {
candidate = scanAssetWidget(node, widget, executionId, getDirectory)
} else if (isComboWidget(widget)) {
candidate = scanComboWidget(
node,
widget,
executionId,
isAssetSupported,
getDirectory
)
}
if (candidate) candidates.push(candidate)
}
return candidates
@@ -197,8 +226,18 @@ export async function enrichWithEmbeddedMetadata(
}
}
// Workflow-level entries (sourceNodeType === '') survive only when
// some active (non-muted, non-bypassed) node actually references the
// model — not merely because any unrelated active node exists. A
// reference is any widget value (or node.properties.models entry)
// that matches the model name on an active node.
const activeUnmatched = unmatched.filter(
(m) =>
m.sourceNodeType !== '' || isModelReferencedByActiveNode(m.name, allNodes)
)
const settled = await Promise.allSettled(
unmatched.map(async (model) => {
activeUnmatched.map(async (model) => {
const installed = await checkModelInstalled(model.name, model.directory)
if (installed) return null
@@ -235,6 +274,32 @@ export async function enrichWithEmbeddedMetadata(
return enriched
}
function isModelReferencedByActiveNode(
modelName: string,
allNodes: ReturnType<typeof flattenWorkflowNodes>
): boolean {
for (const node of allNodes) {
if (
node.mode === LGraphEventMode.NEVER ||
node.mode === LGraphEventMode.BYPASS
)
continue
const embeddedModels = (
node.properties as { models?: Array<{ name: string }> } | undefined
)?.models
if (embeddedModels?.some((m) => m.name === modelName)) return true
const values = node.widgets_values
if (!values) continue
const valueArray = Array.isArray(values) ? values : Object.values(values)
for (const v of valueArray) {
if (typeof v === 'string' && v === modelName) return true
}
}
return false
}
function collectEmbeddedModelsWithSource(
allNodes: ReturnType<typeof flattenWorkflowNodes>,
graphData: ComfyWorkflowJSON
@@ -242,6 +307,12 @@ function collectEmbeddedModelsWithSource(
const result: EmbeddedModelWithSource[] = []
for (const node of allNodes) {
if (
node.mode === LGraphEventMode.NEVER ||
node.mode === LGraphEventMode.BYPASS
)
continue
const selected = getSelectedModelsMetadata(
node as Parameters<typeof getSelectedModelsMetadata>[0]
)

View File

@@ -244,4 +244,218 @@ describe('missingModelStore', () => {
expect(store.missingModelCandidates).toHaveLength(1)
})
})
describe('addMissingModels', () => {
it('appends to existing candidates', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('model_a.safetensors', { nodeId: '1' })
])
store.addMissingModels([
makeModelCandidate('model_b.safetensors', { nodeId: '2' })
])
expect(store.missingModelCandidates).toHaveLength(2)
expect(store.missingModelCandidates![0].name).toBe('model_a.safetensors')
expect(store.missingModelCandidates![1].name).toBe('model_b.safetensors')
})
it('works when store is empty (candidates are null)', () => {
const store = useMissingModelStore()
expect(store.missingModelCandidates).toBeNull()
store.addMissingModels([
makeModelCandidate('model_a.safetensors', { nodeId: '1' })
])
expect(store.missingModelCandidates).toHaveLength(1)
expect(store.hasMissingModels).toBe(true)
})
it('does nothing when given empty array', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('model_a.safetensors', { nodeId: '1' })
])
store.addMissingModels([])
expect(store.missingModelCandidates).toHaveLength(1)
})
})
describe('removeMissingModelsByNodeId', () => {
it('removes all candidates matching the nodeId', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('model_a.safetensors', {
nodeId: '1',
widgetName: 'ckpt_name'
}),
makeModelCandidate('model_b.safetensors', {
nodeId: '1',
widgetName: 'vae_name'
}),
makeModelCandidate('model_c.safetensors', { nodeId: '2' })
])
store.removeMissingModelsByNodeId('1')
expect(store.missingModelCandidates).toHaveLength(1)
expect(store.missingModelCandidates![0].name).toBe('model_c.safetensors')
})
it('keeps candidates with non-matching nodeId', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('model_a.safetensors', { nodeId: '1' }),
makeModelCandidate('model_b.safetensors', { nodeId: '2' })
])
store.removeMissingModelsByNodeId('99')
expect(store.missingModelCandidates).toHaveLength(2)
})
it('sets candidates to null when all are removed', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('model_a.safetensors', { nodeId: '1' }),
makeModelCandidate('model_b.safetensors', { nodeId: '1' })
])
store.removeMissingModelsByNodeId('1')
expect(store.missingModelCandidates).toBeNull()
expect(store.hasMissingModels).toBe(false)
})
it('does nothing when candidates are null', () => {
const store = useMissingModelStore()
store.removeMissingModelsByNodeId('1')
expect(store.missingModelCandidates).toBeNull()
})
})
describe('removeMissingModelsByPrefix', () => {
it('removes all candidates whose nodeId starts with the prefix', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('a.safetensors', { nodeId: '65:70:63' }),
makeModelCandidate('b.safetensors', { nodeId: '65:70:64' }),
makeModelCandidate('c.safetensors', { nodeId: '65:80:5' })
])
store.removeMissingModelsByPrefix('65:70:')
expect(store.missingModelCandidates).toHaveLength(1)
expect(store.missingModelCandidates![0].nodeId).toBe('65:80:5')
})
it('removes deeply nested interior nodes under the container', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('a.safetensors', { nodeId: '65:70:63' }),
makeModelCandidate('b.safetensors', { nodeId: '65:70:80:5' }),
makeModelCandidate('c.safetensors', { nodeId: '65:71:63' })
])
store.removeMissingModelsByPrefix('65:70:')
expect(store.missingModelCandidates).toHaveLength(1)
expect(store.missingModelCandidates![0].nodeId).toBe('65:71:63')
})
it('does not match siblings that share a numeric prefix (trailing colon)', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('a.safetensors', { nodeId: '65:70:1' }),
makeModelCandidate('b.safetensors', { nodeId: '65:705:1' }),
makeModelCandidate('c.safetensors', { nodeId: '65:70' })
])
store.removeMissingModelsByPrefix('65:70:')
expect(store.missingModelCandidates).toHaveLength(2)
const remainingIds = store.missingModelCandidates!.map((m) =>
String(m.nodeId)
)
expect(remainingIds).toContain('65:705:1')
expect(remainingIds).toContain('65:70')
})
it('sets candidates to null when all are removed', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('a.safetensors', { nodeId: '65:70:63' }),
makeModelCandidate('b.safetensors', { nodeId: '65:70:64' })
])
store.removeMissingModelsByPrefix('65:70:')
expect(store.missingModelCandidates).toBeNull()
expect(store.hasMissingModels).toBe(false)
})
it('does nothing when no candidates match', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('a.safetensors', { nodeId: '65:71:1' })
])
store.removeMissingModelsByPrefix('65:70:')
expect(store.missingModelCandidates).toHaveLength(1)
})
it('does nothing when candidates are null', () => {
const store = useMissingModelStore()
store.removeMissingModelsByPrefix('65:70:')
expect(store.missingModelCandidates).toBeNull()
})
it('preserves workflow-level candidates without a nodeId', () => {
const store = useMissingModelStore()
const workflowLevel: MissingModelCandidate = {
name: 'workflow-level.safetensors',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
isMissing: true
}
store.setMissingModels([
makeModelCandidate('a.safetensors', { nodeId: '65:70:63' }),
workflowLevel
])
store.removeMissingModelsByPrefix('65:70:')
expect(store.missingModelCandidates).toHaveLength(1)
expect(store.missingModelCandidates![0].name).toBe(
'workflow-level.safetensors'
)
})
it('clears interaction state for removed names not used elsewhere', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('shared.safetensors', { nodeId: '65:70:63' }),
makeModelCandidate('shared.safetensors', { nodeId: '65:80:5' }),
makeModelCandidate('only-interior.safetensors', { nodeId: '65:70:64' })
])
store.urlInputs['shared.safetensors'] = 'https://example.com/shared'
store.urlInputs['only-interior.safetensors'] =
'https://example.com/interior'
store.removeMissingModelsByPrefix('65:70:')
// 'only-interior' fully removed → interaction state cleared.
// 'shared' still referenced by 65:80:5 → interaction state preserved.
expect(store.urlInputs['only-interior.safetensors']).toBeUndefined()
expect(store.urlInputs['shared.safetensors']).toBe(
'https://example.com/shared'
)
})
})
})

View File

@@ -128,6 +128,85 @@ export const useMissingModelStore = defineStore('missingModel', () => {
missingModelCandidates.value = null
}
function clearInteractionStateForName(name: string) {
delete modelExpandState.value[name]
delete selectedLibraryModel.value[name]
delete importCategoryMismatch.value[name]
delete importTaskIds.value[name]
delete urlInputs.value[name]
delete urlMetadata.value[name]
delete urlFetching.value[name]
delete urlErrors.value[name]
delete urlImporting.value[name]
}
function removeMissingModelsByNodeId(nodeId: string) {
if (!missingModelCandidates.value) return
const removedNames = new Set(
missingModelCandidates.value
.filter((m) => String(m.nodeId) === nodeId)
.map((m) => m.name)
)
missingModelCandidates.value = missingModelCandidates.value.filter(
(m) => String(m.nodeId) !== nodeId
)
for (const name of removedNames) {
if (!missingModelCandidates.value.some((m) => m.name === name)) {
clearInteractionStateForName(name)
}
}
if (!missingModelCandidates.value.length)
missingModelCandidates.value = null
}
/**
* Remove all candidates whose nodeId starts with `prefix`.
*
* Intended for clearing all interior errors when a subgraph container is
* removed. Callers are expected to pass `${execId}:` (with trailing
* colon) so that sibling IDs sharing a numeric prefix (e.g. `"705"` vs
* `"70"`) are not matched.
*/
function removeMissingModelsByPrefix(prefix: string) {
if (!missingModelCandidates.value) return
const removedNames = new Set<string>()
const remaining: MissingModelCandidate[] = []
for (const m of missingModelCandidates.value) {
// Preserve workflow-level candidates with no nodeId; they are not
// tied to any subgraph scope and should never be matched by prefix.
if (m.nodeId == null) {
remaining.push(m)
continue
}
if (String(m.nodeId).startsWith(prefix)) {
removedNames.add(m.name)
} else {
remaining.push(m)
}
}
if (removedNames.size === 0) return
missingModelCandidates.value = remaining.length ? remaining : null
for (const name of removedNames) {
if (!remaining.some((m) => m.name === name)) {
clearInteractionStateForName(name)
}
}
}
function addMissingModels(models: MissingModelCandidate[]) {
if (!models.length) return
const existing = missingModelCandidates.value ?? []
const existingKeys = new Set(
existing.map((m) => `${String(m.nodeId)}::${m.widgetName}::${m.name}`)
)
const newModels = models.filter(
(m) =>
!existingKeys.has(`${String(m.nodeId)}::${m.widgetName}::${m.name}`)
)
if (!newModels.length) return
missingModelCandidates.value = [...existing, ...newModels]
}
function hasMissingModelOnNode(nodeLocatorId: string): boolean {
return missingModelNodeIds.value.has(nodeLocatorId)
}
@@ -200,8 +279,11 @@ export const useMissingModelStore = defineStore('missingModel', () => {
missingModelAncestorExecutionIds,
setMissingModels,
addMissingModels,
removeMissingModelByNameOnNodes,
removeMissingModelByWidget,
removeMissingModelsByNodeId,
removeMissingModelsByPrefix,
clearMissingModels,
createVerificationAbortController,

View File

@@ -57,7 +57,7 @@ import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNod
function mockNode(
id: number,
type: string,
overrides: Partial<LGraphNode> = {}
overrides: Record<string, unknown> = {}
): LGraphNode {
return fromAny<LGraphNode, unknown>({
id,
@@ -215,6 +215,47 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
expect(typeof missing !== 'string' && missing.isReplaceable).toBe(false)
})
it('skips muted nodes (mode NEVER = 2)', () => {
vi.mocked(collectAllNodes).mockReturnValue([
mockNode(1, 'MutedNode', { mode: 2 })
])
vi.mocked(getExecutionIdByNode).mockReturnValue(null)
rescanAndSurfaceMissingNodes(mockGraph())
const store = useMissingNodesErrorStore()
expect(store.missingNodesError).toBeNull()
})
it('skips bypassed nodes (mode BYPASS = 4)', () => {
vi.mocked(collectAllNodes).mockReturnValue([
mockNode(1, 'BypassedNode', { mode: 4 })
])
vi.mocked(getExecutionIdByNode).mockReturnValue(null)
rescanAndSurfaceMissingNodes(mockGraph())
const store = useMissingNodesErrorStore()
expect(store.missingNodesError).toBeNull()
})
it('detects active nodes (mode ALWAYS = 0) as missing', () => {
vi.mocked(collectAllNodes).mockReturnValue([
mockNode(1, 'ActiveMissingNode', { mode: 0 })
])
vi.mocked(getExecutionIdByNode).mockReturnValue(null)
rescanAndSurfaceMissingNodes(mockGraph())
const store = useMissingNodesErrorStore()
const error = getMissingNodesError(store)
expect(error.nodeTypes).toHaveLength(1)
const missing = error.nodeTypes[0]
expect(typeof missing !== 'string' && missing.type).toBe(
'ActiveMissingNode'
)
})
it('uses last_serialization.type over node.type', () => {
const node = mockNode(1, 'LiveType')
node.last_serialization = fromPartial<LGraphNode['last_serialization']>({

View File

@@ -1,5 +1,6 @@
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
@@ -18,6 +19,12 @@ function scanMissingNodes(rootGraph: LGraph): MissingNodeType[] {
const allNodes = collectAllNodes(rootGraph)
for (const node of allNodes) {
if (
node.mode === LGraphEventMode.NEVER ||
node.mode === LGraphEventMode.BYPASS
)
continue
const originalType = node.last_serialization?.type ?? node.type ?? 'Unknown'
if (originalType in LiteGraph.registered_node_types) continue

View File

@@ -212,4 +212,146 @@ describe('missingNodesErrorStore', () => {
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
})
})
describe('removeMissingNodesByNodeId', () => {
it('removes entries matching the nodeId', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeB', nodeId: '2', isReplaceable: false }
])
store.removeMissingNodesByNodeId('1')
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
const remaining = store.missingNodesError?.nodeTypes[0]
expect(typeof remaining !== 'string' && remaining?.nodeId).toBe('2')
})
it('keeps string entries (they have no nodeId)', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
'StringNode',
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
] as MissingNodeType[])
store.removeMissingNodesByNodeId('1')
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
expect(store.missingNodesError?.nodeTypes[0]).toBe('StringNode')
})
it('keeps entries with different nodeIds', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
{ type: 'NodeB', nodeId: '2', isReplaceable: false },
{ type: 'NodeC', nodeId: '3', isReplaceable: false }
])
store.removeMissingNodesByNodeId('2')
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
})
it('clears missingNodesError when all object entries are removed', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
])
store.removeMissingNodesByNodeId('1')
expect(store.missingNodesError).toBeNull()
expect(store.hasMissingNodes).toBe(false)
})
it('does nothing when missingNodesError is null', () => {
const store = useMissingNodesErrorStore()
store.removeMissingNodesByNodeId('1')
expect(store.missingNodesError).toBeNull()
})
})
describe('removeMissingNodesByPrefix', () => {
it('removes object entries whose nodeId starts with the prefix', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'A', nodeId: '65:70:63', isReplaceable: false },
{ type: 'B', nodeId: '65:70:64', isReplaceable: false },
{ type: 'C', nodeId: '65:80:5', isReplaceable: false }
])
store.removeMissingNodesByPrefix('65:70:')
const remaining = store.missingNodesError?.nodeTypes ?? []
expect(remaining).toHaveLength(1)
const first = remaining[0]
expect(typeof first !== 'string' && first.nodeId).toBe('65:80:5')
})
it('removes deeply nested interior entries', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'A', nodeId: '65:70:63', isReplaceable: false },
{ type: 'B', nodeId: '65:70:80:5', isReplaceable: false },
{ type: 'C', nodeId: '65:71:63', isReplaceable: false }
])
store.removeMissingNodesByPrefix('65:70:')
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
})
it('does not match siblings sharing a numeric prefix (trailing colon)', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'A', nodeId: '65:70:1', isReplaceable: false },
{ type: 'B', nodeId: '65:705:1', isReplaceable: false },
{ type: 'C', nodeId: '65:70', isReplaceable: false }
])
store.removeMissingNodesByPrefix('65:70:')
const remaining = store.missingNodesError?.nodeTypes ?? []
expect(remaining).toHaveLength(2)
const remainingIds = remaining.map((n) =>
typeof n === 'string' ? n : String(n.nodeId)
)
expect(remainingIds).toContain('65:705:1')
expect(remainingIds).toContain('65:70')
})
it('preserves string entries (no nodeId)', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
'StringNode',
{ type: 'A', nodeId: '65:70:1', isReplaceable: false }
] as MissingNodeType[])
store.removeMissingNodesByPrefix('65:70:')
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
expect(store.missingNodesError?.nodeTypes[0]).toBe('StringNode')
})
it('clears missingNodesError when all matching entries are removed and none remain', () => {
const store = useMissingNodesErrorStore()
store.setMissingNodeTypes([
{ type: 'A', nodeId: '65:70:63', isReplaceable: false },
{ type: 'B', nodeId: '65:70:64', isReplaceable: false }
])
store.removeMissingNodesByPrefix('65:70:')
expect(store.missingNodesError).toBeNull()
expect(store.hasMissingNodes).toBe(false)
})
it('does nothing when missingNodesError is null', () => {
const store = useMissingNodesErrorStore()
store.removeMissingNodesByPrefix('65:70:')
expect(store.missingNodesError).toBeNull()
})
})
})

View File

@@ -64,6 +64,33 @@ export const useMissingNodesErrorStore = defineStore(
)
}
function removeMissingNodesByNodeId(nodeId: string) {
if (!missingNodesError.value) return
const remaining = missingNodesError.value.nodeTypes.filter((node) => {
if (typeof node === 'string') return true
return node.nodeId !== nodeId
})
setMissingNodeTypes(remaining)
}
/**
* Remove all object-type entries whose nodeId starts with `prefix`.
* String entries (group nodes) have no nodeId and are preserved.
*
* Intended for clearing all interior errors when a subgraph container
* is removed. Callers are expected to pass `${execId}:` (with trailing
* colon) so that sibling IDs sharing a numeric prefix are not matched.
*/
function removeMissingNodesByPrefix(prefix: string) {
if (!missingNodesError.value) return
const remaining = missingNodesError.value.nodeTypes.filter((node) => {
if (typeof node === 'string') return true
if (node.nodeId == null) return true
return !String(node.nodeId).startsWith(prefix)
})
setMissingNodeTypes(remaining)
}
/** Remove specific node types from the missing nodes list (e.g. after replacement). */
function removeMissingNodesByType(typesToRemove: string[]) {
if (!missingNodesError.value) return
@@ -115,6 +142,8 @@ export const useMissingNodesErrorStore = defineStore(
missingNodesError,
setMissingNodeTypes,
surfaceMissingNodes,
removeMissingNodesByNodeId,
removeMissingNodesByPrefix,
removeMissingNodesByType,
hasMissingNodes,
missingNodeCount,

View File

@@ -13,6 +13,9 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workfl
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import { app } from '@/scripts/app'
import { useAppMode } from '@/composables/useAppMode'
import type { AppMode } from '@/composables/useAppMode'
@@ -115,6 +118,12 @@ vi.mock('@/stores/domWidgetStore', () => ({
})
}))
vi.mock('@/stores/subgraphNavigationStore', () => ({
useSubgraphNavigationStore: () => ({
saveCurrentViewport: vi.fn()
})
}))
vi.mock('@/stores/workspaceStore', () => ({
useWorkspaceStore: () => ({
get workflow() {
@@ -159,16 +168,16 @@ describe('useWorkflowService', () => {
enableWarningSettings()
})
it('should do nothing when workflow has no pending warnings', () => {
it('should clear missing nodes when workflow has no pending warnings', () => {
const workflow = createWorkflow(null)
useWorkflowService().showPendingWarnings(workflow)
expect(
useMissingNodesErrorStore().surfaceMissingNodes
).not.toHaveBeenCalled()
).toHaveBeenCalledWith([])
})
it('should surface missing nodes and clear warnings', () => {
it('should surface missing nodes and cache warnings', () => {
const missingNodeTypes = ['CustomNode1', 'CustomNode2']
const workflow = createWorkflow({ missingNodeTypes })
@@ -177,7 +186,11 @@ describe('useWorkflowService', () => {
expect(
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledWith(missingNodeTypes)
expect(workflow.pendingWarnings).toBeNull()
expect(workflow.pendingWarnings).toEqual({
missingNodeTypes,
missingModelCandidates: undefined,
missingMediaCandidates: undefined
})
})
it('should always surface missing nodes regardless of settings', () => {
@@ -192,10 +205,10 @@ describe('useWorkflowService', () => {
expect(
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledWith(['CustomNode1'])
expect(workflow.pendingWarnings).toBeNull()
expect(workflow.pendingWarnings).not.toBeNull()
})
it('should only show warnings once across multiple calls', () => {
it('should restore cached warnings on repeated calls', () => {
const workflow = createWorkflow({
missingNodeTypes: ['CustomNode1']
})
@@ -206,7 +219,96 @@ describe('useWorkflowService', () => {
expect(
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledTimes(1)
).toHaveBeenCalledTimes(2)
})
it('should NOT call showErrorOverlay when silent is true even with missing nodes', () => {
vi.spyOn(useSettingStore(), 'get').mockImplementation(
(key: string): boolean => {
if (key === 'Comfy.Workflow.ShowMissingModelsWarning') return true
if (key === 'Comfy.RightSidePanel.ShowErrorsTab') return true
return false
}
)
const workflow = createWorkflow({
missingNodeTypes: ['CustomNode1']
})
useWorkflowService().showPendingWarnings(workflow, { silent: true })
expect(
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledWith(['CustomNode1'])
expect(useExecutionErrorStore().showErrorOverlay).not.toHaveBeenCalled()
})
it('should call showErrorOverlay when silent is false and missing nodes exist', () => {
vi.spyOn(useSettingStore(), 'get').mockImplementation(
(key: string): boolean => {
if (key === 'Comfy.Workflow.ShowMissingModelsWarning') return true
if (key === 'Comfy.RightSidePanel.ShowErrorsTab') return true
return false
}
)
const workflow = createWorkflow({
missingNodeTypes: ['CustomNode1']
})
useWorkflowService().showPendingWarnings(workflow)
expect(
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledWith(['CustomNode1'])
expect(useExecutionErrorStore().showErrorOverlay).toHaveBeenCalled()
})
})
describe('beforeLoadNewGraph', () => {
let workflowStore: ReturnType<typeof useWorkflowStore>
beforeEach(() => {
enableWarningSettings()
workflowStore = useWorkflowStore()
})
it('should cache missingModelCandidates and missingMediaCandidates to activeWorkflow.pendingWarnings', () => {
const activeWorkflow = createModeTestWorkflow({
path: 'workflows/test.json'
})
workflowStore.activeWorkflow = activeWorkflow
const modelCandidates = [
{
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'missing.safetensors',
isMissing: true
}
]
const mediaCandidates = [
{
nodeId: '2',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image' as const,
name: 'photo.png',
isMissing: true
}
]
useMissingModelStore().missingModelCandidates = modelCandidates as never
useMissingMediaStore().missingMediaCandidates = mediaCandidates as never
useWorkflowService().beforeLoadNewGraph()
expect(activeWorkflow.pendingWarnings).toEqual(
expect.objectContaining({
missingModelCandidates: modelCandidates,
missingMediaCandidates: mediaCandidates
})
)
})
})
@@ -245,7 +347,7 @@ describe('useWorkflowService', () => {
expect(
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledWith(['CustomNode1'])
expect(workflow.pendingWarnings).toBeNull()
expect(workflow.pendingWarnings).not.toBeNull()
})
it('should show each workflow warnings only when that tab is focused', async () => {
@@ -267,7 +369,7 @@ describe('useWorkflowService', () => {
expect(
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledWith(['MissingNodeA'])
expect(workflow1.pendingWarnings).toBeNull()
expect(workflow1.pendingWarnings).not.toBeNull()
expect(workflow2.pendingWarnings).not.toBeNull()
await service.openWorkflow(workflow2)
@@ -277,10 +379,10 @@ describe('useWorkflowService', () => {
expect(
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenLastCalledWith(['MissingNodeB'])
expect(workflow2.pendingWarnings).toBeNull()
expect(workflow2.pendingWarnings).not.toBeNull()
})
it('should not show warnings when refocusing a cleared tab', async () => {
it('should restore cached warnings silently when refocusing a tab', async () => {
const workflow = createWorkflow(
{ missingNodeTypes: ['CustomNode1'] },
{ loadable: true }
@@ -294,9 +396,10 @@ describe('useWorkflowService', () => {
).toHaveBeenCalledTimes(1)
await service.openWorkflow(workflow, { force: true })
// Cached warnings are restored on refocus
expect(
useMissingNodesErrorStore().surfaceMissingNodes
).toHaveBeenCalledTimes(1)
).toHaveBeenCalledTimes(2)
})
})

View File

@@ -25,6 +25,8 @@ import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import {
appendJsonExt,
@@ -44,7 +46,6 @@ export const useWorkflowService = () => {
const dialogService = useDialogService()
const workflowThumbnail = useWorkflowThumbnail()
const domWidgetStore = useDomWidgetStore()
const executionErrorStore = useExecutionErrorStore()
const missingNodesErrorStore = useMissingNodesErrorStore()
const workflowDraftStore = useWorkflowDraftStore()
@@ -253,13 +254,14 @@ export const useWorkflowService = () => {
/* restore_view=*/ true,
workflow,
{
showMissingModels: loadFromRemote,
showMissingNodes: true,
checkForRerouteMigration: false,
deferWarnings: true
deferWarnings: true,
skipAssetScans: !loadFromRemote && !options.force
}
)
showPendingWarnings()
showPendingWarnings(undefined, {
silent: !loadFromRemote && !options.force
})
}
/**
@@ -389,6 +391,29 @@ export const useWorkflowService = () => {
}
}
}
// Cache missing model/media/node state for restore on tab switch.
// Always overwrite to reflect the current store state (e.g. after
// muting a node cleared its errors).
const modelCandidates = useMissingModelStore().missingModelCandidates
const mediaCandidates = useMissingMediaStore().missingMediaCandidates
const nodeTypes = missingNodesErrorStore.missingNodesError?.nodeTypes
activeWorkflow.pendingWarnings = {
missingNodeTypes: nodeTypes?.length ? [...nodeTypes] : undefined,
missingModelCandidates: modelCandidates?.length
? modelCandidates
: undefined,
missingMediaCandidates: mediaCandidates?.length
? mediaCandidates
: undefined
}
if (
!activeWorkflow.pendingWarnings.missingNodeTypes &&
!activeWorkflow.pendingWarnings.missingModelCandidates &&
!activeWorkflow.pendingWarnings.missingMediaCandidates
) {
activeWorkflow.pendingWarnings = null
}
// Capture thumbnail before loading new graph
void workflowThumbnail.storeThumbnail(activeWorkflow)
domWidgetStore.clear()
@@ -550,17 +575,43 @@ export const useWorkflowService = () => {
* active workflow. Called after a workflow becomes visible so dialogs don't
* overlap with subsequent loads.
*/
function showPendingWarnings(workflow?: ComfyWorkflow | null) {
function showPendingWarnings(
workflow?: ComfyWorkflow | null,
options?: { silent?: boolean }
) {
const wf = workflow ?? workflowStore.activeWorkflow
if (!wf?.pendingWarnings) return
if (!wf) return
const { missingNodeTypes } = wf.pendingWarnings
wf.pendingWarnings = null
const { missingNodeTypes, missingModelCandidates, missingMediaCandidates } =
wf.pendingWarnings ?? {}
if (missingNodeTypes?.length) {
if (missingNodesErrorStore.surfaceMissingNodes(missingNodeTypes)) {
executionErrorStore.showErrorOverlay()
// Always sync missing nodes store (clear when empty).
if (
missingNodesErrorStore.surfaceMissingNodes(missingNodeTypes ?? []) &&
!options?.silent
) {
useExecutionErrorStore().showErrorOverlay()
}
if (missingModelCandidates?.length) {
useMissingModelStore().setMissingModels(missingModelCandidates)
}
if (missingMediaCandidates?.length) {
useMissingMediaStore().setMissingMedia(missingMediaCandidates)
}
// Keep cache for future tab switches
if (
missingNodeTypes?.length ||
missingModelCandidates?.length ||
missingMediaCandidates?.length
) {
wf.pendingWarnings = {
missingNodeTypes,
missingModelCandidates,
missingMediaCandidates
}
} else {
wf.pendingWarnings = null
}
}

View File

@@ -7,6 +7,7 @@ import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import { UserFile } from '@/stores/userFileStore'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
import type { MissingNodeType } from '@/types/comfy'
export interface LinearData {
@@ -16,9 +17,8 @@ export interface LinearData {
export interface PendingWarnings {
missingNodeTypes?: MissingNodeType[]
// TODO: Currently unused — missing models are surfaced directly on every
// graph load. Reserved for future per-workflow missing model state management.
missingModelCandidates?: MissingModelCandidate[]
missingMediaCandidates?: MissingMediaCandidate[]
}
export class ComfyWorkflow extends UserFile {

View File

@@ -21,6 +21,7 @@ import {
import { snapPoint } from '@/lib/litegraph/src/measure'
import type { Vector2 } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
@@ -86,6 +87,7 @@ import type { NodeExecutionId } from '@/types/nodeIdentification'
import { graphToPrompt } from '@/utils/executionUtil'
import { getCnrIdFromProperties } from '@/platform/nodeReplacement/cnrIdUtil'
import { rescanAndSurfaceMissingNodes } from '@/platform/nodeReplacement/missingNodeScan'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import {
scanAllModelCandidates,
enrichWithEmbeddedMetadata,
@@ -93,6 +95,7 @@ import {
} from '@/platform/missingModel/missingModelScan'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
import {
scanAllMediaCandidates,
verifyCloudMediaCandidates
@@ -1125,24 +1128,35 @@ export class ComfyApp {
restore_view: boolean = true,
workflow: string | null | ComfyWorkflow = null,
options: {
showMissingNodes?: boolean
showMissingModels?: boolean
checkForRerouteMigration?: boolean
openSource?: WorkflowOpenSource
deferWarnings?: boolean
skipAssetScans?: boolean
silentAssetErrors?: boolean
} = {}
) {
const {
showMissingNodes = true,
showMissingModels = true,
checkForRerouteMigration = false,
openSource,
deferWarnings = false
deferWarnings = false,
skipAssetScans = false,
silentAssetErrors = false
} = options
useWorkflowService().beforeLoadNewGraph()
useMissingModelStore().clearMissingModels()
useMissingMediaStore().clearMissingMedia()
if (skipAssetScans) {
// Only reset candidates; preserve UI state (fileSizes, urlInputs, etc.)
// so cached results restored by showPendingWarnings still display sizes.
// Abort any in-flight verification from the outgoing workflow so a late
// result cannot repopulate the store after we've switched workflows.
useMissingModelStore().createVerificationAbortController().abort()
useMissingMediaStore().createVerificationAbortController().abort()
useMissingModelStore().setMissingModels([])
useMissingMediaStore().setMissingMedia([])
} else {
useMissingModelStore().clearMissingModels()
useMissingMediaStore().clearMissingMedia()
}
if (clean !== false) {
// Reset canvas context before configuring a new graph so subgraph UI
@@ -1218,24 +1232,31 @@ export class ComfyApp {
}
for (let n of nodes) {
if (!(n.type in LiteGraph.registered_node_types)) {
const replacement = nodeReplacementStore.getReplacementFor(n.type)
const cnrId = getCnrIdFromProperties(
n.properties as Record<string, unknown> | undefined
)
const executionId = pathPrefix
? `${pathPrefix}:${n.id}`
: String(n.id)
// Always sanitize so configure() can handle unregistered types,
// but only report as missing if the node is active.
const isMuted =
n.mode === LGraphEventMode.NEVER ||
n.mode === LGraphEventMode.BYPASS
if (!isMuted) {
const replacement = nodeReplacementStore.getReplacementFor(n.type)
const cnrId = getCnrIdFromProperties(
n.properties as Record<string, unknown> | undefined
)
const executionId = pathPrefix
? `${pathPrefix}:${n.id}`
: String(n.id)
missingNodeTypes.push({
type: n.type,
nodeId: executionId,
cnrId,
...(displayName && {
hint: t('g.inSubgraph', { name: displayName })
}),
isReplaceable: replacement !== null,
replacement: replacement ?? undefined
})
missingNodeTypes.push({
type: n.type,
nodeId: executionId,
cnrId,
...(displayName && {
hint: t('g.inSubgraph', { name: displayName })
}),
isReplaceable: replacement !== null,
replacement: replacement ?? undefined
})
}
n.type = sanitizeNodeName(n.type)
}
@@ -1415,17 +1436,20 @@ export class ComfyApp {
requestAnimationFrame(() => fitView())
}
await this.runMissingModelPipeline(
graphData,
missingNodeTypes,
showMissingNodes,
showMissingModels
)
if (!skipAssetScans) {
await this.runMissingModelPipeline(
graphData,
missingNodeTypes,
silentAssetErrors
)
await this.runMissingMediaPipeline()
await this.runMissingMediaPipeline(silentAssetErrors)
}
if (!deferWarnings) {
useWorkflowService().showPendingWarnings()
useWorkflowService().showPendingWarnings(undefined, {
silent: silentAssetErrors
})
}
void useSubgraphNavigationStore().updateHash()
@@ -1440,8 +1464,7 @@ export class ComfyApp {
private async runMissingModelPipeline(
graphData: ComfyWorkflowJSON,
missingNodeTypes: MissingNodeType[],
showMissingNodes: boolean,
showMissingModels: boolean
silent: boolean = false
): Promise<{ missingModels: ModelFile[] }> {
const missingModelStore = useMissingModelStore()
@@ -1491,33 +1514,36 @@ export class ComfyApp {
const activeWf = useWorkspaceStore().workflow.activeWorkflow
if (activeWf) {
const warnings: PendingWarnings = {}
if (missingNodeTypes.length && showMissingNodes) {
warnings.missingNodeTypes = missingNodeTypes
}
if (confirmedCandidates.length && showMissingModels) {
warnings.missingModelCandidates = confirmedCandidates
}
if (warnings.missingNodeTypes || warnings.missingModelCandidates) {
activeWf.pendingWarnings = warnings
activeWf.pendingWarnings = {
...activeWf.pendingWarnings,
missingNodeTypes: missingNodeTypes.length
? missingNodeTypes
: undefined,
missingModelCandidates: confirmedCandidates.length
? confirmedCandidates
: undefined
}
this.cleanupPendingWarnings(activeWf)
}
// Intentionally runs on every graph load (including tab switches and
// undo/redo) because missing model state depends on external asset data
// that may change between workflow activations.
if (enrichedCandidates.length) {
if (isCloud) {
const controller = missingModelStore.createVerificationAbortController()
verifyAssetSupportedCandidates(enrichedCandidates, controller.signal)
void verifyAssetSupportedCandidates(
enrichedCandidates,
controller.signal
)
.then(() => {
if (controller.signal.aborted) return
const confirmed = enrichedCandidates.filter(
(c) => c.isMissing === true
)
if (confirmed.length) {
useExecutionErrorStore().surfaceMissingModels(confirmed)
useExecutionErrorStore().surfaceMissingModels(confirmed, {
silent
})
}
this.cacheModelCandidates(activeWf, confirmed)
})
.catch((err) => {
console.warn(
@@ -1537,7 +1563,7 @@ export class ComfyApp {
const controller = missingModelStore.createVerificationAbortController()
const confirmed = enrichedCandidates.filter((c) => c.isMissing === true)
if (confirmed.length) {
api
void api
.getFolderPaths()
.then((paths) => {
if (controller.signal.aborted) return
@@ -1551,7 +1577,10 @@ export class ComfyApp {
})
.finally(() => {
if (controller.signal.aborted) return
useExecutionErrorStore().surfaceMissingModels(confirmed)
useExecutionErrorStore().surfaceMissingModels(confirmed, {
silent
})
this.cacheModelCandidates(activeWf, confirmed)
})
void Promise.allSettled(
@@ -1573,11 +1602,53 @@ export class ComfyApp {
return { missingModels }
}
private async runMissingMediaPipeline(): Promise<void> {
private cleanupPendingWarnings(wf: {
pendingWarnings: PendingWarnings | null
}) {
if (
!wf.pendingWarnings?.missingNodeTypes &&
!wf.pendingWarnings?.missingModelCandidates &&
!wf.pendingWarnings?.missingMediaCandidates
) {
wf.pendingWarnings = null
}
}
private cacheModelCandidates(
wf: ComfyWorkflow | null,
confirmed: MissingModelCandidate[]
) {
if (!wf) return
wf.pendingWarnings = {
...wf.pendingWarnings,
missingModelCandidates: confirmed.length ? confirmed : undefined
}
this.cleanupPendingWarnings(wf)
}
private cacheMediaCandidates(
wf: ComfyWorkflow | null,
confirmed: MissingMediaCandidate[]
) {
if (!wf) return
wf.pendingWarnings = {
...wf.pendingWarnings,
missingMediaCandidates: confirmed.length ? confirmed : undefined
}
this.cleanupPendingWarnings(wf)
}
private async runMissingMediaPipeline(
silent: boolean = false
): Promise<void> {
const missingMediaStore = useMissingMediaStore()
const activeWf = useWorkspaceStore().workflow.activeWorkflow
const candidates = scanAllMediaCandidates(this.rootGraph, isCloud)
if (!candidates.length) return
if (!candidates.length) {
this.cacheMediaCandidates(activeWf, [])
return
}
if (isCloud) {
const controller = missingMediaStore.createVerificationAbortController()
@@ -1586,8 +1657,9 @@ export class ComfyApp {
if (controller.signal.aborted) return
const confirmed = candidates.filter((c) => c.isMissing === true)
if (confirmed.length) {
useExecutionErrorStore().surfaceMissingMedia(confirmed)
useExecutionErrorStore().surfaceMissingMedia(confirmed, { silent })
}
this.cacheMediaCandidates(activeWf, confirmed)
})
.catch((err) => {
console.warn(
@@ -1606,8 +1678,9 @@ export class ComfyApp {
} else {
const confirmed = candidates.filter((c) => c.isMissing === true)
if (confirmed.length) {
useExecutionErrorStore().surfaceMissingMedia(confirmed)
useExecutionErrorStore().surfaceMissingMedia(confirmed, { silent })
}
this.cacheMediaCandidates(activeWf, confirmed)
}
}

View File

@@ -165,9 +165,8 @@ export class ChangeTracker {
this._restoringState = true
try {
await app.loadGraphData(prevState, false, false, this.workflow, {
showMissingModels: false,
showMissingNodes: false,
checkForRerouteMigration: false
checkForRerouteMigration: false,
silentAssetErrors: true
})
this.activeState = prevState
this.updateModified()

View File

@@ -351,6 +351,142 @@ describe('executionErrorStore — node error operations', () => {
})
})
describe('surfaceMissingModels — silent option', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockShowErrorsTab.value = true
})
it('opens error overlay when silent is not specified and setting is enabled', () => {
const store = useExecutionErrorStore()
store.surfaceMissingModels([
fromAny({
name: 'model.safetensors',
nodeId: '1',
nodeType: 'Loader',
widgetName: 'ckpt',
isMissing: true,
isAssetSupported: false
})
])
expect(store.isErrorOverlayOpen).toBe(true)
})
it('opens error overlay when silent is false and setting is enabled', () => {
const store = useExecutionErrorStore()
store.surfaceMissingModels(
[
fromAny({
name: 'model.safetensors',
nodeId: '1',
nodeType: 'Loader',
widgetName: 'ckpt',
isMissing: true,
isAssetSupported: false
})
],
{ silent: false }
)
expect(store.isErrorOverlayOpen).toBe(true)
})
it('does NOT open error overlay when silent is true', () => {
const store = useExecutionErrorStore()
store.surfaceMissingModels(
[
fromAny({
name: 'model.safetensors',
nodeId: '1',
nodeType: 'Loader',
widgetName: 'ckpt',
isMissing: true,
isAssetSupported: false
})
],
{ silent: true }
)
expect(store.isErrorOverlayOpen).toBe(false)
})
it('does NOT open error overlay for empty models even without silent', () => {
const store = useExecutionErrorStore()
store.surfaceMissingModels([])
expect(store.isErrorOverlayOpen).toBe(false)
})
})
describe('surfaceMissingMedia — silent option', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockShowErrorsTab.value = true
})
it('opens error overlay when silent is not specified and setting is enabled', () => {
const store = useExecutionErrorStore()
store.surfaceMissingMedia([
fromAny({
name: 'photo.png',
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
isMissing: true
})
])
expect(store.isErrorOverlayOpen).toBe(true)
})
it('opens error overlay when silent is false and setting is enabled', () => {
const store = useExecutionErrorStore()
store.surfaceMissingMedia(
[
fromAny({
name: 'photo.png',
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
isMissing: true
})
],
{ silent: false }
)
expect(store.isErrorOverlayOpen).toBe(true)
})
it('does NOT open error overlay when silent is true', () => {
const store = useExecutionErrorStore()
store.surfaceMissingMedia(
[
fromAny({
name: 'photo.png',
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
isMissing: true
})
],
{ silent: true }
)
expect(store.isErrorOverlayOpen).toBe(false)
})
it('does NOT open error overlay for empty media even without silent', () => {
const store = useExecutionErrorStore()
store.surfaceMissingMedia([])
expect(store.isErrorOverlayOpen).toBe(false)
})
})
describe('clearAllErrors', () => {
let executionErrorStore: ReturnType<typeof useExecutionErrorStore>
let missingNodesStore: ReturnType<typeof useMissingNodesErrorStore>

View File

@@ -163,10 +163,14 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
missingMediaStore.removeMissingMediaByWidget(executionId, widgetName)
}
/** Set missing models and open the error overlay if the Errors tab is enabled. */
function surfaceMissingModels(models: MissingModelCandidate[]) {
/** Set missing models and optionally open the error overlay. */
function surfaceMissingModels(
models: MissingModelCandidate[],
options?: { silent?: boolean }
) {
missingModelStore.setMissingModels(models)
if (
!options?.silent &&
models.length &&
useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')
) {
@@ -174,10 +178,14 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
}
}
/** Set missing media and open the error overlay if the Errors tab is enabled. */
function surfaceMissingMedia(media: MissingMediaCandidate[]) {
/** Set missing media and optionally open the error overlay. */
function surfaceMissingMedia(
media: MissingMediaCandidate[],
options?: { silent?: boolean }
) {
missingMediaStore.setMissingMedia(media)
if (
!options?.silent &&
media.length &&
useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')
) {

View File

@@ -28,7 +28,8 @@ import {
traverseSubgraphPath,
triggerCallbackOnAllNodes,
visitGraphNodes,
getExecutionIdByNode
getExecutionIdByNode,
getExecutionIdForNodeInGraph
} from '@/utils/graphTraversalUtil'
import { createMockLGraphNode } from './__tests__/litegraphTestUtils'
@@ -642,6 +643,86 @@ describe('graphTraversalUtil', () => {
})
})
describe('getExecutionIdForNodeInGraph', () => {
it('returns local id when graph is rootGraph', () => {
const node = createMockNode('42')
const rootGraph = createMockGraph([node])
expect(getExecutionIdForNodeInGraph(rootGraph, rootGraph, '42')).toBe(
'42'
)
})
it('returns local id when graph.isRootGraph is true', () => {
const node = createMockNode('42')
const rootGraph = createMockGraph([node])
const otherRoot = createMockGraph([])
expect(getExecutionIdForNodeInGraph(otherRoot, rootGraph, '42')).toBe(
'42'
)
})
it('builds parentPath:nodeId for a single-level subgraph', () => {
const interior = createMockNode('63')
const subgraph = createMockSubgraph('sub-uuid', [interior])
const subgraphNode = createMockNode('65', {
isSubgraph: true,
subgraph
})
const rootGraph = createMockGraph([subgraphNode])
expect(getExecutionIdForNodeInGraph(rootGraph, subgraph, '63')).toBe(
'65:63'
)
})
it('builds nested parentPath:nodeId for deeply-nested subgraph', () => {
const interior = createMockNode('999')
const deep = createMockSubgraph('deep', [interior])
const midNode = createMockNode('456', {
isSubgraph: true,
subgraph: deep
})
const mid = createMockSubgraph('mid', [midNode])
const topNode = createMockNode('123', {
isSubgraph: true,
subgraph: mid
})
const rootGraph = createMockGraph([topNode])
expect(getExecutionIdForNodeInGraph(rootGraph, deep, '999')).toBe(
'123:456:999'
)
})
it('works when node is detached (node.graph = null)', () => {
// This is the primary use case — onNodeRemoved fires after
// LiteGraph nulls node.graph, but the hook closure still has
// the local graph instance, which is enough.
const interior = createMockNode('63')
const subgraph = createMockSubgraph('sub-uuid', [interior])
const subgraphNode = createMockNode('65', {
isSubgraph: true,
subgraph
})
const rootGraph = createMockGraph([subgraphNode])
interior.graph = null as unknown as LGraph
expect(
getExecutionIdForNodeInGraph(rootGraph, subgraph, interior.id)
).toBe('65:63')
})
it('falls back to local id when graph is not reachable from root', () => {
const interior = createMockNode('63')
const orphanSubgraph = createMockSubgraph('orphan', [interior])
const rootGraph = createMockGraph([])
expect(
getExecutionIdForNodeInGraph(rootGraph, orphanSubgraph, '63')
).toBe('63')
})
})
describe('getExecutionIdFromNodeData', () => {
it('should return the correct execution ID for a normal node', () => {
const node = createMockNode('123')

View File

@@ -362,6 +362,27 @@ export function getExecutionIdByNode(
return `${parentPath}:${node.id}`
}
/**
* Returns the execution ID for a node identified by its (graph, nodeId) pair.
*
* Unlike {@link getExecutionIdByNode}, this does not rely on `node.graph`.
* Use this when the node reference may be detached (e.g. inside
* `onNodeRemoved`, which LiteGraph fires after clearing `node.graph`).
*
* @param rootGraph - The root graph to resolve from
* @param graph - The graph the node currently lives in (or lived in)
* @param nodeId - The local node ID within `graph`
*/
export function getExecutionIdForNodeInGraph(
rootGraph: LGraph,
graph: LGraph | Subgraph,
nodeId: string | number
): string {
if (graph === rootGraph || graph.isRootGraph) return String(nodeId)
const parentPath = findPartialExecutionPathToGraph(graph as LGraph, rootGraph)
return parentPath !== undefined ? `${parentPath}:${nodeId}` : String(nodeId)
}
/**
* Returns the execution ID for a node described by plain data (id + subgraphId),
* without requiring a pre-existing {@link LGraphNode} reference.