Compare commits

...

9 Commits

Author SHA1 Message Date
jaeone94
26c1fbbf1d [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.
2026-04-14 11:19:15 +09:00
Comfy Org PR Bot
8dd3ee072e [backport cloud/1.43] fix: check server feature flags for progress_text binary format (#11191)
Backport of #10996 to `cloud/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11191-backport-cloud-1-43-fix-check-server-feature-flags-for-progress_text-binary-format-3416d73d36508120abc9fbeeb1bbb447)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
2026-04-13 11:52:07 -07:00
Comfy Org PR Bot
e49d1afa61 [backport cloud/1.43] fix: prevent node context menu from overflowing viewport on desktop (#11123)
Backport of #10854 to `cloud/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11123-backport-cloud-1-43-fix-prevent-node-context-menu-from-overflowing-viewport-on-deskto-33e6d73d365081af9e33d6c04d965e16)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
2026-04-13 11:50:56 -07:00
Comfy Org PR Bot
c7943ca1b6 [backport cloud/1.43] fix: preserve CustomCombo options through clone and paste (#11125)
Backport of #10853 to `cloud/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11125-backport-cloud-1-43-fix-preserve-CustomCombo-options-through-clone-and-paste-33e6d73d3650819c827fe9387fe2b770)
by [Unito](https://www.unito.io)

Co-authored-by: Dante <bunggl@naver.com>
2026-04-13 11:50:24 -07:00
Comfy Org PR Bot
9816951a39 [backport cloud/1.43] fix: debounce reconnecting toast to prevent false-positive banner (#11163)
Backport of #10997 to `cloud/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11163-backport-cloud-1-43-fix-debounce-reconnecting-toast-to-prevent-false-positive-banner-33f6d73d36508148854cd66118b0393c)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-10 18:11:26 -07:00
Comfy Org PR Bot
e7c10aaf77 [backport cloud/1.43] fix: use standard size-4 for blueprint action icons (#11158)
Backport of #10992 to `cloud/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11158-backport-cloud-1-43-fix-use-standard-size-4-for-blueprint-action-icons-33f6d73d36508159921fd8f26e05c1f7)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-04-10 18:05:08 -07:00
Comfy Org PR Bot
1ddc0bb125 [backport cloud/1.43] fix: resolve lint/knip warnings and upgrade oxlint, oxfmt, knip (#11121)
Backport of #10973 to `cloud/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11121-backport-cloud-1-43-fix-resolve-lint-knip-warnings-and-upgrade-oxlint-oxfmt-knip-33e6d73d36508166a438fd292a7bb302)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
2026-04-10 18:22:06 +00:00
Comfy Org PR Bot
fe8dc17d2d [backport cloud/1.43] fix: use || instead of ?? and server type in WebcamCapture upload path (#11005)
Backport of #11000 to `cloud/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11005-backport-cloud-1-43-fix-use-instead-of-and-server-type-in-WebcamCapture-upload--33d6d73d36508107866fc428296020c1)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-04-09 23:17:40 +00:00
Comfy Org PR Bot
352f5a0cd4 [backport cloud/1.43] fix: use cloud assets for asset widget default value (#10986)
Backport of #10983 to `cloud/1.43`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10986-backport-cloud-1-43-fix-use-cloud-assets-for-asset-widget-default-value-33d6d73d365081f0bb1ee04f90d87299)
by [Unito](https://www.unito.io)

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
2026-04-09 06:53:12 +00:00
69 changed files with 4734 additions and 635 deletions

View File

@@ -64,6 +64,7 @@
]
}
],
"no-unsafe-optional-chaining": "error",
"no-self-assign": "allow",
"no-unused-expressions": "off",
"no-unused-private-class-members": "off",
@@ -104,8 +105,7 @@
"allowInterfaces": "always"
}
],
"vue/no-import-compiler-macros": "error",
"vue/no-dupe-keys": "error"
"vue/no-import-compiler-macros": "error"
},
"overrides": [
{

View File

@@ -318,6 +318,9 @@ When referencing Comfy-Org repos:
- Find existing `!important` classes that are interfering with the styling and propose corrections of those instead.
- NEVER use arbitrary percentage values like `w-[80%]` when a Tailwind fraction utility exists
- Use `w-4/5` instead of `w-[80%]`, `w-1/2` instead of `w-[50%]`, etc.
- NEVER use font-size classes (`text-xs`, `text-sm`, etc.) to size `icon-[...]` (iconify) icons
- Iconify icons size via `width`/`height: 1.2em`, so font-size produces unpredictable results
- Use `size-*` classes for explicit sizing, or set font-size on the **parent** container and let `1.2em` scale naturally
## Agent-only rules

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,82 @@
import { expect } from '@playwright/test'
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import {
STABLE_CHECKPOINT,
STABLE_CHECKPOINT_2
} from '@e2e/fixtures/data/assetFixtures'
function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
return { assets, total: assets.length, has_more: false }
}
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT, STABLE_CHECKPOINT_2]
// Stub /api/assets before the app loads. The local ComfyUI backend has no
// /api/assets endpoint (returns 503), which poisons the assets store on
// first load. Narrow pattern avoids intercepting static /assets/*.js bundles.
//
// TODO: Consider moving this stub into ComfyPage fixture for all @cloud tests.
const test = comfyPageFixture.extend<{ stubCloudAssets: void }>({
stubCloudAssets: [
async ({ page }, use) => {
const pattern = '**/api/assets?*'
await page.route(pattern, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(makeAssetsResponse(CLOUD_ASSETS))
})
)
await use()
await page.unroute(pattern)
},
{ auto: true }
]
})
test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
})
test('should use first cloud asset when server default is not in assets', async ({
comfyPage
}) => {
// The default workflow contains a CheckpointLoaderSimple node whose
// server default (from object_info) is a local file not in cloud assets.
// Wait for the existing node's asset widget to mount, confirming the
// assets store has been populated from the stub before adding a new node.
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const node = window.app!.graph.nodes.find(
(n: { type: string }) => n.type === 'CheckpointLoaderSimple'
)
return node?.widgets?.find(
(w: { name: string }) => w.name === 'ckpt_name'
)?.type
}),
{ timeout: 10_000 }
)
.toBe('asset')
// Add a new CheckpointLoaderSimple — should use first cloud asset,
// not the server's object_info default.
const widgetValue = await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('CheckpointLoaderSimple')
window.app!.graph.add(node!)
const widget = node!.widgets?.find(
(w: { name: string }) => w.name === 'ckpt_name'
)
return String(widget?.value ?? '')
})
// Production resolves via getAssetFilename (user_metadata.filename →
// metadata.filename → asset.name). Test fixtures have no metadata
// filename, so asset.name is the resolved value.
expect(widgetValue).toBe(CLOUD_ASSETS[0].name)
})
})

