mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
[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:
34
browser_tests/assets/missing/missing_media_bypassed.json
Normal file
34
browser_tests/assets/missing/missing_media_bypassed.json
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"id": 10,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 50],
|
||||
"pos": [50, 200],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
|
||||
@@ -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
|
||||
|
||||
42
browser_tests/assets/missing/missing_models_bypassed.json
Normal file
42
browser_tests/assets/missing/missing_models_bypassed.json
Normal 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
|
||||
}
|
||||
141
browser_tests/assets/missing/missing_models_in_subgraph.json
Normal file
141
browser_tests/assets/missing/missing_models_in_subgraph.json
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
519
browser_tests/tests/propertiesPanel/errorsTabModeAware.spec.ts
Normal file
519
browser_tests/tests/propertiesPanel/errorsTabModeAware.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
<Button
|
||||
v-else-if="
|
||||
group.type === 'missing_model' &&
|
||||
downloadableModels.length > 0
|
||||
downloadableModels.length > 1
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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 !!(
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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']>({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user