View File

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

View File

@@ -0,0 +1,105 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
test.describe(
'Node context menu viewport overflow (#10824)',
{ tag: '@ui' },
() => {
test.beforeEach(async ({ comfyPage }) => {
// Keep the viewport well below the menu content height so overflow is guaranteed.
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
await comfyPage.nextFrame()
})
async function openMoreOptions(comfyPage: ComfyPage) {
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
if (ksamplerNodes.length === 0) {
throw new Error('No KSampler nodes found')
}
// Drag the KSampler toward the lower-left so the menu has limited space below it.
const nodePos = await ksamplerNodes[0].getPosition()
const viewportSize = comfyPage.page.viewportSize()!
const centerX = viewportSize.width / 3
const centerY = viewportSize.height * 0.75
await comfyPage.canvasOps.dragAndDrop(
{ x: nodePos.x, y: nodePos.y },
{ x: centerX, y: centerY }
)
await comfyPage.nextFrame()
await ksamplerNodes[0].click('title')
await comfyPage.nextFrame()
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible({
timeout: 5000
})
const moreOptionsBtn = comfyPage.page.locator(
'[data-testid="more-options-button"]'
)
await expect(moreOptionsBtn).toBeVisible({ timeout: 3000 })
await moreOptionsBtn.click()
await comfyPage.nextFrame()
const menu = comfyPage.page.locator('.p-contextmenu')
await expect(menu).toBeVisible({ timeout: 3000 })
// Wait for constrainMenuHeight (runs via requestAnimationFrame in onMenuShow)
await comfyPage.nextFrame()
return menu
}
test('last menu item "Remove" is reachable via scroll', async ({
comfyPage
}) => {
const menu = await openMoreOptions(comfyPage)
const rootList = menu.locator(':scope > ul')
await expect
.poll(
() => rootList.evaluate((el) => el.scrollHeight > el.clientHeight),
{
message:
'Menu should overflow vertically so this test exercises the viewport clamp',
timeout: 3000
}
)
.toBe(true)
// "Remove" is the last item in the More Options menu.
// It must become reachable by scrolling the bounded menu list.
const removeItem = menu.getByText('Remove', { exact: true })
const didScroll = await rootList.evaluate((el) => {
const previousScrollTop = el.scrollTop
el.scrollTo({ top: el.scrollHeight })
return el.scrollTop > previousScrollTop
})
expect(didScroll).toBe(true)
await expect(removeItem).toBeVisible()
})
test('last menu item "Remove" is clickable and removes the node', async ({
comfyPage
}) => {
const menu = await openMoreOptions(comfyPage)
const removeItem = menu.getByText('Remove', { exact: true })
await removeItem.scrollIntoViewIfNeeded()
await removeItem.click()
await comfyPage.nextFrame()
// The node should be removed from the graph
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 3000 })
.toBe(0)
})
}
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -1,6 +1,7 @@
import type { KnipConfig } from 'knip'
const config: KnipConfig = {
treatConfigHintsAsErrors: true,
workspaces: {
'.': {
entry: [
@@ -33,11 +34,9 @@ const config: KnipConfig = {
'src/pages/**/*.astro',
'src/layouts/**/*.astro',
'src/components/**/*.vue',
'src/styles/global.css',
'astro.config.ts'
'src/styles/global.css'
],
project: ['src/**/*.{astro,vue,ts}', '*.{js,ts,mjs}'],
ignoreDependencies: ['@comfyorg/design-system', '@vercel/analytics']
project: ['src/**/*.{astro,vue,ts}', '*.{js,ts,mjs}']
}
},
ignoreBinaries: ['python3'],
@@ -54,8 +53,6 @@ const config: KnipConfig = {
// Auto generated API types
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
'packages/ingest-types/src/zod.gen.ts',
// Used by stacked PR (feat/glsl-live-preview)
'src/renderer/glsl/useGLSLRenderer.ts',
// Workflow files contain license names that knip misinterprets as binaries
'.github/workflows/ci-oss-assets-validation.yaml',
// Pending integration in stacked PR

627
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -74,7 +74,7 @@ catalog:
eslint-import-resolver-typescript: ^4.4.4
eslint-plugin-better-tailwindcss: ^4.3.1
eslint-plugin-import-x: ^4.16.1
eslint-plugin-oxlint: 1.55.0
eslint-plugin-oxlint: 1.59.0
eslint-plugin-storybook: ^10.2.10
eslint-plugin-testing-library: ^7.16.1
eslint-plugin-unused-imports: ^4.3.0
@@ -89,14 +89,14 @@ catalog:
jsdom: ^27.4.0
jsonata: ^2.1.0
jsondiffpatch: ^0.7.3
knip: ^6.0.1
knip: ^6.3.1
lint-staged: ^16.2.7
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
nx: 22.6.1
oxfmt: ^0.40.0
oxlint: ^1.55.0
oxlint-tsgolint: ^0.17.0
oxfmt: ^0.44.0
oxlint: ^1.59.0
oxlint-tsgolint: ^0.20.0
picocolors: ^1.1.1
pinia: ^3.0.4
postcss-html: ^1.8.0

View File

@@ -32,21 +32,14 @@
:aria-label="$t('g.delete')"
@click.stop="deleteBlueprint"
>
<i class="icon-[lucide--trash-2] text-xs" />
<i class="icon-[lucide--trash-2]" />
</button>
<button
:class="cn(ACTION_BTN_CLASS, 'text-muted-foreground')"
:aria-label="$t('icon.bookmark')"
@click.stop="toggleBookmark"
>
<i
:class="
cn(
isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark',
'text-xs'
)
"
/>
<i :class="isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark'" />
</button>
</div>
</div>
@@ -115,7 +108,7 @@ const ROW_CLASS =
'group/tree-node flex w-full min-w-0 cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input rounded'
const ACTION_BTN_CLASS =
'flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent opacity-0 group-hover/tree-node:opacity-100 hover:text-foreground'
'flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent text-sm opacity-0 group-hover/tree-node:opacity-100 hover:text-foreground'
const { item } = defineProps<{
item: FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>

View File

@@ -260,8 +260,26 @@ function handleColorSelect(subOption: SubMenuOption) {
hide()
}
function constrainMenuHeight() {
const menuInstance = contextMenu.value as unknown as {
container?: HTMLElement
}
const rootList = menuInstance?.container?.querySelector(
':scope > ul'
) as HTMLElement | null
if (!rootList) return
const rect = rootList.getBoundingClientRect()
const maxHeight = window.innerHeight - rect.top - 8
if (maxHeight > 0) {
rootList.style.maxHeight = `${maxHeight}px`
rootList.style.overflowY = 'auto'
}
}
function onMenuShow() {
isOpen.value = true
requestAnimationFrame(constrainMenuHeight)
}
function onMenuHide() {

View File

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

View File

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

View File

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

View File

@@ -25,7 +25,7 @@
:aria-label="$t('g.delete')"
@click.stop="deleteBlueprint"
>
<i class="icon-[lucide--trash-2] size-3.5" />
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
variant="muted-textonly"
@@ -33,7 +33,7 @@
:aria-label="$t('g.edit')"
@click.stop="editBlueprint"
>
<i class="icon-[lucide--square-pen] size-3.5" />
<i class="icon-[lucide--square-pen] size-4" />
</Button>
</template>
<template v-else #actions>

View File

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

View File

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

View File

@@ -123,7 +123,8 @@ export const useContextMenuTranslation = () => {
}
// for capture translation text of input and widget
const extraInfo = (options.extra || options.parentMenu?.options?.extra) as
const extraInfo = (options.extra ||
options.parentMenu?.options?.extra) as
| { inputs?: INodeInputSlot[]; widgets?: IWidget[] }
| undefined
// widgets and inputs

View File

@@ -0,0 +1,138 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useReconnectingNotification } from '@/composables/useReconnectingNotification'
const mockToastAdd = vi.fn()
const mockToastRemove = vi.fn()
vi.mock('primevue/usetoast', () => ({
useToast: () => ({
add: mockToastAdd,
remove: mockToastRemove
})
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
const settingMocks = vi.hoisted(() => ({
disableToast: false
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn((key: string) => {
if (key === 'Comfy.Toast.DisableReconnectingToast')
return settingMocks.disableToast
return undefined
})
}))
}))
describe('useReconnectingNotification', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.useFakeTimers()
vi.clearAllMocks()
settingMocks.disableToast = false
})
afterEach(() => {
vi.useRealTimers()
})
it('does not show toast immediately on reconnecting', () => {
const { onReconnecting } = useReconnectingNotification()
onReconnecting()
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('shows error toast after delay', () => {
const { onReconnecting } = useReconnectingNotification()
onReconnecting()
vi.advanceTimersByTime(1500)
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
summary: 'g.reconnecting'
})
)
})
it('suppresses toast when reconnected before delay expires', () => {
const { onReconnecting, onReconnected } = useReconnectingNotification()
onReconnecting()
vi.advanceTimersByTime(500)
onReconnected()
vi.advanceTimersByTime(1500)
expect(mockToastAdd).not.toHaveBeenCalled()
expect(mockToastRemove).not.toHaveBeenCalled()
})
it('removes toast and shows success when reconnected after delay', () => {
const { onReconnecting, onReconnected } = useReconnectingNotification()
onReconnecting()
vi.advanceTimersByTime(1500)
mockToastAdd.mockClear()
onReconnected()
expect(mockToastRemove).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
summary: 'g.reconnecting'
})
)
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'success',
summary: 'g.reconnected',
life: 2000
})
)
})
it('does nothing when toast is disabled via setting', () => {
settingMocks.disableToast = true
const { onReconnecting, onReconnected } = useReconnectingNotification()
onReconnecting()
vi.advanceTimersByTime(1500)
onReconnected()
expect(mockToastAdd).not.toHaveBeenCalled()
expect(mockToastRemove).not.toHaveBeenCalled()
})
it('does nothing when onReconnected is called without prior onReconnecting', () => {
const { onReconnected } = useReconnectingNotification()
onReconnected()
expect(mockToastAdd).not.toHaveBeenCalled()
expect(mockToastRemove).not.toHaveBeenCalled()
})
it('handles multiple reconnecting events without duplicating toasts', () => {
const { onReconnecting } = useReconnectingNotification()
onReconnecting()
vi.advanceTimersByTime(1500) // first toast fires
onReconnecting() // second reconnecting event
vi.advanceTimersByTime(1500) // second toast fires
expect(mockToastAdd).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,52 @@
import { useTimeoutFn } from '@vueuse/core'
import type { ToastMessageOptions } from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
const RECONNECT_TOAST_DELAY_MS = 1500
export function useReconnectingNotification() {
const { t } = useI18n()
const toast = useToast()
const settingStore = useSettingStore()
const reconnectingMessage: ToastMessageOptions = {
severity: 'error',
summary: t('g.reconnecting')
}
const reconnectingToastShown = ref(false)
const { start, stop } = useTimeoutFn(
() => {
toast.add(reconnectingMessage)
reconnectingToastShown.value = true
},
RECONNECT_TOAST_DELAY_MS,
{ immediate: false }
)
function onReconnecting() {
if (settingStore.get('Comfy.Toast.DisableReconnectingToast')) return
start()
}
function onReconnected() {
stop()
if (reconnectingToastShown.value) {
toast.remove(reconnectingMessage)
toast.add({
severity: 'success',
summary: t('g.reconnected'),
life: 2000
})
reconnectingToastShown.value = false
}
}
return { onReconnecting, onReconnected }
}

View File

@@ -115,7 +115,7 @@ describe('resolvePromotedWidgetAtHost', () => {
expect(resolved).toBeDefined()
expect(
(resolved?.widget as PromotedWidgetStub).disambiguatingSourceNodeId
(resolved!.widget as PromotedWidgetStub).disambiguatingSourceNodeId
).toBe('2')
})
})

View File

@@ -0,0 +1,103 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { app } from '@/scripts/app'
import { useExtensionStore } from '@/stores/extensionStore'
import type { ComfyExtension } from '@/types/comfy'
const TEST_CUSTOM_COMBO_TYPE = 'test/CustomComboCopyPaste'
class TestCustomComboNode extends LGraphNode {
static override title = 'CustomCombo'
constructor() {
super('CustomCombo')
this.serialize_widgets = true
this.addOutput('value', '*')
this.addWidget('combo', 'value', '', () => {}, {
values: [] as string[]
})
}
}
function findWidget(node: LGraphNode, name: string) {
return node.widgets?.find((widget) => widget.name === name)
}
function getCustomWidgetsExtension(): ComfyExtension {
const extension = useExtensionStore().extensions.find(
(candidate) => candidate.name === 'Comfy.CustomWidgets'
)
if (!extension) {
throw new Error('Comfy.CustomWidgets extension was not registered')
}
return extension
}
describe('CustomCombo copy/paste', () => {
beforeAll(async () => {
setActivePinia(createTestingPinia({ stubActions: false }))
await import('./customWidgets')
const extension = getCustomWidgetsExtension()
await extension.beforeRegisterNodeDef?.(
TestCustomComboNode,
{ name: 'CustomCombo' } as ComfyNodeDef,
app
)
if (LiteGraph.registered_node_types[TEST_CUSTOM_COMBO_TYPE]) {
LiteGraph.unregisterNodeType(TEST_CUSTOM_COMBO_TYPE)
}
LiteGraph.registerNodeType(TEST_CUSTOM_COMBO_TYPE, TestCustomComboNode)
})
afterAll(() => {
if (LiteGraph.registered_node_types[TEST_CUSTOM_COMBO_TYPE]) {
LiteGraph.unregisterNodeType(TEST_CUSTOM_COMBO_TYPE)
}
})
it('preserves combo options and selected value through clone and paste', () => {
const graph = new LGraph()
type AppWithRootGraph = { rootGraphInternal?: LGraph }
const appWithRootGraph = app as unknown as AppWithRootGraph
const previousRootGraph = appWithRootGraph.rootGraphInternal
appWithRootGraph.rootGraphInternal = graph
try {
const original = LiteGraph.createNode(TEST_CUSTOM_COMBO_TYPE)!
graph.add(original)
findWidget(original, 'option1')!.value = 'alpha'
findWidget(original, 'option2')!.value = 'beta'
findWidget(original, 'option3')!.value = 'gamma'
findWidget(original, 'value')!.value = 'beta'
const clonedSerialised = original.clone()?.serialize()
expect(clonedSerialised).toBeDefined()
const pasted = LiteGraph.createNode(TEST_CUSTOM_COMBO_TYPE)!
pasted.configure(clonedSerialised!)
graph.add(pasted)
expect(findWidget(pasted, 'value')!.value).toBe('beta')
expect(findWidget(pasted, 'option1')!.value).toBe('alpha')
expect(findWidget(pasted, 'option2')!.value).toBe('beta')
expect(findWidget(pasted, 'option3')!.value).toBe('gamma')
expect(findWidget(pasted, 'value')!.options.values).toEqual([
'alpha',
'beta',
'gamma'
])
} finally {
appWithRootGraph.rootGraphInternal = previousRootGraph
}
})
})

View File

@@ -63,7 +63,7 @@ function onCustomComboCreated(this: LGraphNode) {
(w) => w.name.startsWith('option') && w.value
).map((w) => `${w.value}`)
)
if (app.configuringGraph) return
if (app.configuringGraph || !this.graph) return
if (values.includes(`${comboWidget.value}`)) return
comboWidget.value = values[0] ?? ''
comboWidget.callback?.(comboWidget.value)
@@ -71,6 +71,9 @@ function onCustomComboCreated(this: LGraphNode) {
comboWidget.callback = useChainCallback(comboWidget.callback, () =>
this.applyToGraph!()
)
this.onAdded = useChainCallback(this.onAdded, function () {
updateCombo()
})
function addOption(node: LGraphNode) {
if (!node.widgets) return
@@ -78,16 +81,17 @@ function onCustomComboCreated(this: LGraphNode) {
const widgetName = `option${newCount}`
const widget = node.addWidget('string', widgetName, '', () => {})
if (!widget) return
let localValue = `${widget.value ?? ''}`
Object.defineProperty(widget, 'value', {
get() {
return useWidgetValueStore().getWidget(
app.rootGraph.id,
node.id,
widgetName
)?.value
return (
useWidgetValueStore().getWidget(app.rootGraph.id, node.id, widgetName)
?.value ?? localValue
)
},
set(v: string) {
localValue = v
const state = useWidgetValueStore().getWidget(
app.rootGraph.id,
node.id,

View File

@@ -143,9 +143,10 @@ app.registerExtension({
throw new Error(err)
}
const data = await resp.json()
const serverName = data.name ?? name
const subfolder = data.subfolder ?? 'webcam'
return `${subfolder}/${serverName} [temp]`
const serverName = data.name || name
const subfolder = data.subfolder || 'webcam'
const type = data.type || 'temp'
return `${subfolder}/${serverName} [${type}]`
}
// @ts-expect-error fixme ts strict error

View File

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

View File

@@ -1,11 +1,26 @@
import { describe, expect, it } from 'vitest'
import { fromAny } from '@total-typescript/shoehorn'
import { describe, expect, it, vi } from 'vitest'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
import {
scanAllMediaCandidates,
scanNodeMediaCandidates,
verifyCloudMediaCandidates,
groupCandidatesByName,
groupCandidatesByMediaType
} from './missingMediaScan'
import type { MissingMediaCandidate } from './types'
vi.mock('@/utils/graphTraversalUtil', () => ({
collectAllNodes: (graph: { _testNodes: LGraphNode[] }) => graph._testNodes,
getExecutionIdByNode: (
_graph: unknown,
node: { _testExecutionId?: string; id: number }
) => node._testExecutionId ?? String(node.id)
}))
function makeCandidate(
nodeId: string,
name: string,
@@ -22,6 +37,122 @@ function makeCandidate(
}
}
function makeMediaCombo(
name: string,
value: string,
options: string[] = []
): IComboWidget {
return fromAny<IComboWidget, unknown>({
type: 'combo',
name,
value,
options: { values: options }
})
}
function makeMediaNode(
id: number,
type: string,
widgets: IComboWidget[],
mode: number = 0,
executionId?: string
): LGraphNode {
return fromAny<LGraphNode, unknown>({
id,
type,
widgets,
mode,
_testExecutionId: executionId ?? String(id)
})
}
function makeGraph(nodes: LGraphNode[]): LGraph {
return fromAny<LGraph, unknown>({ _testNodes: nodes })
}
describe('scanNodeMediaCandidates', () => {
it('returns candidate for a LoadImage node with missing image', () => {
const graph = makeGraph([])
const node = makeMediaNode(
1,
'LoadImage',
[makeMediaCombo('image', 'photo.png', ['other.png'])],
0
)
const result = scanNodeMediaCandidates(graph, node, false)
expect(result).toHaveLength(1)
expect(result[0]).toEqual({
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'photo.png',
isMissing: true
})
})
it('returns empty for non-media node types', () => {
const graph = makeGraph([])
const node = makeMediaNode(
1,
'KSampler',
[makeMediaCombo('sampler', 'euler', ['euler', 'dpm'])],
0
)
const result = scanNodeMediaCandidates(graph, node, false)
expect(result).toEqual([])
})
it('returns empty for node with no widgets', () => {
const graph = makeGraph([])
const node = makeMediaNode(1, 'LoadImage', [], 0)
const result = scanNodeMediaCandidates(graph, node, false)
expect(result).toEqual([])
})
})
describe('scanAllMediaCandidates', () => {
it('skips muted nodes (mode === NEVER)', () => {
const node = makeMediaNode(
1,
'LoadImage',
[makeMediaCombo('image', 'photo.png', ['other.png'])],
2 // NEVER
)
const result = scanAllMediaCandidates(makeGraph([node]), false)
expect(result).toHaveLength(0)
})
it('skips bypassed nodes (mode === BYPASS)', () => {
const node = makeMediaNode(
2,
'LoadImage',
[makeMediaCombo('image', 'photo.png', ['other.png'])],
4 // BYPASS
)
const result = scanAllMediaCandidates(makeGraph([node]), false)
expect(result).toHaveLength(0)
})
it('includes active nodes (mode === ALWAYS)', () => {
const node = makeMediaNode(
3,
'LoadImage',
[makeMediaCombo('image', 'photo.png', ['other.png'])],
0 // ALWAYS
)
const result = scanAllMediaCandidates(makeGraph([node]), false)
expect(result).toHaveLength(1)
expect(result[0].isMissing).toBe(true)
})
})
describe('groupCandidatesByName', () => {
it('groups candidates with the same name', () => {
const candidates = [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -121,7 +121,7 @@ describe('GtmTelemetryProvider', () => {
event: 'execution_error',
node_type: 'KSampler'
})
expect((entry?.error as string).length).toBe(100)
expect(entry!.error as string).toHaveLength(100)
})
it('pushes select_content for template events', () => {

View File

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

View File

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

View File

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

View File

@@ -28,6 +28,7 @@ function createMockAssetItem(overrides: Partial<AssetItem> = {}): AssetItem {
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
const mockUpdateInputs = vi.hoisted(() => vi.fn(() => Promise.resolve()))
const mockGetInputName = vi.hoisted(() => vi.fn((hash: string) => hash))
const mockGetAssets = vi.hoisted(() => vi.fn(() => [] as AssetItem[]))
const mockAssetsStoreState = vi.hoisted(() => {
const inputAssets: AssetItem[] = []
return {
@@ -55,7 +56,8 @@ vi.mock('@/stores/assetsStore', () => ({
return mockAssetsStoreState.inputLoading
},
updateInputs: mockUpdateInputs,
getInputName: mockGetInputName
getInputName: mockGetInputName,
getAssets: mockGetAssets
}))
}))
@@ -199,67 +201,117 @@ describe('useComboWidget', () => {
expect(widget).toBe(mockWidget)
})
it('should create asset browser widget when API enabled', () => {
mockDistributionState.isCloud = true
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(true)
describe('cloud asset browser widget', () => {
// "Select model" is the fallback from t('widgets.selectModel')
// in createAssetWidget when defaultValue is undefined.
const PLACEHOLDER = 'Select model'
const constructor = useComboWidget()
const mockWidget = createMockWidget({
type: 'asset',
name: 'ckpt_name',
value: 'model1.safetensors'
})
const mockNode = createMockNode('CheckpointLoaderSimple')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'ckpt_name',
options: ['model1.safetensors', 'model2.safetensors']
function setupCloudAssetWidget(
inputSpecOverrides: Partial<InputSpec> = {}
) {
mockDistributionState.isCloud = true
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(true)
const constructor = useComboWidget()
const mockWidget = createMockWidget({
type: 'asset',
name: 'ckpt_name',
value: ''
})
const mockNode = createMockNode('CheckpointLoaderSimple')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'ckpt_name',
...inputSpecOverrides
})
constructor(mockNode, inputSpec)
return { mockNode }
}
function getWidgetDefault(mockNode: ReturnType<typeof createMockNode>) {
return vi.mocked(mockNode.addWidget).mock.calls[0]?.[2]
}
it('should create asset browser widget when API enabled', () => {
mockGetAssets.mockReturnValue([
createMockAssetItem({ name: 'cloud_model.safetensors' })
])
const { mockNode } = setupCloudAssetWidget({
options: ['model1.safetensors', 'model2.safetensors']
})
expect(
vi.mocked(assetService.shouldUseAssetBrowser)
).toHaveBeenCalledWith('CheckpointLoaderSimple', 'ckpt_name')
expect(mockNode.addWidget).toHaveBeenCalledWith(
'asset',
'ckpt_name',
expect.anything(),
expect.any(Function),
expect.any(Object)
)
})
const widget = constructor(mockNode, inputSpec)
it('should use first cloud asset as default instead of server combo options', () => {
mockGetAssets.mockReturnValue([
createMockAssetItem({ name: 'cloud_model.safetensors' })
])
expect(mockNode.addWidget).toHaveBeenCalledWith(
'asset',
'ckpt_name',
'model1.safetensors',
expect.any(Function),
expect.any(Object)
)
expect(vi.mocked(assetService.shouldUseAssetBrowser)).toHaveBeenCalledWith(
'CheckpointLoaderSimple',
'ckpt_name'
)
expect(widget).toBe(mockWidget)
})
const { mockNode } = setupCloudAssetWidget({
options: ['local_only_model.safetensors']
})
it('should create asset browser widget when default value provided without options', () => {
mockDistributionState.isCloud = true
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(true)
const constructor = useComboWidget()
const mockWidget = createMockWidget({
type: 'asset',
name: 'ckpt_name',
value: 'fallback.safetensors'
})
const mockNode = createMockNode('CheckpointLoaderSimple')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'ckpt_name',
default: 'fallback.safetensors'
// Note: no options array provided
expect(getWidgetDefault(mockNode)).toBe('cloud_model.safetensors')
})
const widget = constructor(mockNode, inputSpec)
it('should fallback to assets[0] when inputSpec.default not in cloud assets', () => {
mockGetAssets.mockReturnValue([
createMockAssetItem({ name: 'cloud_model.safetensors' })
])
expect(mockNode.addWidget).toHaveBeenCalledWith(
'asset',
'ckpt_name',
'fallback.safetensors',
expect.any(Function),
expect.any(Object)
)
expect(widget).toBe(mockWidget)
const { mockNode } = setupCloudAssetWidget({
default: 'not_in_cloud.safetensors'
})
expect(getWidgetDefault(mockNode)).toBe('cloud_model.safetensors')
})
it('should prefer inputSpec.default when it exists in cloud assets', () => {
mockGetAssets.mockReturnValue([
createMockAssetItem({ name: 'other_model.safetensors' }),
createMockAssetItem({ name: 'fallback.safetensors' })
])
const { mockNode } = setupCloudAssetWidget({
// Note: no options array provided
default: 'fallback.safetensors'
})
expect(getWidgetDefault(mockNode)).toBe('fallback.safetensors')
})
it('should create asset browser widget when default value provided without options', () => {
mockGetAssets.mockReturnValue([])
const { mockNode } = setupCloudAssetWidget({
// Note: no options array provided
default: 'fallback.safetensors'
})
expect(getWidgetDefault(mockNode)).toBe(PLACEHOLDER)
})
it('should fallback to placeholder when cloud assets not loaded', () => {
mockGetAssets.mockReturnValue([])
const { mockNode } = setupCloudAssetWidget({
options: ['local_model.safetensors']
})
expect(getWidgetDefault(mockNode)).toBe(PLACEHOLDER)
})
})
it('should show Select model when asset widget has undefined current value', () => {

View File

@@ -6,6 +6,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isComboWidget } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { assetService } from '@/platform/assets/services/assetService'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import { createAssetWidget } from '@/platform/assets/utils/createAssetWidget'
import { isCloud } from '@/platform/distribution/types'
import type {
@@ -104,6 +105,25 @@ const addMultiSelectWidget = (
return widget
}
/**
* Resolve the default value for a cloud asset widget.
* Priority: inputSpec.default (if present in cloud assets) → first cloud
* asset → undefined (shows placeholder).
*/
function resolveCloudDefault(
nodeType: string,
specDefault: string | undefined
): string | undefined {
const assets = useAssetsStore().getAssets(nodeType)
if (specDefault != null) {
const inAssets = assets.some((a) => getAssetFilename(a) === specDefault)
if (inAssets) return specDefault
}
// empty filename → undefined (shows placeholder)
const filename = assets.length ? getAssetFilename(assets[0]) : undefined
return filename || undefined
}
function createAssetBrowserWidget(
node: LGraphNode,
inputSpec: ComboInputSpec,
@@ -195,7 +215,14 @@ const addComboWidget = (
if (isCloud) {
if (assetService.shouldUseAssetBrowser(node.comfyClass, inputSpec.name)) {
return createAssetBrowserWidget(node, inputSpec, defaultValue)
// Default from cloud assets, not from server combo options.
// Server options list local files that may not exist in the user's
// cloud asset library, leading to missing-model errors on undo/reload.
const cloudDefault = resolveCloudDefault(
node.comfyClass ?? '',
inputSpec.default
)
return createAssetBrowserWidget(node, inputSpec, cloudDefault)
}
if (NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']) {

View File

@@ -17,12 +17,12 @@ interface AutogrowGroup {
prefix?: string
}
export interface UniformSource {
interface UniformSource {
nodeId: NodeId
widgetName: string
}
export interface UniformSources {
interface UniformSources {
floats: UniformSource[]
ints: UniformSource[]
bools: UniformSource[]

View File

@@ -234,6 +234,163 @@ describe('API Feature Flags', () => {
})
})
describe('progress_text binary message parsing', () => {
/**
* Build a legacy progress_text binary message:
* [4B event_type=3][4B node_id_len][node_id_bytes][text_bytes]
*/
function buildLegacyProgressText(
nodeId: string,
text: string
): ArrayBuffer {
const encoder = new TextEncoder()
const nodeIdBytes = encoder.encode(nodeId)
const textBytes = encoder.encode(text)
const buf = new ArrayBuffer(4 + 4 + nodeIdBytes.length + textBytes.length)
const view = new DataView(buf)
view.setUint32(0, 3) // event type
view.setUint32(4, nodeIdBytes.length)
new Uint8Array(buf, 8, nodeIdBytes.length).set(nodeIdBytes)
new Uint8Array(buf, 8 + nodeIdBytes.length, textBytes.length).set(
textBytes
)
return buf
}
/**
* Build a new-format progress_text binary message:
* [4B event_type=3][4B prompt_id_len][prompt_id_bytes][4B node_id_len][node_id_bytes][text_bytes]
*/
function buildNewProgressText(
promptId: string,
nodeId: string,
text: string
): ArrayBuffer {
const encoder = new TextEncoder()
const promptIdBytes = encoder.encode(promptId)
const nodeIdBytes = encoder.encode(nodeId)
const textBytes = encoder.encode(text)
const buf = new ArrayBuffer(
4 + 4 + promptIdBytes.length + 4 + nodeIdBytes.length + textBytes.length
)
const view = new DataView(buf)
let offset = 0
view.setUint32(offset, 3) // event type
offset += 4
view.setUint32(offset, promptIdBytes.length)
offset += 4
new Uint8Array(buf, offset, promptIdBytes.length).set(promptIdBytes)
offset += promptIdBytes.length
view.setUint32(offset, nodeIdBytes.length)
offset += 4
new Uint8Array(buf, offset, nodeIdBytes.length).set(nodeIdBytes)
offset += nodeIdBytes.length
new Uint8Array(buf, offset, textBytes.length).set(textBytes)
return buf
}
let dispatchedEvents: { nodeId: string; text: string; prompt_id?: string }[]
let listener: EventListener
beforeEach(async () => {
dispatchedEvents = []
listener = ((e: CustomEvent) => {
dispatchedEvents.push(e.detail)
}) as EventListener
api.addEventListener('progress_text', listener)
// Connect the WebSocket so the message handler is active
const initPromise = api.init()
wsEventHandlers['open'](new Event('open'))
wsEventHandlers['message']({
data: JSON.stringify({
type: 'status',
data: {
status: { exec_info: { queue_remaining: 0 } },
sid: 'test-sid'
}
})
})
await initPromise
})
afterEach(() => {
api.removeEventListener('progress_text', listener)
})
it('should parse legacy format when server does not support progress_text_metadata', () => {
// Restore real getClientFeatureFlags (advertises support)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_progress_text_metadata: true
})
// Server does NOT echo support
api.serverFeatureFlags.value = {}
const msg = buildLegacyProgressText('42', 'Generating image...')
wsEventHandlers['message']({ data: msg })
expect(dispatchedEvents).toHaveLength(1)
expect(dispatchedEvents[0]).toEqual({
nodeId: '42',
text: 'Generating image...'
})
})
it('should parse new format when server supports progress_text_metadata', () => {
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_progress_text_metadata: true
})
api.serverFeatureFlags.value = {
supports_progress_text_metadata: true
}
const msg = buildNewProgressText('prompt-abc', '42', 'Step 5/20')
wsEventHandlers['message']({ data: msg })
expect(dispatchedEvents).toHaveLength(1)
expect(dispatchedEvents[0]).toEqual({
nodeId: '42',
text: 'Step 5/20',
prompt_id: 'prompt-abc'
})
})
it('should not corrupt legacy messages when client advertises support but server does not', () => {
// This is the exact bug scenario: client says it supports the flag,
// server doesn't, but the decoder checks the client flag and tries
// to parse a prompt_id that isn't there.
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_progress_text_metadata: true
})
api.serverFeatureFlags.value = {}
// Send multiple legacy messages to ensure none are corrupted
const messages = [
buildLegacyProgressText('7', 'Loading model...'),
buildLegacyProgressText('12', 'Sampling 3/20'),
buildLegacyProgressText('99', 'VAE decode')
]
for (const msg of messages) {
wsEventHandlers['message']({ data: msg })
}
expect(dispatchedEvents).toHaveLength(3)
expect(dispatchedEvents[0]).toMatchObject({
nodeId: '7',
text: 'Loading model...'
})
expect(dispatchedEvents[1]).toMatchObject({
nodeId: '12',
text: 'Sampling 3/20'
})
expect(dispatchedEvents[2]).toMatchObject({
nodeId: '99',
text: 'VAE decode'
})
})
})
describe('Dev override via localStorage', () => {
afterEach(() => {
localStorage.clear()

View File

@@ -638,7 +638,7 @@ export class ComfyApi extends EventTarget {
let promptId: string | undefined
if (
this.getClientFeatureFlags()?.supports_progress_text_metadata
this.serverFeatureFlags.value?.supports_progress_text_metadata
) {
const promptIdLength = rawView.getUint32(offset)
offset += 4

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,8 +34,7 @@
<script setup lang="ts">
import { useEventListener, useIntervalFn } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import type { ToastMessageOptions } from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import {
computed,
nextTick,
@@ -45,7 +44,6 @@ import {
watch,
watchEffect
} from 'vue'
import { useI18n } from 'vue-i18n'
import { runWhenGlobalIdle } from '@/base/common/async'
import MenuHamburger from '@/components/MenuHamburger.vue'
@@ -58,6 +56,7 @@ import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
import { useCoreCommands } from '@/composables/useCoreCommands'
import { useQueuePolling } from '@/platform/remote/comfyui/useQueuePolling'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useReconnectingNotification } from '@/composables/useReconnectingNotification'
import { useProgressFavicon } from '@/composables/useProgressFavicon'
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
import type { ServerConfig, ServerConfigValue } from '@/constants/serverConfig'
@@ -103,8 +102,6 @@ setupAutoQueueHandler()
useProgressFavicon()
useBrowserTabTitle()
const { t } = useI18n()
const toast = useToast()
const settingStore = useSettingStore()
const executionStore = useExecutionStore()
const colorPaletteStore = useColorPaletteStore()
@@ -250,28 +247,7 @@ const onExecutionSuccess = async () => {
}
}
const reconnectingMessage: ToastMessageOptions = {
severity: 'error',
summary: t('g.reconnecting')
}
const onReconnecting = () => {
if (!settingStore.get('Comfy.Toast.DisableReconnectingToast')) {
toast.remove(reconnectingMessage)
toast.add(reconnectingMessage)
}
}
const onReconnected = () => {
if (!settingStore.get('Comfy.Toast.DisableReconnectingToast')) {
toast.remove(reconnectingMessage)
toast.add({
severity: 'success',
summary: t('g.reconnected'),
life: 2000
})
}
}
const { onReconnecting, onReconnected } = useReconnectingNotification()
useEventListener(api, 'status', onStatus)
useEventListener(api, 'execution_success', onExecutionSuccess)