mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-17 05:01:02 +00:00
Compare commits
9 Commits
feat/node-
...
backport/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26c1fbbf1d | ||
|
|
8dd3ee072e | ||
|
|
e49d1afa61 | ||
|
|
c7943ca1b6 | ||
|
|
9816951a39 | ||
|
|
e7c10aaf77 | ||
|
|
1ddc0bb125 | ||
|
|
fe8dc17d2d | ||
|
|
352f5a0cd4 |
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
34
browser_tests/assets/missing/missing_media_bypassed.json
Normal file
34
browser_tests/assets/missing/missing_media_bypassed.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 200],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 4,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": null },
|
||||
{ "name": "MASK", "type": "MASK", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["nonexistent_test_image_12345.png", "image"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"id": 10,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 50],
|
||||
"pos": [50, 200],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
@@ -31,7 +31,7 @@
|
||||
{
|
||||
"id": 11,
|
||||
"type": "LoadImage",
|
||||
"pos": [450, 50],
|
||||
"pos": [450, 200],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"id": 10,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 50],
|
||||
"pos": [50, 200],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
{
|
||||
"last_node_id": 0,
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [256, 256],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": null },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
@@ -15,7 +35,7 @@
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "text_encoders"
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
|
||||
42
browser_tests/assets/missing/missing_models_bypassed.json
Normal file
42
browser_tests/assets/missing/missing_models_bypassed.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [256, 256],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 4,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": null },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
}
|
||||
141
browser_tests/assets/missing/missing_models_in_subgraph.json
Normal file
141
browser_tests/assets/missing/missing_models_in_subgraph.json
Normal file
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"id": "test-missing-models-in-subgraph",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [100, 100],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [] }],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "subgraph-with-missing-model",
|
||||
"pos": [450, 100],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "model", "type": "MODEL", "link": null }],
|
||||
"outputs": [{ "name": "MODEL", "type": "MODEL", "links": null }],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "subgraph-with-missing-model",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 2,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Subgraph with Missing Model",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [100, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [500, 200, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "input1-id",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [1],
|
||||
"pos": { "0": 150, "1": 220 }
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "output1-id",
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"linkIds": [2],
|
||||
"pos": { "0": 520, "1": 220 }
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [250, 180],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": [2] },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -78,7 +78,7 @@
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "text_encoders"
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
|
||||
@@ -79,7 +79,8 @@ export const TestIds = {
|
||||
bookmarksSection: 'node-library-bookmarks-section'
|
||||
},
|
||||
propertiesPanel: {
|
||||
root: 'properties-panel'
|
||||
root: 'properties-panel',
|
||||
errorsTab: 'panel-tab-errors'
|
||||
},
|
||||
subgraphEditor: {
|
||||
toggle: 'subgraph-editor-toggle',
|
||||
|
||||
82
browser_tests/tests/cloud-asset-default.spec.ts
Normal file
82
browser_tests/tests/cloud-asset-default.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
105
browser_tests/tests/nodeContextMenuOverflow.spec.ts
Normal file
105
browser_tests/tests/nodeContextMenuOverflow.spec.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -2,8 +2,9 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
|
||||
export async function openErrorsTabViaSeeErrors(
|
||||
export async function loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: string
|
||||
) {
|
||||
@@ -15,3 +16,30 @@ export async function openErrorsTabViaSeeErrors(
|
||||
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
}
|
||||
|
||||
export async function openErrorsTab(comfyPage: ComfyPage) {
|
||||
const panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await panel.open(comfyPage.actionbar.propertiesButton)
|
||||
|
||||
const errorsTab = comfyPage.page.getByTestId(
|
||||
TestIds.propertiesPanel.errorsTab
|
||||
)
|
||||
await expect(errorsTab).toBeVisible()
|
||||
await errorsTab.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the fake model file from the backend so it is detected as missing.
|
||||
* Fixture URLs (e.g. http://localhost:8188/...) are not actually downloaded
|
||||
* during tests — they only serve as metadata for the missing model UI.
|
||||
*/
|
||||
export async function cleanupFakeModel(comfyPage: ComfyPage) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(async (url: string) => {
|
||||
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`)
|
||||
return response.ok
|
||||
}, comfyPage.url)
|
||||
)
|
||||
.toBeTruthy()
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect } from '@playwright/test'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { openErrorsTabViaSeeErrors } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
|
||||
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
|
||||
const dropzone = comfyPage.page.getByTestId(
|
||||
@@ -47,7 +47,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
|
||||
test.describe('Detection', () => {
|
||||
test('Shows missing media group in errors tab', async ({ comfyPage }) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
|
||||
@@ -57,7 +60,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
test('Shows correct number of missing media rows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_multiple'
|
||||
)
|
||||
@@ -68,7 +71,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
test('Shows upload dropzone and library select for each missing item', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
|
||||
await expect(getDropzone(comfyPage)).toBeVisible()
|
||||
await expect(
|
||||
@@ -81,7 +87,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
test('Upload via file picker shows status card then allows confirm', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
await uploadFileViaDropzone(comfyPage)
|
||||
|
||||
await expect(getStatusCard(comfyPage)).toBeVisible()
|
||||
@@ -95,7 +104,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
test('Selecting from library shows status card then allows confirm', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
|
||||
const librarySelect = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaLibrarySelect
|
||||
@@ -121,7 +133,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
test('Cancelling pending selection returns to upload/library UI', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
await uploadFileViaDropzone(comfyPage)
|
||||
|
||||
await expect(getStatusCard(comfyPage)).toBeVisible()
|
||||
@@ -140,7 +155,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
test('Missing Inputs group disappears when all items are resolved', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
await uploadFileViaDropzone(comfyPage)
|
||||
await confirmPendingSelection(comfyPage)
|
||||
|
||||
@@ -154,7 +172,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
test('Locate button navigates canvas to the missing media node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
|
||||
const offsetBefore = await comfyPage.page.evaluate(() => {
|
||||
const canvas = window['app']?.canvas
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
interceptClipboardWrite,
|
||||
getClipboardText
|
||||
} from '@e2e/helpers/clipboardSpy'
|
||||
import { openErrorsTabViaSeeErrors } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
import {
|
||||
cleanupFakeModel,
|
||||
loadWorkflowAndOpenErrorsTab
|
||||
} from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
|
||||
test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -15,17 +18,13 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
const cleanupOk = await comfyPage.page.evaluate(async (url: string) => {
|
||||
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`)
|
||||
return response.ok
|
||||
}, comfyPage.url)
|
||||
expect(cleanupOk).toBeTruthy()
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test('Should show missing models group in errors tab', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
|
||||
@@ -35,7 +34,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
test('Should display model name with referencing node count', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const modelsGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
@@ -46,7 +45,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
test('Should expand model row to show referencing nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_models_with_nodes'
|
||||
)
|
||||
@@ -54,7 +53,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
const locateButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelLocate
|
||||
)
|
||||
await expect(locateButton.first()).not.toBeVisible()
|
||||
await expect(locateButton.first()).toBeHidden()
|
||||
|
||||
const expandButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelExpand
|
||||
@@ -66,14 +65,14 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test('Should copy model name to clipboard', async ({ comfyPage }) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
await interceptClipboardWrite(comfyPage.page)
|
||||
|
||||
const copyButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelCopyName
|
||||
)
|
||||
await expect(copyButton.first()).toBeVisible()
|
||||
await copyButton.first().click()
|
||||
await copyButton.first().dispatchEvent('click')
|
||||
|
||||
const copiedText = await getClipboardText(comfyPage.page)
|
||||
expect(copiedText).toContain('fake_model.safetensors')
|
||||
@@ -83,7 +82,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
test('Should show Copy URL button for non-asset models', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const copyUrlButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelCopyUrl
|
||||
@@ -94,7 +93,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
test('Should show Download button for downloadable models', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const downloadButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelDownload
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { openErrorsTabViaSeeErrors } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
|
||||
test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -14,7 +14,7 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test('Should show MissingNodeCard in errors tab', async ({ comfyPage }) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_nodes')
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
|
||||
@@ -22,7 +22,7 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test('Should show missing node packs group', async ({ comfyPage }) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_nodes')
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingNodePacksGroup)
|
||||
@@ -32,7 +32,7 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
test('Should expand pack group to reveal node type names', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_nodes_in_subgraph'
|
||||
)
|
||||
@@ -52,7 +52,7 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test('Should collapse expanded pack group', async ({ comfyPage }) => {
|
||||
await openErrorsTabViaSeeErrors(
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_nodes_in_subgraph'
|
||||
)
|
||||
@@ -80,7 +80,7 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
test('Locate node button is visible for expanded pack nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_nodes_in_subgraph'
|
||||
)
|
||||
|
||||
519
browser_tests/tests/propertiesPanel/errorsTabModeAware.spec.ts
Normal file
519
browser_tests/tests/propertiesPanel/errorsTabModeAware.spec.ts
Normal file
@@ -0,0 +1,519 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import {
|
||||
cleanupFakeModel,
|
||||
openErrorsTab,
|
||||
loadWorkflowAndOpenErrorsTab
|
||||
} from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
|
||||
test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Missing nodes', () => {
|
||||
test('Deleting a missing node removes its error from the errors tab', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
const missingNodeGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodePacksGroup
|
||||
)
|
||||
await expect(missingNodeGroup).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.delete()
|
||||
|
||||
await expect(missingNodeGroup).toBeHidden()
|
||||
})
|
||||
|
||||
test('Undo after bypass restores error without showing overlay', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
const missingNodeGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodePacksGroup
|
||||
)
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(missingNodeGroup).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
||||
await expect(missingNodeGroup).toBeHidden()
|
||||
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect.poll(() => node.isBypassed()).toBeFalsy()
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingNodeGroup).toBeVisible()
|
||||
|
||||
await comfyPage.keyboard.redo()
|
||||
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
||||
await expect(missingNodeGroup).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Missing models', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test('Loading a workflow with all nodes bypassed shows no errors', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models_bypassed')
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('Bypassing a node hides its error, un-bypassing restores it', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeFalsy()
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
})
|
||||
|
||||
test('Pasting a node with missing model increases referencing node count', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(1\)/
|
||||
)
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
await comfyPage.clipboard.copy()
|
||||
await comfyPage.clipboard.paste()
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
|
||||
|
||||
await comfyPage.canvas.click()
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(2\)/
|
||||
)
|
||||
})
|
||||
|
||||
test('Pasting a bypassed node does not add a new error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
|
||||
await comfyPage.clipboard.copy()
|
||||
await comfyPage.clipboard.paste()
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
})
|
||||
|
||||
test('Deleting a node with missing model removes its error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.delete()
|
||||
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
})
|
||||
|
||||
test('Undo after bypass restores error without showing overlay', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect.poll(() => node.isBypassed()).toBeFalsy()
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
|
||||
await comfyPage.keyboard.redo()
|
||||
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
})
|
||||
|
||||
test('Selecting a node filters errors tab to only that node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_models_with_nodes'
|
||||
)
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toContainText(/\(2\)/)
|
||||
|
||||
const node1 = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node1.click('title')
|
||||
await expect(missingModelGroup).toContainText(/\(1\)/)
|
||||
|
||||
await comfyPage.canvas.click()
|
||||
await expect(missingModelGroup).toContainText(/\(2\)/)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Missing media', () => {
|
||||
test('Loading a workflow with all nodes bypassed shows no errors', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_media_bypassed')
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('Bypassing a node hides its error, un-bypassing restores it', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
|
||||
const missingMediaGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaGroup
|
||||
)
|
||||
await expect(missingMediaGroup).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
||||
await expect(missingMediaGroup).toBeHidden()
|
||||
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeFalsy()
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingMediaGroup).toBeVisible()
|
||||
})
|
||||
|
||||
test('Pasting a bypassed node does not add a new error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
|
||||
const missingMediaGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaGroup
|
||||
)
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
||||
await expect(missingMediaGroup).toBeHidden()
|
||||
|
||||
await comfyPage.clipboard.copy()
|
||||
await comfyPage.clipboard.paste()
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
|
||||
await expect(missingMediaGroup).toBeHidden()
|
||||
})
|
||||
|
||||
test('Selecting a node filters errors tab to only that node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_media_multiple')
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
||||
.click()
|
||||
|
||||
const mediaRows = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaRow
|
||||
)
|
||||
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(mediaRows).toHaveCount(2)
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await node.click('title')
|
||||
await expect(mediaRows).toHaveCount(1)
|
||||
|
||||
await comfyPage.canvas.click({ position: { x: 400, y: 600 } })
|
||||
await expect(mediaRows).toHaveCount(2)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Subgraph', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test('Bypassing a subgraph hides interior errors, un-bypassing restores them', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_models_in_subgraph'
|
||||
)
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
||||
.click()
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
const errorsTab = comfyPage.page.getByTestId(
|
||||
TestIds.propertiesPanel.errorsTab
|
||||
)
|
||||
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => subgraphNode.isBypassed()).toBeTruthy()
|
||||
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(errorsTab).toBeHidden()
|
||||
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => subgraphNode.isBypassed()).toBeFalsy()
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
})
|
||||
|
||||
test('Deleting a node inside a subgraph removes its missing model error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Regression: before the execId fix, onNodeRemoved fell back to the
|
||||
// interior node's local id (e.g. "1") when node.graph was already
|
||||
// null, so the error keyed under "2:1" was never removed.
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_models_in_subgraph'
|
||||
)
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
||||
.click()
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Select-all + Delete: interior node IDs may be reassigned during
|
||||
// subgraph configure when they collide with root-graph IDs, so
|
||||
// looking up by static id can fail.
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
})
|
||||
|
||||
test('Deleting a node inside a subgraph removes its missing node-type error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes_in_subgraph')
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
||||
.click()
|
||||
|
||||
const missingNodeGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodePacksGroup
|
||||
)
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingNodeGroup).toBeVisible()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Select-all + Delete: interior node IDs may be reassigned during
|
||||
// subgraph configure when they collide with root-graph IDs, so
|
||||
// looking up by static id can fail.
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
|
||||
await expect(missingNodeGroup).toBeHidden()
|
||||
})
|
||||
|
||||
test('Bypassing a node inside a subgraph hides its error, un-bypassing restores it', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_models_in_subgraph'
|
||||
)
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
||||
.click()
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.keyboard.bypass()
|
||||
|
||||
const errorsTab = comfyPage.page.getByTestId(
|
||||
TestIds.propertiesPanel.errorsTab
|
||||
)
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(errorsTab).toBeHidden()
|
||||
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.keyboard.bypass()
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Workflow switching', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Sidebar'
|
||||
)
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
})
|
||||
|
||||
test('Restores missing nodes in errors tab when switching back to workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
||||
.click()
|
||||
|
||||
const missingNodeGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodePacksGroup
|
||||
)
|
||||
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingNodeGroup).toBeVisible()
|
||||
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await expect(missingNodeGroup).toBeHidden()
|
||||
|
||||
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingNodeGroup).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { openErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
|
||||
test.describe('Workflows sidebar', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -232,7 +233,7 @@ test.describe('Workflows sidebar', () => {
|
||||
.toEqual('workflow1')
|
||||
})
|
||||
|
||||
test('Reports missing nodes warning again when switching back to workflow', async ({
|
||||
test('Restores missing nodes errors silently when switching back to workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
@@ -254,11 +255,17 @@ test.describe('Workflows sidebar', () => {
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
|
||||
// Switch back to the missing_nodes workflow — overlay should reappear
|
||||
// so users can install missing node packs without a page reload
|
||||
// Switch back to the missing_nodes workflow — overlay should NOT
|
||||
// reappear (silent restore), but errors tab should have content
|
||||
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')
|
||||
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
// Errors tab should still show missing nodes after silent restore
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingNodePacksGroup)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Can close saved-workflows from the open workflows section', async ({
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 107 KiB |
@@ -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
627
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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>>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
@@ -41,6 +42,7 @@ import TabErrors from './errors/TabErrors.vue'
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const missingMediaStore = useMissingMediaStore()
|
||||
const missingNodesErrorStore = useMissingNodesErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const settingStore = useSettingStore()
|
||||
@@ -58,6 +60,7 @@ const activeMissingNodeGraphIds = computed<Set<string>>(() => {
|
||||
})
|
||||
|
||||
const { activeMissingModelGraphIds } = storeToRefs(missingModelStore)
|
||||
const { activeMissingMediaGraphIds } = storeToRefs(missingMediaStore)
|
||||
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
|
||||
@@ -142,13 +145,22 @@ const hasMissingModelSelected = computed(
|
||||
)
|
||||
)
|
||||
|
||||
const hasMissingMediaSelected = computed(
|
||||
() =>
|
||||
hasSelection.value &&
|
||||
selectedNodes.value.some((node) =>
|
||||
activeMissingMediaGraphIds.value.has(String(node.id))
|
||||
)
|
||||
)
|
||||
|
||||
const hasRelevantErrors = computed(() => {
|
||||
if (!hasSelection.value) return hasAnyError.value
|
||||
return (
|
||||
hasDirectNodeError.value ||
|
||||
hasContainerInternalError.value ||
|
||||
hasMissingNodeSelected.value ||
|
||||
hasMissingModelSelected.value
|
||||
hasMissingModelSelected.value ||
|
||||
hasMissingMediaSelected.value
|
||||
)
|
||||
})
|
||||
|
||||
@@ -287,11 +299,14 @@ function handleTitleCancel() {
|
||||
@cancel="handleTitleCancel"
|
||||
@click="isEditing = true"
|
||||
/>
|
||||
<i
|
||||
<button
|
||||
v-if="!isEditing"
|
||||
class="relative top-[2px] ml-2 icon-[lucide--pencil] size-4 shrink-0 cursor-pointer content-center text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.editTitle')"
|
||||
class="relative top-[2px] ml-2 size-4 shrink-0 cursor-pointer content-center text-muted-foreground hover:text-base-foreground"
|
||||
@click="isEditing = true"
|
||||
/>
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--pencil] size-4" />
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ panelTitle }}
|
||||
@@ -304,6 +319,7 @@ function handleTitleCancel() {
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
data-testid="subgraph-editor-toggle"
|
||||
:aria-label="t('rightSidePanel.editSubgraph')"
|
||||
:class="cn(isEditingSubgraph && 'bg-secondary-background-selected')"
|
||||
@click="
|
||||
rightSidePanelStore.openPanel(
|
||||
@@ -338,6 +354,7 @@ function handleTitleCancel() {
|
||||
:key="tab.value"
|
||||
class="px-2 py-1 font-inter text-sm transition-all active:scale-95"
|
||||
:value="tab.value"
|
||||
:data-testid="`panel-tab-${tab.value}`"
|
||||
>
|
||||
{{ tab.label() }}
|
||||
<i
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
<Button
|
||||
v-else-if="
|
||||
group.type === 'missing_model' &&
|
||||
downloadableModels.length > 0
|
||||
downloadableModels.length > 1
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
|
||||
@@ -660,6 +660,106 @@ export function useErrorGroups(
|
||||
]
|
||||
}
|
||||
|
||||
function isAssetErrorInSelection(executionNodeId: string): boolean {
|
||||
const nodeIds = selectedNodeInfo.value.nodeIds
|
||||
if (!nodeIds) return true
|
||||
|
||||
// Try missing node cache first
|
||||
const cachedNode = missingNodeCache.value.get(executionNodeId)
|
||||
if (cachedNode && nodeIds.has(String(cachedNode.id))) return true
|
||||
|
||||
// Resolve from graph for model/media candidates
|
||||
if (app.rootGraph) {
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, executionNodeId)
|
||||
if (graphNode && nodeIds.has(String(graphNode.id))) return true
|
||||
}
|
||||
|
||||
for (const containerExecId of selectedNodeInfo.value
|
||||
.containerExecutionIds) {
|
||||
if (executionNodeId.startsWith(`${containerExecId}:`)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const filteredMissingModelGroups = computed(() => {
|
||||
if (!selectedNodeInfo.value.nodeIds) return missingModelGroups.value
|
||||
const candidates = missingModelStore.missingModelCandidates
|
||||
if (!candidates?.length) return []
|
||||
const filtered = candidates.filter(
|
||||
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
|
||||
)
|
||||
if (!filtered.length) return []
|
||||
|
||||
const map = new Map<
|
||||
string | null | typeof UNSUPPORTED,
|
||||
{ candidates: MissingModelCandidate[]; isAssetSupported: boolean }
|
||||
>()
|
||||
for (const c of filtered) {
|
||||
const groupKey =
|
||||
c.isAssetSupported || !isCloud ? c.directory || null : UNSUPPORTED
|
||||
const existing = map.get(groupKey)
|
||||
if (existing) {
|
||||
existing.candidates.push(c)
|
||||
} else {
|
||||
map.set(groupKey, {
|
||||
candidates: [c],
|
||||
isAssetSupported: c.isAssetSupported
|
||||
})
|
||||
}
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.sort(([dirA], [dirB]) => {
|
||||
if (dirA === UNSUPPORTED) return 1
|
||||
if (dirB === UNSUPPORTED) return -1
|
||||
if (dirA === null) return 1
|
||||
if (dirB === null) return -1
|
||||
return dirA.localeCompare(dirB)
|
||||
})
|
||||
.map(([key, { candidates: groupCandidates, isAssetSupported }]) => ({
|
||||
directory: typeof key === 'string' ? key : null,
|
||||
models: groupCandidatesByName(groupCandidates),
|
||||
isAssetSupported
|
||||
}))
|
||||
})
|
||||
|
||||
const filteredMissingMediaGroups = computed(() => {
|
||||
if (!selectedNodeInfo.value.nodeIds) return missingMediaGroups.value
|
||||
const candidates = missingMediaStore.missingMediaCandidates
|
||||
if (!candidates?.length) return []
|
||||
const filtered = candidates.filter(
|
||||
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
|
||||
)
|
||||
if (!filtered.length) return []
|
||||
return groupCandidatesByMediaType(filtered)
|
||||
})
|
||||
|
||||
function buildMissingModelGroupsFiltered(): ErrorGroup[] {
|
||||
if (!filteredMissingModelGroups.value.length) return []
|
||||
return [
|
||||
{
|
||||
type: 'missing_model' as const,
|
||||
title: `${t('rightSidePanel.missingModels.missingModelsTitle')} (${filteredMissingModelGroups.value.reduce((count, group) => count + group.models.length, 0)})`,
|
||||
priority: 2
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function buildMissingMediaGroupsFiltered(): ErrorGroup[] {
|
||||
if (!filteredMissingMediaGroups.value.length) return []
|
||||
const totalItems = filteredMissingMediaGroups.value.reduce(
|
||||
(count, group) => count + group.items.length,
|
||||
0
|
||||
)
|
||||
return [
|
||||
{
|
||||
type: 'missing_media' as const,
|
||||
title: `${t('rightSidePanel.missingMedia.missingMediaTitle')} (${totalItems})`,
|
||||
priority: 3
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const allErrorGroups = computed<ErrorGroup[]>(() => {
|
||||
const groupsMap = new Map<string, GroupEntry>()
|
||||
|
||||
@@ -686,10 +786,18 @@ export function useErrorGroups(
|
||||
? toSortedGroups(regroupByErrorMessage(groupsMap))
|
||||
: toSortedGroups(groupsMap)
|
||||
|
||||
const filterByNode = selectedNodeInfo.value.nodeIds !== null
|
||||
|
||||
// Missing nodes are intentionally unfiltered — they represent
|
||||
// pack-level problems relevant regardless of which node is selected.
|
||||
return [
|
||||
...buildMissingNodeGroups(),
|
||||
...buildMissingModelGroups(),
|
||||
...buildMissingMediaGroups(),
|
||||
...(filterByNode
|
||||
? buildMissingModelGroupsFiltered()
|
||||
: buildMissingModelGroups()),
|
||||
...(filterByNode
|
||||
? buildMissingMediaGroupsFiltered()
|
||||
: buildMissingMediaGroups()),
|
||||
...executionGroups
|
||||
]
|
||||
})
|
||||
@@ -725,8 +833,8 @@ export function useErrorGroups(
|
||||
missingNodeCache,
|
||||
groupedErrorMessages,
|
||||
missingPackGroups,
|
||||
missingModelGroups,
|
||||
missingMediaGroups,
|
||||
missingModelGroups: filteredMissingModelGroups,
|
||||
missingMediaGroups: filteredMissingMediaGroups,
|
||||
swapNodeGroups
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -9,7 +9,15 @@ import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import {
|
||||
LGraphEventMode,
|
||||
NodeSlotType
|
||||
} from '@/lib/litegraph/src/types/globalEnums'
|
||||
import * as missingMediaScan from '@/platform/missingMedia/missingMediaScan'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import * as missingModelScan from '@/platform/missingModel/missingModelScan'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
|
||||
@@ -357,6 +365,371 @@ describe('installErrorClearingHooks lifecycle', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('onNodeRemoved clears missing asset errors by execution ID', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('removes root-level node missing model error using its local id', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('CheckpointLoaderSimple')
|
||||
graph.add(node)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const modelStore = useMissingModelStore()
|
||||
modelStore.setMissingModels([
|
||||
fromAny<
|
||||
Parameters<typeof modelStore.setMissingModels>[0][number],
|
||||
unknown
|
||||
>({
|
||||
nodeId: String(node.id),
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'model.safetensors',
|
||||
isMissing: true
|
||||
})
|
||||
])
|
||||
|
||||
graph.remove(node)
|
||||
|
||||
expect(modelStore.missingModelCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('removes subgraph interior node missing model error using parentId:nodeId', () => {
|
||||
// Regression: node.graph is nulled before onNodeRemoved fires, so
|
||||
// getExecutionIdByNode returned null and removal fell back to the
|
||||
// local node id. Errors stored under "parentId:nodeId" were never
|
||||
// removed for subgraph interior nodes.
|
||||
const subgraph = createTestSubgraph()
|
||||
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
const rootGraph = subgraphNode.graph as LGraph
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
|
||||
// Hooks are installed on whichever graph is currently active in
|
||||
// the canvas; when the user is inside the subgraph, that is the
|
||||
// graph whose onNodeRemoved fires for interior deletions.
|
||||
installErrorClearingHooks(subgraph)
|
||||
|
||||
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
|
||||
const modelStore = useMissingModelStore()
|
||||
modelStore.setMissingModels([
|
||||
fromAny<
|
||||
Parameters<typeof modelStore.setMissingModels>[0][number],
|
||||
unknown
|
||||
>({
|
||||
nodeId: interiorExecId,
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'model.safetensors',
|
||||
isMissing: true
|
||||
})
|
||||
])
|
||||
|
||||
subgraph.remove(interiorNode)
|
||||
|
||||
expect(modelStore.missingModelCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('removes subgraph interior node missing media and missing node errors', () => {
|
||||
const subgraph = createTestSubgraph()
|
||||
const interiorNode = new LGraphNode('LoadImage')
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
const rootGraph = subgraphNode.graph as LGraph
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
|
||||
installErrorClearingHooks(subgraph)
|
||||
|
||||
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
|
||||
|
||||
const mediaStore = useMissingMediaStore()
|
||||
mediaStore.setMissingMedia([
|
||||
fromAny<
|
||||
Parameters<typeof mediaStore.setMissingMedia>[0][number],
|
||||
unknown
|
||||
>({
|
||||
nodeId: interiorExecId,
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'cat.png',
|
||||
isMissing: true
|
||||
})
|
||||
])
|
||||
|
||||
const nodesStore = useMissingNodesErrorStore()
|
||||
nodesStore.surfaceMissingNodes([
|
||||
{
|
||||
type: 'LoadImage',
|
||||
nodeId: interiorExecId,
|
||||
cnrId: undefined,
|
||||
isReplaceable: false,
|
||||
replacement: undefined
|
||||
}
|
||||
])
|
||||
|
||||
subgraph.remove(interiorNode)
|
||||
|
||||
expect(mediaStore.missingMediaCandidates).toBeNull()
|
||||
expect(nodesStore.missingNodesError).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('realtime scan verifies pending cloud candidates', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('un-bypass path surfaces pending model candidates after verification', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('CheckpointLoaderSimple')
|
||||
graph.add(node)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
// Cloud mode returns candidates with isMissing: undefined until
|
||||
// verifyAssetSupportedCandidates resolves them against the assets store.
|
||||
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
|
||||
{
|
||||
nodeId: String(node.id),
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: true,
|
||||
name: 'cloud_model.safetensors',
|
||||
isMissing: undefined
|
||||
}
|
||||
])
|
||||
const verifySpy = vi
|
||||
.spyOn(missingModelScan, 'verifyAssetSupportedCandidates')
|
||||
.mockImplementation(async (candidates) => {
|
||||
for (const c of candidates) c.isMissing = true
|
||||
})
|
||||
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
|
||||
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
// Simulate un-bypass (BYPASS → NEVER_BY_USER is not active; use 0 = active)
|
||||
node.mode = LGraphEventMode.ALWAYS
|
||||
graph.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(verifySpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
const store = useMissingModelStore()
|
||||
expect(store.missingModelCandidates).toHaveLength(1)
|
||||
expect(store.missingModelCandidates![0].name).toBe(
|
||||
'cloud_model.safetensors'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('un-bypass path surfaces pending media candidates after verification', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('LoadImage')
|
||||
graph.add(node)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
|
||||
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([
|
||||
{
|
||||
nodeId: String(node.id),
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'cloud_image.png',
|
||||
isMissing: undefined
|
||||
}
|
||||
])
|
||||
const verifySpy = vi
|
||||
.spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
|
||||
.mockImplementation(async (candidates) => {
|
||||
for (const c of candidates) c.isMissing = true
|
||||
})
|
||||
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
node.mode = LGraphEventMode.ALWAYS
|
||||
graph.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(verifySpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
const store = useMissingMediaStore()
|
||||
expect(store.missingMediaCandidates).toHaveLength(1)
|
||||
expect(store.missingMediaCandidates![0].name).toBe('cloud_image.png')
|
||||
})
|
||||
})
|
||||
|
||||
it('does not add candidates that remain confirmed-present after verification', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('CheckpointLoaderSimple')
|
||||
graph.add(node)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
|
||||
{
|
||||
nodeId: String(node.id),
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: true,
|
||||
name: 'present.safetensors',
|
||||
isMissing: undefined
|
||||
}
|
||||
])
|
||||
vi.spyOn(
|
||||
missingModelScan,
|
||||
'verifyAssetSupportedCandidates'
|
||||
).mockImplementation(async (candidates) => {
|
||||
for (const c of candidates) c.isMissing = false
|
||||
})
|
||||
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
|
||||
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
node.mode = LGraphEventMode.ALWAYS
|
||||
graph.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
expect(useMissingModelStore().missingModelCandidates).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('realtime verification staleness guards', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('skips adding verified model when node was bypassed before verification resolved', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('CheckpointLoaderSimple')
|
||||
graph.add(node)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
|
||||
{
|
||||
nodeId: String(node.id),
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: true,
|
||||
name: 'stale_model.safetensors',
|
||||
isMissing: undefined
|
||||
}
|
||||
])
|
||||
let resolveVerify: (() => void) | undefined
|
||||
const verifyPromise = new Promise<void>((r) => (resolveVerify = r))
|
||||
const verifySpy = vi
|
||||
.spyOn(missingModelScan, 'verifyAssetSupportedCandidates')
|
||||
.mockImplementation(async (candidates) => {
|
||||
await verifyPromise
|
||||
for (const c of candidates) c.isMissing = true
|
||||
})
|
||||
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
|
||||
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
// Un-bypass: kicks off verification (still pending)
|
||||
node.mode = LGraphEventMode.ALWAYS
|
||||
graph.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce())
|
||||
|
||||
// Bypass again before verification resolves
|
||||
node.mode = LGraphEventMode.BYPASS
|
||||
|
||||
// Verification now resolves with isMissing: true, but staleness
|
||||
// check must drop the add because node is currently bypassed.
|
||||
resolveVerify!()
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(useMissingModelStore().missingModelCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('skips adding verified media when node is deleted before verification resolved', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('LoadImage')
|
||||
graph.add(node)
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
|
||||
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
|
||||
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([
|
||||
{
|
||||
nodeId: String(node.id),
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'deleted_image.png',
|
||||
isMissing: undefined
|
||||
}
|
||||
])
|
||||
let resolveVerify: (() => void) | undefined
|
||||
const verifyPromise = new Promise<void>((r) => (resolveVerify = r))
|
||||
const verifySpy = vi
|
||||
.spyOn(missingMediaScan, 'verifyCloudMediaCandidates')
|
||||
.mockImplementation(async (candidates) => {
|
||||
await verifyPromise
|
||||
for (const c of candidates) c.isMissing = true
|
||||
})
|
||||
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
node.mode = LGraphEventMode.ALWAYS
|
||||
graph.onTrigger?.({
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'mode',
|
||||
oldValue: LGraphEventMode.BYPASS,
|
||||
newValue: LGraphEventMode.ALWAYS
|
||||
})
|
||||
await vi.waitFor(() => expect(verifySpy).toHaveBeenCalledOnce())
|
||||
|
||||
// Delete the node before verification completes
|
||||
graph.remove(node)
|
||||
|
||||
resolveVerify!()
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(useMissingMediaStore().missingMediaCandidates).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearWidgetRelatedErrors parameter routing', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
@@ -8,12 +8,41 @@
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import {
|
||||
LGraphEventMode,
|
||||
NodeSlotType
|
||||
} from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { LGraphTriggerEvent } from '@/lib/litegraph/src/types/graphTriggers'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import {
|
||||
scanNodeModelCandidates,
|
||||
verifyAssetSupportedCandidates
|
||||
} from '@/platform/missingModel/missingModelScan'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import {
|
||||
scanNodeMediaCandidates,
|
||||
verifyCloudMediaCandidates
|
||||
} from '@/platform/missingMedia/missingMediaScan'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
|
||||
import { getCnrIdFromNode } from '@/platform/nodeReplacement/cnrIdUtil'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
import {
|
||||
collectAllNodes,
|
||||
getExecutionIdByNode,
|
||||
getExecutionIdForNodeInGraph,
|
||||
getNodeByExecutionId
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
|
||||
function resolvePromotedExecId(
|
||||
rootGraph: LGraph,
|
||||
@@ -121,6 +150,189 @@ function restoreNodeHooksRecursive(node: LGraphNode): void {
|
||||
}
|
||||
}
|
||||
|
||||
function isNodeInactive(mode: number): boolean {
|
||||
return mode === LGraphEventMode.NEVER || mode === LGraphEventMode.BYPASS
|
||||
}
|
||||
|
||||
/** Scan a single node and add confirmed missing model/media to stores.
|
||||
* For subgraph containers, also scans all active interior nodes. */
|
||||
function scanAndAddNodeErrors(node: LGraphNode): void {
|
||||
if (!app.rootGraph) return
|
||||
|
||||
if (node.isSubgraphNode?.() && node.subgraph) {
|
||||
for (const innerNode of collectAllNodes(node.subgraph)) {
|
||||
if (isNodeInactive(innerNode.mode)) continue
|
||||
scanSingleNodeErrors(innerNode)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
scanSingleNodeErrors(node)
|
||||
}
|
||||
|
||||
function scanSingleNodeErrors(node: LGraphNode): void {
|
||||
if (!app.rootGraph) return
|
||||
|
||||
const modelCandidates = scanNodeModelCandidates(
|
||||
app.rootGraph,
|
||||
node,
|
||||
isCloud
|
||||
? (nodeType, widgetName) =>
|
||||
assetService.shouldUseAssetBrowser(nodeType, widgetName)
|
||||
: () => false,
|
||||
(nodeType) => useModelToNodeStore().getCategoryForNodeType(nodeType)
|
||||
)
|
||||
const confirmedModels = modelCandidates.filter((c) => c.isMissing === true)
|
||||
if (confirmedModels.length) {
|
||||
useMissingModelStore().addMissingModels(confirmedModels)
|
||||
}
|
||||
// Cloud scans return isMissing: undefined for asset-browser-supported
|
||||
// widgets until async verification resolves. Without this, realtime
|
||||
// add/un-bypass paths would silently drop those candidates.
|
||||
const pendingModels = modelCandidates.filter((c) => c.isMissing === undefined)
|
||||
if (pendingModels.length) {
|
||||
void verifyAndAddPendingModels(pendingModels)
|
||||
}
|
||||
|
||||
const mediaCandidates = scanNodeMediaCandidates(app.rootGraph, node, isCloud)
|
||||
const confirmedMedia = mediaCandidates.filter((c) => c.isMissing === true)
|
||||
if (confirmedMedia.length) {
|
||||
useMissingMediaStore().addMissingMedia(confirmedMedia)
|
||||
}
|
||||
// Cloud media scans always return isMissing: undefined pending
|
||||
// verification against the input-assets list.
|
||||
const pendingMedia = mediaCandidates.filter((c) => c.isMissing === undefined)
|
||||
if (pendingMedia.length) {
|
||||
void verifyAndAddPendingMedia(pendingMedia)
|
||||
}
|
||||
|
||||
// Check for missing node type
|
||||
const originalType = node.last_serialization?.type ?? node.type ?? 'Unknown'
|
||||
if (!(originalType in LiteGraph.registered_node_types)) {
|
||||
const execId = getExecutionIdByNode(app.rootGraph, node)
|
||||
if (execId) {
|
||||
const nodeReplacementStore = useNodeReplacementStore()
|
||||
const replacement = nodeReplacementStore.getReplacementFor(originalType)
|
||||
const store = useMissingNodesErrorStore()
|
||||
const existing = store.missingNodesError?.nodeTypes ?? []
|
||||
store.surfaceMissingNodes([
|
||||
...existing,
|
||||
{
|
||||
type: originalType,
|
||||
nodeId: execId,
|
||||
cnrId: getCnrIdFromNode(node),
|
||||
isReplaceable: replacement !== null,
|
||||
replacement: replacement ?? undefined
|
||||
}
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the candidate's node still exists in the current root graph
|
||||
* and is active. Filters out late verification results for nodes that
|
||||
* have been bypassed, deleted, or belong to a workflow that is no
|
||||
* longer current — any of which would reintroduce stale errors.
|
||||
*/
|
||||
function isCandidateStillActive(nodeId: unknown): boolean {
|
||||
if (!app.rootGraph || nodeId == null) return false
|
||||
const node = getNodeByExecutionId(app.rootGraph, String(nodeId))
|
||||
if (!node) return false
|
||||
return !isNodeInactive(node.mode)
|
||||
}
|
||||
|
||||
async function verifyAndAddPendingModels(
|
||||
pending: MissingModelCandidate[]
|
||||
): Promise<void> {
|
||||
try {
|
||||
await verifyAssetSupportedCandidates(pending)
|
||||
const verified = pending.filter(
|
||||
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)
|
||||
)
|
||||
if (verified.length) useMissingModelStore().addMissingModels(verified)
|
||||
} catch (error: unknown) {
|
||||
console.warn('[useErrorClearingHooks] model verification failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyAndAddPendingMedia(
|
||||
pending: MissingMediaCandidate[]
|
||||
): Promise<void> {
|
||||
try {
|
||||
await verifyCloudMediaCandidates(pending)
|
||||
const verified = pending.filter(
|
||||
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)
|
||||
)
|
||||
if (verified.length) useMissingMediaStore().addMissingMedia(verified)
|
||||
} catch (error: unknown) {
|
||||
console.warn('[useErrorClearingHooks] media verification failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function scanAddedNode(node: LGraphNode): void {
|
||||
if (!app.rootGraph || ChangeTracker.isLoadingGraph) return
|
||||
if (isNodeInactive(node.mode)) return
|
||||
scanAndAddNodeErrors(node)
|
||||
}
|
||||
|
||||
function handleNodeModeChange(
|
||||
localGraph: LGraph,
|
||||
nodeId: number,
|
||||
oldMode: number,
|
||||
newMode: number
|
||||
): void {
|
||||
if (!app.rootGraph) return
|
||||
|
||||
const wasInactive = isNodeInactive(oldMode)
|
||||
const isNowInactive = isNodeInactive(newMode)
|
||||
|
||||
if (wasInactive === isNowInactive) return
|
||||
|
||||
// Find the node by local ID in the graph that fired the event,
|
||||
// then compute its execution ID relative to the root graph.
|
||||
const node = localGraph.getNodeById(nodeId)
|
||||
if (!node) return
|
||||
|
||||
const execId = getExecutionIdByNode(app.rootGraph, node)
|
||||
if (!execId) return
|
||||
|
||||
if (isNowInactive) {
|
||||
removeNodeErrors(node, execId)
|
||||
} else {
|
||||
scanAndAddNodeErrors(node)
|
||||
if (
|
||||
useMissingModelStore().hasMissingModels ||
|
||||
useMissingMediaStore().hasMissingMedia ||
|
||||
useMissingNodesErrorStore().hasMissingNodes
|
||||
) {
|
||||
useExecutionErrorStore().showErrorOverlay()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove all missing asset errors for a node and, if it's a subgraph
|
||||
* container, for all interior nodes (prefix match on execution ID). */
|
||||
function removeNodeErrors(node: LGraphNode, execId: string): void {
|
||||
const modelStore = useMissingModelStore()
|
||||
const mediaStore = useMissingMediaStore()
|
||||
const nodesStore = useMissingNodesErrorStore()
|
||||
|
||||
modelStore.removeMissingModelsByNodeId(execId)
|
||||
mediaStore.removeMissingMediaByNodeId(execId)
|
||||
nodesStore.removeMissingNodesByNodeId(execId)
|
||||
|
||||
// For subgraph containers, also remove errors from interior nodes.
|
||||
// The trailing colon in the prefix is load-bearing: it prevents sibling
|
||||
// IDs sharing a numeric prefix (e.g. "705" vs "70") from being matched.
|
||||
if (node.isSubgraphNode?.() && node.subgraph) {
|
||||
const prefix = `${execId}:`
|
||||
modelStore.removeMissingModelsByPrefix(prefix)
|
||||
mediaStore.removeMissingMediaByPrefix(prefix)
|
||||
nodesStore.removeMissingNodesByPrefix(prefix)
|
||||
}
|
||||
}
|
||||
|
||||
export function installErrorClearingHooks(graph: LGraph): () => void {
|
||||
for (const node of graph._nodes ?? []) {
|
||||
installNodeHooksRecursive(node)
|
||||
@@ -129,20 +341,54 @@ export function installErrorClearingHooks(graph: LGraph): () => void {
|
||||
const originalOnNodeAdded = graph.onNodeAdded
|
||||
graph.onNodeAdded = function (node: LGraphNode) {
|
||||
installNodeHooksRecursive(node)
|
||||
|
||||
// Scan pasted/duplicated nodes for missing models/media.
|
||||
// Skip during loadGraphData (undo/redo/tab switch) — those are
|
||||
// handled by the full pipeline or cache restore.
|
||||
// Deferred to microtask because onNodeAdded fires before
|
||||
// node.configure() restores widget values.
|
||||
if (!ChangeTracker.isLoadingGraph) {
|
||||
queueMicrotask(() => scanAddedNode(node))
|
||||
}
|
||||
|
||||
originalOnNodeAdded?.call(this, node)
|
||||
}
|
||||
|
||||
const originalOnNodeRemoved = graph.onNodeRemoved
|
||||
graph.onNodeRemoved = function (node: LGraphNode) {
|
||||
// node.graph is already null by the time onNodeRemoved fires, so
|
||||
// derive the execution ID from the graph the hook is installed on
|
||||
// plus node.id. For subgraph interior nodes this yields the full
|
||||
// "parentId:...:nodeId" path that matches how missing asset errors
|
||||
// are keyed; without this, removal falls back to the local ID and
|
||||
// misses subgraph entries.
|
||||
const execId = app.rootGraph
|
||||
? getExecutionIdForNodeInGraph(app.rootGraph, graph, node.id)
|
||||
: String(node.id)
|
||||
removeNodeErrors(node, execId)
|
||||
restoreNodeHooksRecursive(node)
|
||||
originalOnNodeRemoved?.call(this, node)
|
||||
}
|
||||
|
||||
const originalOnTrigger = graph.onTrigger
|
||||
graph.onTrigger = (event: LGraphTriggerEvent) => {
|
||||
if (event.type === 'node:property:changed' && event.property === 'mode') {
|
||||
handleNodeModeChange(
|
||||
graph,
|
||||
event.nodeId as number,
|
||||
event.oldValue as number,
|
||||
event.newValue as number
|
||||
)
|
||||
}
|
||||
originalOnTrigger?.(event)
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const node of graph._nodes ?? []) {
|
||||
restoreNodeHooksRecursive(node)
|
||||
}
|
||||
graph.onNodeAdded = originalOnNodeAdded || undefined
|
||||
graph.onNodeRemoved = originalOnNodeRemoved || undefined
|
||||
graph.onTrigger = originalOnTrigger || undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
138
src/composables/useReconnectingNotification.test.ts
Normal file
138
src/composables/useReconnectingNotification.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
52
src/composables/useReconnectingNotification.ts
Normal file
52
src/composables/useReconnectingNotification.ts
Normal 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 }
|
||||
}
|
||||
@@ -115,7 +115,7 @@ describe('resolvePromotedWidgetAtHost', () => {
|
||||
|
||||
expect(resolved).toBeDefined()
|
||||
expect(
|
||||
(resolved?.widget as PromotedWidgetStub).disambiguatingSourceNodeId
|
||||
(resolved!.widget as PromotedWidgetStub).disambiguatingSourceNodeId
|
||||
).toBe('2')
|
||||
})
|
||||
})
|
||||
|
||||
103
src/extensions/core/customWidgets.clone.test.ts
Normal file
103
src/extensions/core/customWidgets.clone.test.ts
Normal 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
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3403,6 +3403,8 @@
|
||||
},
|
||||
"rightSidePanel": {
|
||||
"togglePanel": "Toggle properties panel",
|
||||
"editTitle": "Edit title",
|
||||
"editSubgraph": "Edit subgraph",
|
||||
"noSelection": "Select a node to see its properties and info.",
|
||||
"workflowOverview": "Workflow Overview",
|
||||
"title": "No item(s) selected | 1 item selected | {count} items selected",
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import {
|
||||
scanAllMediaCandidates,
|
||||
scanNodeMediaCandidates,
|
||||
verifyCloudMediaCandidates,
|
||||
groupCandidatesByName,
|
||||
groupCandidatesByMediaType
|
||||
} from './missingMediaScan'
|
||||
import type { MissingMediaCandidate } from './types'
|
||||
|
||||
vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
collectAllNodes: (graph: { _testNodes: LGraphNode[] }) => graph._testNodes,
|
||||
getExecutionIdByNode: (
|
||||
_graph: unknown,
|
||||
node: { _testExecutionId?: string; id: number }
|
||||
) => node._testExecutionId ?? String(node.id)
|
||||
}))
|
||||
|
||||
function makeCandidate(
|
||||
nodeId: string,
|
||||
name: string,
|
||||
@@ -22,6 +37,122 @@ function makeCandidate(
|
||||
}
|
||||
}
|
||||
|
||||
function makeMediaCombo(
|
||||
name: string,
|
||||
value: string,
|
||||
options: string[] = []
|
||||
): IComboWidget {
|
||||
return fromAny<IComboWidget, unknown>({
|
||||
type: 'combo',
|
||||
name,
|
||||
value,
|
||||
options: { values: options }
|
||||
})
|
||||
}
|
||||
|
||||
function makeMediaNode(
|
||||
id: number,
|
||||
type: string,
|
||||
widgets: IComboWidget[],
|
||||
mode: number = 0,
|
||||
executionId?: string
|
||||
): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
id,
|
||||
type,
|
||||
widgets,
|
||||
mode,
|
||||
_testExecutionId: executionId ?? String(id)
|
||||
})
|
||||
}
|
||||
|
||||
function makeGraph(nodes: LGraphNode[]): LGraph {
|
||||
return fromAny<LGraph, unknown>({ _testNodes: nodes })
|
||||
}
|
||||
|
||||
describe('scanNodeMediaCandidates', () => {
|
||||
it('returns candidate for a LoadImage node with missing image', () => {
|
||||
const graph = makeGraph([])
|
||||
const node = makeMediaNode(
|
||||
1,
|
||||
'LoadImage',
|
||||
[makeMediaCombo('image', 'photo.png', ['other.png'])],
|
||||
0
|
||||
)
|
||||
|
||||
const result = scanNodeMediaCandidates(graph, node, false)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toEqual({
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'photo.png',
|
||||
isMissing: true
|
||||
})
|
||||
})
|
||||
|
||||
it('returns empty for non-media node types', () => {
|
||||
const graph = makeGraph([])
|
||||
const node = makeMediaNode(
|
||||
1,
|
||||
'KSampler',
|
||||
[makeMediaCombo('sampler', 'euler', ['euler', 'dpm'])],
|
||||
0
|
||||
)
|
||||
|
||||
const result = scanNodeMediaCandidates(graph, node, false)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty for node with no widgets', () => {
|
||||
const graph = makeGraph([])
|
||||
const node = makeMediaNode(1, 'LoadImage', [], 0)
|
||||
|
||||
const result = scanNodeMediaCandidates(graph, node, false)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('scanAllMediaCandidates', () => {
|
||||
it('skips muted nodes (mode === NEVER)', () => {
|
||||
const node = makeMediaNode(
|
||||
1,
|
||||
'LoadImage',
|
||||
[makeMediaCombo('image', 'photo.png', ['other.png'])],
|
||||
2 // NEVER
|
||||
)
|
||||
const result = scanAllMediaCandidates(makeGraph([node]), false)
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips bypassed nodes (mode === BYPASS)', () => {
|
||||
const node = makeMediaNode(
|
||||
2,
|
||||
'LoadImage',
|
||||
[makeMediaCombo('image', 'photo.png', ['other.png'])],
|
||||
4 // BYPASS
|
||||
)
|
||||
const result = scanAllMediaCandidates(makeGraph([node]), false)
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('includes active nodes (mode === ALWAYS)', () => {
|
||||
const node = makeMediaNode(
|
||||
3,
|
||||
'LoadImage',
|
||||
[makeMediaCombo('image', 'photo.png', ['other.png'])],
|
||||
0 // ALWAYS
|
||||
)
|
||||
const result = scanAllMediaCandidates(makeGraph([node]), false)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].isMissing).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupCandidatesByName', () => {
|
||||
it('groups candidates with the same name', () => {
|
||||
const candidates = [
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
MediaType
|
||||
} from './types'
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IComboWidget
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
collectAllNodes,
|
||||
getExecutionIdByNode
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { resolveComboValues } from '@/utils/litegraphUtil'
|
||||
|
||||
/** Map of node types to their media widget name and media type. */
|
||||
@@ -49,38 +51,56 @@ export function scanAllMediaCandidates(
|
||||
for (const node of allNodes) {
|
||||
if (!node.widgets?.length) continue
|
||||
if (node.isSubgraphNode?.()) continue
|
||||
if (
|
||||
node.mode === LGraphEventMode.NEVER ||
|
||||
node.mode === LGraphEventMode.BYPASS
|
||||
)
|
||||
continue
|
||||
|
||||
const mediaInfo = MEDIA_NODE_WIDGETS[node.type]
|
||||
if (!mediaInfo) continue
|
||||
candidates.push(...scanNodeMediaCandidates(rootGraph, node, isCloud))
|
||||
}
|
||||
|
||||
const executionId = getExecutionIdByNode(rootGraph, node)
|
||||
if (!executionId) continue
|
||||
return candidates
|
||||
}
|
||||
|
||||
for (const widget of node.widgets) {
|
||||
if (!isComboWidget(widget)) continue
|
||||
if (widget.name !== mediaInfo.widgetName) continue
|
||||
/** Scan a single node for missing media candidates (OSS immediate resolution). */
|
||||
export function scanNodeMediaCandidates(
|
||||
rootGraph: LGraph,
|
||||
node: LGraphNode,
|
||||
isCloud: boolean
|
||||
): MissingMediaCandidate[] {
|
||||
if (!node.widgets?.length) return []
|
||||
|
||||
const value = widget.value
|
||||
if (typeof value !== 'string' || !value.trim()) continue
|
||||
const mediaInfo = MEDIA_NODE_WIDGETS[node.type]
|
||||
if (!mediaInfo) return []
|
||||
|
||||
let isMissing: boolean | undefined
|
||||
if (isCloud) {
|
||||
// Cloud: options may be empty initially; defer to async verification
|
||||
isMissing = undefined
|
||||
} else {
|
||||
const options = resolveComboValues(widget)
|
||||
isMissing = !options.includes(value)
|
||||
}
|
||||
const executionId = getExecutionIdByNode(rootGraph, node)
|
||||
if (!executionId) return []
|
||||
|
||||
candidates.push({
|
||||
nodeId: executionId as NodeId,
|
||||
nodeType: node.type,
|
||||
widgetName: widget.name,
|
||||
mediaType: mediaInfo.mediaType,
|
||||
name: value,
|
||||
isMissing
|
||||
})
|
||||
const candidates: MissingMediaCandidate[] = []
|
||||
for (const widget of node.widgets) {
|
||||
if (!isComboWidget(widget)) continue
|
||||
if (widget.name !== mediaInfo.widgetName) continue
|
||||
|
||||
const value = widget.value
|
||||
if (typeof value !== 'string' || !value.trim()) continue
|
||||
|
||||
let isMissing: boolean | undefined
|
||||
if (isCloud) {
|
||||
isMissing = undefined
|
||||
} else {
|
||||
const options = resolveComboValues(widget)
|
||||
isMissing = !options.includes(value)
|
||||
}
|
||||
|
||||
candidates.push({
|
||||
nodeId: executionId as NodeId,
|
||||
nodeType: node.type,
|
||||
widgetName: widget.name,
|
||||
mediaType: mediaInfo.mediaType,
|
||||
name: value,
|
||||
isMissing
|
||||
})
|
||||
}
|
||||
|
||||
return candidates
|
||||
|
||||
@@ -194,4 +194,224 @@ describe('useMissingMediaStore', () => {
|
||||
store.createVerificationAbortController()
|
||||
expect(first.signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
describe('addMissingMedia', () => {
|
||||
it('appends to existing candidates', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([makeCandidate('1', 'photo.png')])
|
||||
|
||||
store.addMissingMedia([makeCandidate('2', 'clip.mp4', 'video')])
|
||||
|
||||
expect(store.missingMediaCandidates).toHaveLength(2)
|
||||
expect(store.missingMediaCandidates![0].name).toBe('photo.png')
|
||||
expect(store.missingMediaCandidates![1].name).toBe('clip.mp4')
|
||||
})
|
||||
|
||||
it('works when store is empty (candidates are null)', () => {
|
||||
const store = useMissingMediaStore()
|
||||
expect(store.missingMediaCandidates).toBeNull()
|
||||
|
||||
store.addMissingMedia([makeCandidate('1', 'photo.png')])
|
||||
|
||||
expect(store.missingMediaCandidates).toHaveLength(1)
|
||||
expect(store.hasMissingMedia).toBe(true)
|
||||
})
|
||||
|
||||
it('does nothing when given empty array', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([makeCandidate('1', 'photo.png')])
|
||||
|
||||
store.addMissingMedia([])
|
||||
|
||||
expect(store.missingMediaCandidates).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeMissingMediaByNodeId', () => {
|
||||
it('removes all candidates matching the nodeId', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
makeCandidate('1', 'photo.png'),
|
||||
makeCandidate('1', 'other.png'),
|
||||
makeCandidate('2', 'clip.mp4', 'video')
|
||||
])
|
||||
|
||||
store.removeMissingMediaByNodeId('1')
|
||||
|
||||
expect(store.missingMediaCandidates).toHaveLength(1)
|
||||
expect(store.missingMediaCandidates![0].name).toBe('clip.mp4')
|
||||
})
|
||||
|
||||
it('keeps candidates with non-matching nodeId', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([makeCandidate('1', 'photo.png')])
|
||||
|
||||
store.removeMissingMediaByNodeId('99')
|
||||
|
||||
expect(store.missingMediaCandidates).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('sets candidates to null when all are removed', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
makeCandidate('1', 'photo.png'),
|
||||
makeCandidate('1', 'other.png')
|
||||
])
|
||||
|
||||
store.removeMissingMediaByNodeId('1')
|
||||
|
||||
expect(store.missingMediaCandidates).toBeNull()
|
||||
expect(store.hasMissingMedia).toBe(false)
|
||||
})
|
||||
|
||||
it('cleans interaction state for removed names', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
makeCandidate('1', 'photo.png'),
|
||||
makeCandidate('2', 'clip.mp4', 'video')
|
||||
])
|
||||
store.expandState['photo.png'] = true
|
||||
store.uploadState['photo.png'] = {
|
||||
fileName: 'photo.png',
|
||||
status: 'uploaded'
|
||||
}
|
||||
store.pendingSelection['photo.png'] = 'uploaded/photo.png'
|
||||
|
||||
store.removeMissingMediaByNodeId('1')
|
||||
|
||||
expect(store.expandState['photo.png']).toBeUndefined()
|
||||
expect(store.uploadState['photo.png']).toBeUndefined()
|
||||
expect(store.pendingSelection['photo.png']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves interaction state when other candidates share the name', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
makeCandidate('1', 'photo.png'),
|
||||
makeCandidate('2', 'photo.png')
|
||||
])
|
||||
store.pendingSelection['photo.png'] = 'library/photo.png'
|
||||
|
||||
store.removeMissingMediaByNodeId('1')
|
||||
|
||||
expect(store.missingMediaCandidates).toHaveLength(1)
|
||||
expect(store.pendingSelection['photo.png']).toBe('library/photo.png')
|
||||
})
|
||||
|
||||
it('does nothing when candidates are null', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.removeMissingMediaByNodeId('1')
|
||||
expect(store.missingMediaCandidates).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeMissingMediaByPrefix', () => {
|
||||
it('removes all candidates whose nodeId starts with the prefix', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
makeCandidate('65:70:63', 'a.png'),
|
||||
makeCandidate('65:70:64', 'b.png'),
|
||||
makeCandidate('65:80:5', 'c.png')
|
||||
])
|
||||
|
||||
store.removeMissingMediaByPrefix('65:70:')
|
||||
|
||||
expect(store.missingMediaCandidates).toHaveLength(1)
|
||||
expect(store.missingMediaCandidates![0].nodeId).toBe('65:80:5')
|
||||
})
|
||||
|
||||
it('removes deeply nested interior nodes under the container', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
makeCandidate('65:70:63', 'a.png'),
|
||||
makeCandidate('65:70:80:5', 'b.png'),
|
||||
makeCandidate('65:71:63', 'c.png')
|
||||
])
|
||||
|
||||
store.removeMissingMediaByPrefix('65:70:')
|
||||
|
||||
expect(store.missingMediaCandidates).toHaveLength(1)
|
||||
expect(store.missingMediaCandidates![0].nodeId).toBe('65:71:63')
|
||||
})
|
||||
|
||||
it('does not match siblings that share a numeric prefix (trailing colon)', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
makeCandidate('65:70:1', 'a.png'),
|
||||
makeCandidate('65:705:1', 'b.png'),
|
||||
makeCandidate('65:70', 'c.png')
|
||||
])
|
||||
|
||||
store.removeMissingMediaByPrefix('65:70:')
|
||||
|
||||
expect(store.missingMediaCandidates).toHaveLength(2)
|
||||
const remainingIds = store.missingMediaCandidates!.map((m) =>
|
||||
String(m.nodeId)
|
||||
)
|
||||
expect(remainingIds).toContain('65:705:1')
|
||||
expect(remainingIds).toContain('65:70')
|
||||
})
|
||||
|
||||
it('sets candidates to null when all are removed', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
makeCandidate('65:70:63', 'a.png'),
|
||||
makeCandidate('65:70:64', 'b.png')
|
||||
])
|
||||
|
||||
store.removeMissingMediaByPrefix('65:70:')
|
||||
|
||||
expect(store.missingMediaCandidates).toBeNull()
|
||||
expect(store.hasMissingMedia).toBe(false)
|
||||
})
|
||||
|
||||
it('does nothing when no candidates match', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([makeCandidate('65:71:1', 'a.png')])
|
||||
|
||||
store.removeMissingMediaByPrefix('65:70:')
|
||||
|
||||
expect(store.missingMediaCandidates).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does nothing when candidates are null', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.removeMissingMediaByPrefix('65:70:')
|
||||
expect(store.missingMediaCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves candidates with a nullish nodeId (defensive)', () => {
|
||||
const store = useMissingMediaStore()
|
||||
const orphan = {
|
||||
nodeId: undefined as unknown as string,
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image' as const,
|
||||
name: 'orphan.png',
|
||||
isMissing: true
|
||||
}
|
||||
store.setMissingMedia([makeCandidate('65:70:63', 'a.png'), orphan])
|
||||
|
||||
store.removeMissingMediaByPrefix('65:70:')
|
||||
|
||||
expect(store.missingMediaCandidates).toHaveLength(1)
|
||||
expect(store.missingMediaCandidates![0].name).toBe('orphan.png')
|
||||
})
|
||||
|
||||
it('clears interaction state for removed names not used elsewhere', () => {
|
||||
const store = useMissingMediaStore()
|
||||
store.setMissingMedia([
|
||||
makeCandidate('65:70:63', 'shared.png'),
|
||||
makeCandidate('65:80:5', 'shared.png'),
|
||||
makeCandidate('65:70:64', 'only-interior.png')
|
||||
])
|
||||
store.pendingSelection['shared.png'] = 'library/shared.png'
|
||||
store.pendingSelection['only-interior.png'] = 'library/interior.png'
|
||||
|
||||
store.removeMissingMediaByPrefix('65:70:')
|
||||
|
||||
expect(store.pendingSelection['only-interior.png']).toBeUndefined()
|
||||
expect(store.pendingSelection['shared.png']).toBe('library/shared.png')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -121,6 +121,74 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
|
||||
missingMediaCandidates.value = null
|
||||
}
|
||||
|
||||
function removeMissingMediaByNodeId(nodeId: string) {
|
||||
if (!missingMediaCandidates.value) return
|
||||
const removedNames = new Set(
|
||||
missingMediaCandidates.value
|
||||
.filter((m) => String(m.nodeId) === nodeId)
|
||||
.map((m) => m.name)
|
||||
)
|
||||
missingMediaCandidates.value = missingMediaCandidates.value.filter(
|
||||
(m) => String(m.nodeId) !== nodeId
|
||||
)
|
||||
for (const name of removedNames) {
|
||||
if (!missingMediaCandidates.value.some((m) => m.name === name)) {
|
||||
clearInteractionStateForName(name)
|
||||
}
|
||||
}
|
||||
if (!missingMediaCandidates.value.length)
|
||||
missingMediaCandidates.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all candidates whose nodeId starts with `prefix`.
|
||||
*
|
||||
* Intended for clearing all interior errors when a subgraph container is
|
||||
* removed. Callers are expected to pass `${execId}:` (with trailing
|
||||
* colon) so that sibling IDs sharing a numeric prefix (e.g. `"705"` vs
|
||||
* `"70"`) are not matched.
|
||||
*/
|
||||
function removeMissingMediaByPrefix(prefix: string) {
|
||||
if (!missingMediaCandidates.value) return
|
||||
const removedNames = new Set<string>()
|
||||
const remaining: MissingMediaCandidate[] = []
|
||||
for (const m of missingMediaCandidates.value) {
|
||||
// Preserve candidates without a nodeId; they cannot belong to any
|
||||
// subgraph scope. The type marks nodeId as required, but defensive
|
||||
// handling matches the rest of the missing-media code.
|
||||
if (m.nodeId == null) {
|
||||
remaining.push(m)
|
||||
continue
|
||||
}
|
||||
if (String(m.nodeId).startsWith(prefix)) {
|
||||
removedNames.add(m.name)
|
||||
} else {
|
||||
remaining.push(m)
|
||||
}
|
||||
}
|
||||
if (removedNames.size === 0) return
|
||||
missingMediaCandidates.value = remaining.length ? remaining : null
|
||||
for (const name of removedNames) {
|
||||
if (!remaining.some((m) => m.name === name)) {
|
||||
clearInteractionStateForName(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addMissingMedia(media: MissingMediaCandidate[]) {
|
||||
if (!media.length) return
|
||||
const existing = missingMediaCandidates.value ?? []
|
||||
const existingKeys = new Set(
|
||||
existing.map((m) => `${String(m.nodeId)}::${m.widgetName}::${m.name}`)
|
||||
)
|
||||
const newMedia = media.filter(
|
||||
(m) =>
|
||||
!existingKeys.has(`${String(m.nodeId)}::${m.widgetName}::${m.name}`)
|
||||
)
|
||||
if (!newMedia.length) return
|
||||
missingMediaCandidates.value = [...existing, ...newMedia]
|
||||
}
|
||||
|
||||
function clearMissingMedia() {
|
||||
_verificationAbortController?.abort()
|
||||
_verificationAbortController = null
|
||||
@@ -139,8 +207,11 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
|
||||
activeMissingMediaGraphIds,
|
||||
|
||||
setMissingMedia,
|
||||
addMissingMedia,
|
||||
removeMissingMediaByName,
|
||||
removeMissingMediaByWidget,
|
||||
removeMissingMediaByNodeId,
|
||||
removeMissingMediaByPrefix,
|
||||
clearMissingMedia,
|
||||
createVerificationAbortController,
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
@@ -206,6 +206,7 @@ import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import {
|
||||
downloadModel,
|
||||
fetchModelMetadata,
|
||||
isModelDownloadable,
|
||||
toBrowsableUrl
|
||||
} from '@/platform/missingModel/missingModelDownload'
|
||||
@@ -244,6 +245,24 @@ const store = useMissingModelStore()
|
||||
const { selectedLibraryModel, importCategoryMismatch, urlInputs } =
|
||||
storeToRefs(store)
|
||||
|
||||
onMounted(() => {
|
||||
const url = model.representative.url
|
||||
if (url && !store.fileSizes[url]) {
|
||||
fetchModelMetadata(url)
|
||||
.then((metadata) => {
|
||||
if (metadata.fileSize !== null) {
|
||||
store.setFileSize(url, metadata.fileSize)
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.warn(
|
||||
`[MissingModelRow] Failed to fetch metadata for ${url}:`,
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const downloadable = computed(() => {
|
||||
const rep = model.representative
|
||||
return !!(
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import {
|
||||
scanAllModelCandidates,
|
||||
scanNodeModelCandidates,
|
||||
isModelFileName,
|
||||
enrichWithEmbeddedMetadata,
|
||||
verifyAssetSupportedCandidates,
|
||||
@@ -111,6 +112,52 @@ describe('MODEL_FILE_EXTENSIONS', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('scanNodeModelCandidates', () => {
|
||||
it('returns candidates for a node with a missing model combo widget', () => {
|
||||
const graph = makeGraph([])
|
||||
const node = makeNode(1, 'CheckpointLoaderSimple', [
|
||||
makeComboWidget('ckpt_name', 'missing_model.safetensors', [
|
||||
'existing_model.safetensors'
|
||||
])
|
||||
])
|
||||
|
||||
const result = scanNodeModelCandidates(graph, node, noAssetSupport)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toEqual({
|
||||
nodeId: '1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'missing_model.safetensors',
|
||||
isMissing: true
|
||||
})
|
||||
})
|
||||
|
||||
it('returns empty array for node with no widgets', () => {
|
||||
const graph = makeGraph([])
|
||||
const node = makeNode(1, 'EmptyNode', [])
|
||||
|
||||
const result = scanNodeModelCandidates(graph, node, noAssetSupport)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when executionId is null', () => {
|
||||
const graph = makeGraph([])
|
||||
const node = makeNode(
|
||||
1,
|
||||
'CheckpointLoaderSimple',
|
||||
[makeComboWidget('ckpt_name', 'model.safetensors', [])],
|
||||
''
|
||||
)
|
||||
|
||||
const result = scanNodeModelCandidates(graph, node, noAssetSupport)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('scanAllModelCandidates', () => {
|
||||
it('should detect a missing model from a combo widget', () => {
|
||||
const graph = makeGraph([
|
||||
@@ -390,6 +437,58 @@ describe('scanAllModelCandidates', () => {
|
||||
expect(result[1].widgetName).toBe('vae_name')
|
||||
})
|
||||
|
||||
it('skips muted nodes (mode === NEVER)', () => {
|
||||
const mutedNode = fromAny<LGraphNode, unknown>({
|
||||
id: 10,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
widgets: [
|
||||
makeComboWidget('ckpt_name', 'model.safetensors', ['other.safetensors'])
|
||||
],
|
||||
mode: 2, // LGraphEventMode.NEVER
|
||||
_testExecutionId: '10'
|
||||
})
|
||||
|
||||
const graph = makeGraph([mutedNode])
|
||||
const result = scanAllModelCandidates(graph, noAssetSupport)
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips bypassed nodes (mode === BYPASS)', () => {
|
||||
const bypassedNode = fromAny<LGraphNode, unknown>({
|
||||
id: 11,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
widgets: [
|
||||
makeComboWidget('ckpt_name', 'model.safetensors', ['other.safetensors'])
|
||||
],
|
||||
mode: 4, // LGraphEventMode.BYPASS
|
||||
_testExecutionId: '11'
|
||||
})
|
||||
|
||||
const graph = makeGraph([bypassedNode])
|
||||
const result = scanAllModelCandidates(graph, noAssetSupport)
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('includes active nodes (mode === ALWAYS)', () => {
|
||||
const activeNode = fromAny<LGraphNode, unknown>({
|
||||
id: 12,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
widgets: [
|
||||
makeComboWidget('ckpt_name', 'model.safetensors', ['other.safetensors'])
|
||||
],
|
||||
mode: 0, // LGraphEventMode.ALWAYS
|
||||
_testExecutionId: '12'
|
||||
})
|
||||
|
||||
const graph = makeGraph([activeNode])
|
||||
const result = scanAllModelCandidates(graph, noAssetSupport)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].isMissing).toBe(true)
|
||||
})
|
||||
|
||||
it('skips subgraph container nodes whose promoted widgets are already scanned via interior nodes', () => {
|
||||
const containerNode = fromAny<LGraphNode, unknown>({
|
||||
id: 65,
|
||||
@@ -638,6 +737,194 @@ describe('enrichWithEmbeddedMetadata', () => {
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips embedded models from muted nodes', async () => {
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 2, // NEVER (muted)
|
||||
properties: {},
|
||||
widgets_values: { ckpt_name: 'model.safetensors' }
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4,
|
||||
models: [
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://example.com/model',
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
alwaysMissing
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('drops workflow-level model entries when only referencing nodes are bypassed (other active nodes present)', async () => {
|
||||
// Regression: a previous `hasActiveNodes` check kept workflow-level
|
||||
// models in a mixed graph if ANY active node existed, even when every
|
||||
// node that actually referenced the model was bypassed. The correct
|
||||
// check drops unmatched workflow-level entries since candidates are
|
||||
// derived from active-node widgets.
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 2,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 4, // BYPASS — only node referencing the model
|
||||
properties: {},
|
||||
widgets_values: { ckpt_name: 'model.safetensors' }
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'KSampler',
|
||||
pos: [200, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0, // ALWAYS — unrelated active node
|
||||
properties: {},
|
||||
widgets_values: {}
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4,
|
||||
models: [
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://example.com/model',
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
alwaysMissing
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps unmatched node-sourced entries in a mixed graph', async () => {
|
||||
// A node-sourced unmatched entry (sourceNodeType !== '') must survive
|
||||
// the workflow-level filter. This ensures the simplification does not
|
||||
// over-filter legitimate per-node missing models.
|
||||
const candidates = [
|
||||
makeCandidate('node_model.safetensors', { nodeId: '1' })
|
||||
]
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
properties: {
|
||||
models: [
|
||||
{
|
||||
name: 'node_model.safetensors',
|
||||
url: 'https://example.com/node_model',
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
},
|
||||
widgets_values: { ckpt_name: 'node_model.safetensors' }
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4,
|
||||
models: []
|
||||
})
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
alwaysMissing
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toBe('node_model.safetensors')
|
||||
})
|
||||
|
||||
it('skips embedded models from bypassed nodes', async () => {
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
const graphData = fromPartial<ComfyWorkflowJSON>({
|
||||
last_node_id: 1,
|
||||
last_link_id: 0,
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'CheckpointLoaderSimple',
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 4, // BYPASS
|
||||
properties: {},
|
||||
widgets_values: { ckpt_name: 'model.safetensors' }
|
||||
}
|
||||
],
|
||||
links: [],
|
||||
groups: [],
|
||||
config: {},
|
||||
extra: {},
|
||||
version: 0.4,
|
||||
models: [
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://example.com/model',
|
||||
directory: 'checkpoints'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const result = await enrichWithEmbeddedMetadata(
|
||||
candidates,
|
||||
graphData,
|
||||
alwaysMissing
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('OSS missing model detection (non-Cloud path)', () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
// eslint-disable-next-line import-x/no-restricted-paths
|
||||
import { getSelectedModelsMetadata } from '@/workbench/utils/modelMetadataUtil'
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type {
|
||||
IAssetWidget,
|
||||
IBaseWidget,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
collectAllNodes,
|
||||
getExecutionIdByNode
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { resolveComboValues } from '@/utils/litegraphUtil'
|
||||
|
||||
function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
|
||||
@@ -73,27 +75,54 @@ export function scanAllModelCandidates(
|
||||
// Skip subgraph container nodes: their promoted widgets are synthetic
|
||||
// views of interior widgets, which are already scanned via recursion.
|
||||
if (node.isSubgraphNode?.()) continue
|
||||
if (
|
||||
node.mode === LGraphEventMode.NEVER ||
|
||||
node.mode === LGraphEventMode.BYPASS
|
||||
)
|
||||
continue
|
||||
|
||||
const executionId = getExecutionIdByNode(rootGraph, node)
|
||||
if (!executionId) continue
|
||||
candidates.push(
|
||||
...scanNodeModelCandidates(
|
||||
rootGraph,
|
||||
node,
|
||||
isAssetSupported,
|
||||
getDirectory
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
for (const widget of node.widgets) {
|
||||
let candidate: MissingModelCandidate | null = null
|
||||
return candidates
|
||||
}
|
||||
|
||||
if (isAssetWidget(widget)) {
|
||||
candidate = scanAssetWidget(node, widget, executionId, getDirectory)
|
||||
} else if (isComboWidget(widget)) {
|
||||
candidate = scanComboWidget(
|
||||
node,
|
||||
widget,
|
||||
executionId,
|
||||
isAssetSupported,
|
||||
getDirectory
|
||||
)
|
||||
}
|
||||
/** Scan a single node's widgets for missing model candidates (OSS immediate resolution). */
|
||||
export function scanNodeModelCandidates(
|
||||
rootGraph: LGraph,
|
||||
node: LGraphNode,
|
||||
isAssetSupported: (nodeType: string, widgetName: string) => boolean,
|
||||
getDirectory?: (nodeType: string) => string | undefined
|
||||
): MissingModelCandidate[] {
|
||||
if (!node.widgets?.length) return []
|
||||
|
||||
if (candidate) candidates.push(candidate)
|
||||
const executionId = getExecutionIdByNode(rootGraph, node)
|
||||
if (!executionId) return []
|
||||
|
||||
const candidates: MissingModelCandidate[] = []
|
||||
for (const widget of node.widgets) {
|
||||
let candidate: MissingModelCandidate | null = null
|
||||
|
||||
if (isAssetWidget(widget)) {
|
||||
candidate = scanAssetWidget(node, widget, executionId, getDirectory)
|
||||
} else if (isComboWidget(widget)) {
|
||||
candidate = scanComboWidget(
|
||||
node,
|
||||
widget,
|
||||
executionId,
|
||||
isAssetSupported,
|
||||
getDirectory
|
||||
)
|
||||
}
|
||||
|
||||
if (candidate) candidates.push(candidate)
|
||||
}
|
||||
|
||||
return candidates
|
||||
@@ -197,8 +226,18 @@ export async function enrichWithEmbeddedMetadata(
|
||||
}
|
||||
}
|
||||
|
||||
// Workflow-level entries (sourceNodeType === '') survive only when
|
||||
// some active (non-muted, non-bypassed) node actually references the
|
||||
// model — not merely because any unrelated active node exists. A
|
||||
// reference is any widget value (or node.properties.models entry)
|
||||
// that matches the model name on an active node.
|
||||
const activeUnmatched = unmatched.filter(
|
||||
(m) =>
|
||||
m.sourceNodeType !== '' || isModelReferencedByActiveNode(m.name, allNodes)
|
||||
)
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
unmatched.map(async (model) => {
|
||||
activeUnmatched.map(async (model) => {
|
||||
const installed = await checkModelInstalled(model.name, model.directory)
|
||||
if (installed) return null
|
||||
|
||||
@@ -235,6 +274,32 @@ export async function enrichWithEmbeddedMetadata(
|
||||
return enriched
|
||||
}
|
||||
|
||||
function isModelReferencedByActiveNode(
|
||||
modelName: string,
|
||||
allNodes: ReturnType<typeof flattenWorkflowNodes>
|
||||
): boolean {
|
||||
for (const node of allNodes) {
|
||||
if (
|
||||
node.mode === LGraphEventMode.NEVER ||
|
||||
node.mode === LGraphEventMode.BYPASS
|
||||
)
|
||||
continue
|
||||
|
||||
const embeddedModels = (
|
||||
node.properties as { models?: Array<{ name: string }> } | undefined
|
||||
)?.models
|
||||
if (embeddedModels?.some((m) => m.name === modelName)) return true
|
||||
|
||||
const values = node.widgets_values
|
||||
if (!values) continue
|
||||
const valueArray = Array.isArray(values) ? values : Object.values(values)
|
||||
for (const v of valueArray) {
|
||||
if (typeof v === 'string' && v === modelName) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function collectEmbeddedModelsWithSource(
|
||||
allNodes: ReturnType<typeof flattenWorkflowNodes>,
|
||||
graphData: ComfyWorkflowJSON
|
||||
@@ -242,6 +307,12 @@ function collectEmbeddedModelsWithSource(
|
||||
const result: EmbeddedModelWithSource[] = []
|
||||
|
||||
for (const node of allNodes) {
|
||||
if (
|
||||
node.mode === LGraphEventMode.NEVER ||
|
||||
node.mode === LGraphEventMode.BYPASS
|
||||
)
|
||||
continue
|
||||
|
||||
const selected = getSelectedModelsMetadata(
|
||||
node as Parameters<typeof getSelectedModelsMetadata>[0]
|
||||
)
|
||||
|
||||
@@ -244,4 +244,218 @@ describe('missingModelStore', () => {
|
||||
expect(store.missingModelCandidates).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('addMissingModels', () => {
|
||||
it('appends to existing candidates', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors', { nodeId: '1' })
|
||||
])
|
||||
|
||||
store.addMissingModels([
|
||||
makeModelCandidate('model_b.safetensors', { nodeId: '2' })
|
||||
])
|
||||
|
||||
expect(store.missingModelCandidates).toHaveLength(2)
|
||||
expect(store.missingModelCandidates![0].name).toBe('model_a.safetensors')
|
||||
expect(store.missingModelCandidates![1].name).toBe('model_b.safetensors')
|
||||
})
|
||||
|
||||
it('works when store is empty (candidates are null)', () => {
|
||||
const store = useMissingModelStore()
|
||||
expect(store.missingModelCandidates).toBeNull()
|
||||
|
||||
store.addMissingModels([
|
||||
makeModelCandidate('model_a.safetensors', { nodeId: '1' })
|
||||
])
|
||||
|
||||
expect(store.missingModelCandidates).toHaveLength(1)
|
||||
expect(store.hasMissingModels).toBe(true)
|
||||
})
|
||||
|
||||
it('does nothing when given empty array', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors', { nodeId: '1' })
|
||||
])
|
||||
|
||||
store.addMissingModels([])
|
||||
|
||||
expect(store.missingModelCandidates).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeMissingModelsByNodeId', () => {
|
||||
it('removes all candidates matching the nodeId', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors', {
|
||||
nodeId: '1',
|
||||
widgetName: 'ckpt_name'
|
||||
}),
|
||||
makeModelCandidate('model_b.safetensors', {
|
||||
nodeId: '1',
|
||||
widgetName: 'vae_name'
|
||||
}),
|
||||
makeModelCandidate('model_c.safetensors', { nodeId: '2' })
|
||||
])
|
||||
|
||||
store.removeMissingModelsByNodeId('1')
|
||||
|
||||
expect(store.missingModelCandidates).toHaveLength(1)
|
||||
expect(store.missingModelCandidates![0].name).toBe('model_c.safetensors')
|
||||
})
|
||||
|
||||
it('keeps candidates with non-matching nodeId', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors', { nodeId: '1' }),
|
||||
makeModelCandidate('model_b.safetensors', { nodeId: '2' })
|
||||
])
|
||||
|
||||
store.removeMissingModelsByNodeId('99')
|
||||
|
||||
expect(store.missingModelCandidates).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('sets candidates to null when all are removed', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('model_a.safetensors', { nodeId: '1' }),
|
||||
makeModelCandidate('model_b.safetensors', { nodeId: '1' })
|
||||
])
|
||||
|
||||
store.removeMissingModelsByNodeId('1')
|
||||
|
||||
expect(store.missingModelCandidates).toBeNull()
|
||||
expect(store.hasMissingModels).toBe(false)
|
||||
})
|
||||
|
||||
it('does nothing when candidates are null', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.removeMissingModelsByNodeId('1')
|
||||
expect(store.missingModelCandidates).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeMissingModelsByPrefix', () => {
|
||||
it('removes all candidates whose nodeId starts with the prefix', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('a.safetensors', { nodeId: '65:70:63' }),
|
||||
makeModelCandidate('b.safetensors', { nodeId: '65:70:64' }),
|
||||
makeModelCandidate('c.safetensors', { nodeId: '65:80:5' })
|
||||
])
|
||||
|
||||
store.removeMissingModelsByPrefix('65:70:')
|
||||
|
||||
expect(store.missingModelCandidates).toHaveLength(1)
|
||||
expect(store.missingModelCandidates![0].nodeId).toBe('65:80:5')
|
||||
})
|
||||
|
||||
it('removes deeply nested interior nodes under the container', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('a.safetensors', { nodeId: '65:70:63' }),
|
||||
makeModelCandidate('b.safetensors', { nodeId: '65:70:80:5' }),
|
||||
makeModelCandidate('c.safetensors', { nodeId: '65:71:63' })
|
||||
])
|
||||
|
||||
store.removeMissingModelsByPrefix('65:70:')
|
||||
|
||||
expect(store.missingModelCandidates).toHaveLength(1)
|
||||
expect(store.missingModelCandidates![0].nodeId).toBe('65:71:63')
|
||||
})
|
||||
|
||||
it('does not match siblings that share a numeric prefix (trailing colon)', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('a.safetensors', { nodeId: '65:70:1' }),
|
||||
makeModelCandidate('b.safetensors', { nodeId: '65:705:1' }),
|
||||
makeModelCandidate('c.safetensors', { nodeId: '65:70' })
|
||||
])
|
||||
|
||||
store.removeMissingModelsByPrefix('65:70:')
|
||||
|
||||
expect(store.missingModelCandidates).toHaveLength(2)
|
||||
const remainingIds = store.missingModelCandidates!.map((m) =>
|
||||
String(m.nodeId)
|
||||
)
|
||||
expect(remainingIds).toContain('65:705:1')
|
||||
expect(remainingIds).toContain('65:70')
|
||||
})
|
||||
|
||||
it('sets candidates to null when all are removed', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('a.safetensors', { nodeId: '65:70:63' }),
|
||||
makeModelCandidate('b.safetensors', { nodeId: '65:70:64' })
|
||||
])
|
||||
|
||||
store.removeMissingModelsByPrefix('65:70:')
|
||||
|
||||
expect(store.missingModelCandidates).toBeNull()
|
||||
expect(store.hasMissingModels).toBe(false)
|
||||
})
|
||||
|
||||
it('does nothing when no candidates match', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('a.safetensors', { nodeId: '65:71:1' })
|
||||
])
|
||||
|
||||
store.removeMissingModelsByPrefix('65:70:')
|
||||
|
||||
expect(store.missingModelCandidates).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does nothing when candidates are null', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.removeMissingModelsByPrefix('65:70:')
|
||||
expect(store.missingModelCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves workflow-level candidates without a nodeId', () => {
|
||||
const store = useMissingModelStore()
|
||||
const workflowLevel: MissingModelCandidate = {
|
||||
name: 'workflow-level.safetensors',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
isMissing: true
|
||||
}
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('a.safetensors', { nodeId: '65:70:63' }),
|
||||
workflowLevel
|
||||
])
|
||||
|
||||
store.removeMissingModelsByPrefix('65:70:')
|
||||
|
||||
expect(store.missingModelCandidates).toHaveLength(1)
|
||||
expect(store.missingModelCandidates![0].name).toBe(
|
||||
'workflow-level.safetensors'
|
||||
)
|
||||
})
|
||||
|
||||
it('clears interaction state for removed names not used elsewhere', () => {
|
||||
const store = useMissingModelStore()
|
||||
store.setMissingModels([
|
||||
makeModelCandidate('shared.safetensors', { nodeId: '65:70:63' }),
|
||||
makeModelCandidate('shared.safetensors', { nodeId: '65:80:5' }),
|
||||
makeModelCandidate('only-interior.safetensors', { nodeId: '65:70:64' })
|
||||
])
|
||||
store.urlInputs['shared.safetensors'] = 'https://example.com/shared'
|
||||
store.urlInputs['only-interior.safetensors'] =
|
||||
'https://example.com/interior'
|
||||
|
||||
store.removeMissingModelsByPrefix('65:70:')
|
||||
|
||||
// 'only-interior' fully removed → interaction state cleared.
|
||||
// 'shared' still referenced by 65:80:5 → interaction state preserved.
|
||||
expect(store.urlInputs['only-interior.safetensors']).toBeUndefined()
|
||||
expect(store.urlInputs['shared.safetensors']).toBe(
|
||||
'https://example.com/shared'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -128,6 +128,85 @@ export const useMissingModelStore = defineStore('missingModel', () => {
|
||||
missingModelCandidates.value = null
|
||||
}
|
||||
|
||||
function clearInteractionStateForName(name: string) {
|
||||
delete modelExpandState.value[name]
|
||||
delete selectedLibraryModel.value[name]
|
||||
delete importCategoryMismatch.value[name]
|
||||
delete importTaskIds.value[name]
|
||||
delete urlInputs.value[name]
|
||||
delete urlMetadata.value[name]
|
||||
delete urlFetching.value[name]
|
||||
delete urlErrors.value[name]
|
||||
delete urlImporting.value[name]
|
||||
}
|
||||
|
||||
function removeMissingModelsByNodeId(nodeId: string) {
|
||||
if (!missingModelCandidates.value) return
|
||||
const removedNames = new Set(
|
||||
missingModelCandidates.value
|
||||
.filter((m) => String(m.nodeId) === nodeId)
|
||||
.map((m) => m.name)
|
||||
)
|
||||
missingModelCandidates.value = missingModelCandidates.value.filter(
|
||||
(m) => String(m.nodeId) !== nodeId
|
||||
)
|
||||
for (const name of removedNames) {
|
||||
if (!missingModelCandidates.value.some((m) => m.name === name)) {
|
||||
clearInteractionStateForName(name)
|
||||
}
|
||||
}
|
||||
if (!missingModelCandidates.value.length)
|
||||
missingModelCandidates.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all candidates whose nodeId starts with `prefix`.
|
||||
*
|
||||
* Intended for clearing all interior errors when a subgraph container is
|
||||
* removed. Callers are expected to pass `${execId}:` (with trailing
|
||||
* colon) so that sibling IDs sharing a numeric prefix (e.g. `"705"` vs
|
||||
* `"70"`) are not matched.
|
||||
*/
|
||||
function removeMissingModelsByPrefix(prefix: string) {
|
||||
if (!missingModelCandidates.value) return
|
||||
const removedNames = new Set<string>()
|
||||
const remaining: MissingModelCandidate[] = []
|
||||
for (const m of missingModelCandidates.value) {
|
||||
// Preserve workflow-level candidates with no nodeId; they are not
|
||||
// tied to any subgraph scope and should never be matched by prefix.
|
||||
if (m.nodeId == null) {
|
||||
remaining.push(m)
|
||||
continue
|
||||
}
|
||||
if (String(m.nodeId).startsWith(prefix)) {
|
||||
removedNames.add(m.name)
|
||||
} else {
|
||||
remaining.push(m)
|
||||
}
|
||||
}
|
||||
if (removedNames.size === 0) return
|
||||
missingModelCandidates.value = remaining.length ? remaining : null
|
||||
for (const name of removedNames) {
|
||||
if (!remaining.some((m) => m.name === name)) {
|
||||
clearInteractionStateForName(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addMissingModels(models: MissingModelCandidate[]) {
|
||||
if (!models.length) return
|
||||
const existing = missingModelCandidates.value ?? []
|
||||
const existingKeys = new Set(
|
||||
existing.map((m) => `${String(m.nodeId)}::${m.widgetName}::${m.name}`)
|
||||
)
|
||||
const newModels = models.filter(
|
||||
(m) =>
|
||||
!existingKeys.has(`${String(m.nodeId)}::${m.widgetName}::${m.name}`)
|
||||
)
|
||||
if (!newModels.length) return
|
||||
missingModelCandidates.value = [...existing, ...newModels]
|
||||
}
|
||||
|
||||
function hasMissingModelOnNode(nodeLocatorId: string): boolean {
|
||||
return missingModelNodeIds.value.has(nodeLocatorId)
|
||||
}
|
||||
@@ -200,8 +279,11 @@ export const useMissingModelStore = defineStore('missingModel', () => {
|
||||
missingModelAncestorExecutionIds,
|
||||
|
||||
setMissingModels,
|
||||
addMissingModels,
|
||||
removeMissingModelByNameOnNodes,
|
||||
removeMissingModelByWidget,
|
||||
removeMissingModelsByNodeId,
|
||||
removeMissingModelsByPrefix,
|
||||
clearMissingModels,
|
||||
createVerificationAbortController,
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNod
|
||||
function mockNode(
|
||||
id: number,
|
||||
type: string,
|
||||
overrides: Partial<LGraphNode> = {}
|
||||
overrides: Record<string, unknown> = {}
|
||||
): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
id,
|
||||
@@ -215,6 +215,47 @@ describe('scanMissingNodes (via rescanAndSurfaceMissingNodes)', () => {
|
||||
expect(typeof missing !== 'string' && missing.isReplaceable).toBe(false)
|
||||
})
|
||||
|
||||
it('skips muted nodes (mode NEVER = 2)', () => {
|
||||
vi.mocked(collectAllNodes).mockReturnValue([
|
||||
mockNode(1, 'MutedNode', { mode: 2 })
|
||||
])
|
||||
vi.mocked(getExecutionIdByNode).mockReturnValue(null)
|
||||
|
||||
rescanAndSurfaceMissingNodes(mockGraph())
|
||||
|
||||
const store = useMissingNodesErrorStore()
|
||||
expect(store.missingNodesError).toBeNull()
|
||||
})
|
||||
|
||||
it('skips bypassed nodes (mode BYPASS = 4)', () => {
|
||||
vi.mocked(collectAllNodes).mockReturnValue([
|
||||
mockNode(1, 'BypassedNode', { mode: 4 })
|
||||
])
|
||||
vi.mocked(getExecutionIdByNode).mockReturnValue(null)
|
||||
|
||||
rescanAndSurfaceMissingNodes(mockGraph())
|
||||
|
||||
const store = useMissingNodesErrorStore()
|
||||
expect(store.missingNodesError).toBeNull()
|
||||
})
|
||||
|
||||
it('detects active nodes (mode ALWAYS = 0) as missing', () => {
|
||||
vi.mocked(collectAllNodes).mockReturnValue([
|
||||
mockNode(1, 'ActiveMissingNode', { mode: 0 })
|
||||
])
|
||||
vi.mocked(getExecutionIdByNode).mockReturnValue(null)
|
||||
|
||||
rescanAndSurfaceMissingNodes(mockGraph())
|
||||
|
||||
const store = useMissingNodesErrorStore()
|
||||
const error = getMissingNodesError(store)
|
||||
expect(error.nodeTypes).toHaveLength(1)
|
||||
const missing = error.nodeTypes[0]
|
||||
expect(typeof missing !== 'string' && missing.type).toBe(
|
||||
'ActiveMissingNode'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses last_serialization.type over node.type', () => {
|
||||
const node = mockNode(1, 'LiveType')
|
||||
node.last_serialization = fromPartial<LGraphNode['last_serialization']>({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacementStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
@@ -18,6 +19,12 @@ function scanMissingNodes(rootGraph: LGraph): MissingNodeType[] {
|
||||
const allNodes = collectAllNodes(rootGraph)
|
||||
|
||||
for (const node of allNodes) {
|
||||
if (
|
||||
node.mode === LGraphEventMode.NEVER ||
|
||||
node.mode === LGraphEventMode.BYPASS
|
||||
)
|
||||
continue
|
||||
|
||||
const originalType = node.last_serialization?.type ?? node.type ?? 'Unknown'
|
||||
|
||||
if (originalType in LiteGraph.registered_node_types) continue
|
||||
|
||||
@@ -212,4 +212,146 @@ describe('missingNodesErrorStore', () => {
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeMissingNodesByNodeId', () => {
|
||||
it('removes entries matching the nodeId', () => {
|
||||
const store = useMissingNodesErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
|
||||
{ type: 'NodeB', nodeId: '2', isReplaceable: false }
|
||||
])
|
||||
|
||||
store.removeMissingNodesByNodeId('1')
|
||||
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
|
||||
const remaining = store.missingNodesError?.nodeTypes[0]
|
||||
expect(typeof remaining !== 'string' && remaining?.nodeId).toBe('2')
|
||||
})
|
||||
|
||||
it('keeps string entries (they have no nodeId)', () => {
|
||||
const store = useMissingNodesErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
'StringNode',
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
|
||||
] as MissingNodeType[])
|
||||
|
||||
store.removeMissingNodesByNodeId('1')
|
||||
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
|
||||
expect(store.missingNodesError?.nodeTypes[0]).toBe('StringNode')
|
||||
})
|
||||
|
||||
it('keeps entries with different nodeIds', () => {
|
||||
const store = useMissingNodesErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
|
||||
{ type: 'NodeB', nodeId: '2', isReplaceable: false },
|
||||
{ type: 'NodeC', nodeId: '3', isReplaceable: false }
|
||||
])
|
||||
|
||||
store.removeMissingNodesByNodeId('2')
|
||||
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('clears missingNodesError when all object entries are removed', () => {
|
||||
const store = useMissingNodesErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false }
|
||||
])
|
||||
|
||||
store.removeMissingNodesByNodeId('1')
|
||||
|
||||
expect(store.missingNodesError).toBeNull()
|
||||
expect(store.hasMissingNodes).toBe(false)
|
||||
})
|
||||
|
||||
it('does nothing when missingNodesError is null', () => {
|
||||
const store = useMissingNodesErrorStore()
|
||||
store.removeMissingNodesByNodeId('1')
|
||||
expect(store.missingNodesError).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeMissingNodesByPrefix', () => {
|
||||
it('removes object entries whose nodeId starts with the prefix', () => {
|
||||
const store = useMissingNodesErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
{ type: 'A', nodeId: '65:70:63', isReplaceable: false },
|
||||
{ type: 'B', nodeId: '65:70:64', isReplaceable: false },
|
||||
{ type: 'C', nodeId: '65:80:5', isReplaceable: false }
|
||||
])
|
||||
|
||||
store.removeMissingNodesByPrefix('65:70:')
|
||||
|
||||
const remaining = store.missingNodesError?.nodeTypes ?? []
|
||||
expect(remaining).toHaveLength(1)
|
||||
const first = remaining[0]
|
||||
expect(typeof first !== 'string' && first.nodeId).toBe('65:80:5')
|
||||
})
|
||||
|
||||
it('removes deeply nested interior entries', () => {
|
||||
const store = useMissingNodesErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
{ type: 'A', nodeId: '65:70:63', isReplaceable: false },
|
||||
{ type: 'B', nodeId: '65:70:80:5', isReplaceable: false },
|
||||
{ type: 'C', nodeId: '65:71:63', isReplaceable: false }
|
||||
])
|
||||
|
||||
store.removeMissingNodesByPrefix('65:70:')
|
||||
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not match siblings sharing a numeric prefix (trailing colon)', () => {
|
||||
const store = useMissingNodesErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
{ type: 'A', nodeId: '65:70:1', isReplaceable: false },
|
||||
{ type: 'B', nodeId: '65:705:1', isReplaceable: false },
|
||||
{ type: 'C', nodeId: '65:70', isReplaceable: false }
|
||||
])
|
||||
|
||||
store.removeMissingNodesByPrefix('65:70:')
|
||||
|
||||
const remaining = store.missingNodesError?.nodeTypes ?? []
|
||||
expect(remaining).toHaveLength(2)
|
||||
const remainingIds = remaining.map((n) =>
|
||||
typeof n === 'string' ? n : String(n.nodeId)
|
||||
)
|
||||
expect(remainingIds).toContain('65:705:1')
|
||||
expect(remainingIds).toContain('65:70')
|
||||
})
|
||||
|
||||
it('preserves string entries (no nodeId)', () => {
|
||||
const store = useMissingNodesErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
'StringNode',
|
||||
{ type: 'A', nodeId: '65:70:1', isReplaceable: false }
|
||||
] as MissingNodeType[])
|
||||
|
||||
store.removeMissingNodesByPrefix('65:70:')
|
||||
|
||||
expect(store.missingNodesError?.nodeTypes).toHaveLength(1)
|
||||
expect(store.missingNodesError?.nodeTypes[0]).toBe('StringNode')
|
||||
})
|
||||
|
||||
it('clears missingNodesError when all matching entries are removed and none remain', () => {
|
||||
const store = useMissingNodesErrorStore()
|
||||
store.setMissingNodeTypes([
|
||||
{ type: 'A', nodeId: '65:70:63', isReplaceable: false },
|
||||
{ type: 'B', nodeId: '65:70:64', isReplaceable: false }
|
||||
])
|
||||
|
||||
store.removeMissingNodesByPrefix('65:70:')
|
||||
|
||||
expect(store.missingNodesError).toBeNull()
|
||||
expect(store.hasMissingNodes).toBe(false)
|
||||
})
|
||||
|
||||
it('does nothing when missingNodesError is null', () => {
|
||||
const store = useMissingNodesErrorStore()
|
||||
store.removeMissingNodesByPrefix('65:70:')
|
||||
expect(store.missingNodesError).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -64,6 +64,33 @@ export const useMissingNodesErrorStore = defineStore(
|
||||
)
|
||||
}
|
||||
|
||||
function removeMissingNodesByNodeId(nodeId: string) {
|
||||
if (!missingNodesError.value) return
|
||||
const remaining = missingNodesError.value.nodeTypes.filter((node) => {
|
||||
if (typeof node === 'string') return true
|
||||
return node.nodeId !== nodeId
|
||||
})
|
||||
setMissingNodeTypes(remaining)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all object-type entries whose nodeId starts with `prefix`.
|
||||
* String entries (group nodes) have no nodeId and are preserved.
|
||||
*
|
||||
* Intended for clearing all interior errors when a subgraph container
|
||||
* is removed. Callers are expected to pass `${execId}:` (with trailing
|
||||
* colon) so that sibling IDs sharing a numeric prefix are not matched.
|
||||
*/
|
||||
function removeMissingNodesByPrefix(prefix: string) {
|
||||
if (!missingNodesError.value) return
|
||||
const remaining = missingNodesError.value.nodeTypes.filter((node) => {
|
||||
if (typeof node === 'string') return true
|
||||
if (node.nodeId == null) return true
|
||||
return !String(node.nodeId).startsWith(prefix)
|
||||
})
|
||||
setMissingNodeTypes(remaining)
|
||||
}
|
||||
|
||||
/** Remove specific node types from the missing nodes list (e.g. after replacement). */
|
||||
function removeMissingNodesByType(typesToRemove: string[]) {
|
||||
if (!missingNodesError.value) return
|
||||
@@ -115,6 +142,8 @@ export const useMissingNodesErrorStore = defineStore(
|
||||
missingNodesError,
|
||||
setMissingNodeTypes,
|
||||
surfaceMissingNodes,
|
||||
removeMissingNodesByNodeId,
|
||||
removeMissingNodesByPrefix,
|
||||
removeMissingNodesByType,
|
||||
hasMissingNodes,
|
||||
missingNodeCount,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -13,6 +13,9 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workfl
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import type { AppMode } from '@/composables/useAppMode'
|
||||
@@ -115,6 +118,12 @@ vi.mock('@/stores/domWidgetStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/subgraphNavigationStore', () => ({
|
||||
useSubgraphNavigationStore: () => ({
|
||||
saveCurrentViewport: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspaceStore', () => ({
|
||||
useWorkspaceStore: () => ({
|
||||
get workflow() {
|
||||
@@ -159,16 +168,16 @@ describe('useWorkflowService', () => {
|
||||
enableWarningSettings()
|
||||
})
|
||||
|
||||
it('should do nothing when workflow has no pending warnings', () => {
|
||||
it('should clear missing nodes when workflow has no pending warnings', () => {
|
||||
const workflow = createWorkflow(null)
|
||||
useWorkflowService().showPendingWarnings(workflow)
|
||||
|
||||
expect(
|
||||
useMissingNodesErrorStore().surfaceMissingNodes
|
||||
).not.toHaveBeenCalled()
|
||||
).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
it('should surface missing nodes and clear warnings', () => {
|
||||
it('should surface missing nodes and cache warnings', () => {
|
||||
const missingNodeTypes = ['CustomNode1', 'CustomNode2']
|
||||
const workflow = createWorkflow({ missingNodeTypes })
|
||||
|
||||
@@ -177,7 +186,11 @@ describe('useWorkflowService', () => {
|
||||
expect(
|
||||
useMissingNodesErrorStore().surfaceMissingNodes
|
||||
).toHaveBeenCalledWith(missingNodeTypes)
|
||||
expect(workflow.pendingWarnings).toBeNull()
|
||||
expect(workflow.pendingWarnings).toEqual({
|
||||
missingNodeTypes,
|
||||
missingModelCandidates: undefined,
|
||||
missingMediaCandidates: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('should always surface missing nodes regardless of settings', () => {
|
||||
@@ -192,10 +205,10 @@ describe('useWorkflowService', () => {
|
||||
expect(
|
||||
useMissingNodesErrorStore().surfaceMissingNodes
|
||||
).toHaveBeenCalledWith(['CustomNode1'])
|
||||
expect(workflow.pendingWarnings).toBeNull()
|
||||
expect(workflow.pendingWarnings).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should only show warnings once across multiple calls', () => {
|
||||
it('should restore cached warnings on repeated calls', () => {
|
||||
const workflow = createWorkflow({
|
||||
missingNodeTypes: ['CustomNode1']
|
||||
})
|
||||
@@ -206,7 +219,96 @@ describe('useWorkflowService', () => {
|
||||
|
||||
expect(
|
||||
useMissingNodesErrorStore().surfaceMissingNodes
|
||||
).toHaveBeenCalledTimes(1)
|
||||
).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should NOT call showErrorOverlay when silent is true even with missing nodes', () => {
|
||||
vi.spyOn(useSettingStore(), 'get').mockImplementation(
|
||||
(key: string): boolean => {
|
||||
if (key === 'Comfy.Workflow.ShowMissingModelsWarning') return true
|
||||
if (key === 'Comfy.RightSidePanel.ShowErrorsTab') return true
|
||||
return false
|
||||
}
|
||||
)
|
||||
const workflow = createWorkflow({
|
||||
missingNodeTypes: ['CustomNode1']
|
||||
})
|
||||
|
||||
useWorkflowService().showPendingWarnings(workflow, { silent: true })
|
||||
|
||||
expect(
|
||||
useMissingNodesErrorStore().surfaceMissingNodes
|
||||
).toHaveBeenCalledWith(['CustomNode1'])
|
||||
expect(useExecutionErrorStore().showErrorOverlay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call showErrorOverlay when silent is false and missing nodes exist', () => {
|
||||
vi.spyOn(useSettingStore(), 'get').mockImplementation(
|
||||
(key: string): boolean => {
|
||||
if (key === 'Comfy.Workflow.ShowMissingModelsWarning') return true
|
||||
if (key === 'Comfy.RightSidePanel.ShowErrorsTab') return true
|
||||
return false
|
||||
}
|
||||
)
|
||||
const workflow = createWorkflow({
|
||||
missingNodeTypes: ['CustomNode1']
|
||||
})
|
||||
|
||||
useWorkflowService().showPendingWarnings(workflow)
|
||||
|
||||
expect(
|
||||
useMissingNodesErrorStore().surfaceMissingNodes
|
||||
).toHaveBeenCalledWith(['CustomNode1'])
|
||||
expect(useExecutionErrorStore().showErrorOverlay).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('beforeLoadNewGraph', () => {
|
||||
let workflowStore: ReturnType<typeof useWorkflowStore>
|
||||
|
||||
beforeEach(() => {
|
||||
enableWarningSettings()
|
||||
workflowStore = useWorkflowStore()
|
||||
})
|
||||
|
||||
it('should cache missingModelCandidates and missingMediaCandidates to activeWorkflow.pendingWarnings', () => {
|
||||
const activeWorkflow = createModeTestWorkflow({
|
||||
path: 'workflows/test.json'
|
||||
})
|
||||
workflowStore.activeWorkflow = activeWorkflow
|
||||
|
||||
const modelCandidates = [
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'missing.safetensors',
|
||||
isMissing: true
|
||||
}
|
||||
]
|
||||
const mediaCandidates = [
|
||||
{
|
||||
nodeId: '2',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image' as const,
|
||||
name: 'photo.png',
|
||||
isMissing: true
|
||||
}
|
||||
]
|
||||
|
||||
useMissingModelStore().missingModelCandidates = modelCandidates as never
|
||||
useMissingMediaStore().missingMediaCandidates = mediaCandidates as never
|
||||
|
||||
useWorkflowService().beforeLoadNewGraph()
|
||||
|
||||
expect(activeWorkflow.pendingWarnings).toEqual(
|
||||
expect.objectContaining({
|
||||
missingModelCandidates: modelCandidates,
|
||||
missingMediaCandidates: mediaCandidates
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -245,7 +347,7 @@ describe('useWorkflowService', () => {
|
||||
expect(
|
||||
useMissingNodesErrorStore().surfaceMissingNodes
|
||||
).toHaveBeenCalledWith(['CustomNode1'])
|
||||
expect(workflow.pendingWarnings).toBeNull()
|
||||
expect(workflow.pendingWarnings).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should show each workflow warnings only when that tab is focused', async () => {
|
||||
@@ -267,7 +369,7 @@ describe('useWorkflowService', () => {
|
||||
expect(
|
||||
useMissingNodesErrorStore().surfaceMissingNodes
|
||||
).toHaveBeenCalledWith(['MissingNodeA'])
|
||||
expect(workflow1.pendingWarnings).toBeNull()
|
||||
expect(workflow1.pendingWarnings).not.toBeNull()
|
||||
expect(workflow2.pendingWarnings).not.toBeNull()
|
||||
|
||||
await service.openWorkflow(workflow2)
|
||||
@@ -277,10 +379,10 @@ describe('useWorkflowService', () => {
|
||||
expect(
|
||||
useMissingNodesErrorStore().surfaceMissingNodes
|
||||
).toHaveBeenLastCalledWith(['MissingNodeB'])
|
||||
expect(workflow2.pendingWarnings).toBeNull()
|
||||
expect(workflow2.pendingWarnings).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should not show warnings when refocusing a cleared tab', async () => {
|
||||
it('should restore cached warnings silently when refocusing a tab', async () => {
|
||||
const workflow = createWorkflow(
|
||||
{ missingNodeTypes: ['CustomNode1'] },
|
||||
{ loadable: true }
|
||||
@@ -294,9 +396,10 @@ describe('useWorkflowService', () => {
|
||||
).toHaveBeenCalledTimes(1)
|
||||
|
||||
await service.openWorkflow(workflow, { force: true })
|
||||
// Cached warnings are restored on refocus
|
||||
expect(
|
||||
useMissingNodesErrorStore().surfaceMissingNodes
|
||||
).toHaveBeenCalledTimes(1)
|
||||
).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import {
|
||||
appendJsonExt,
|
||||
@@ -44,7 +46,6 @@ export const useWorkflowService = () => {
|
||||
const dialogService = useDialogService()
|
||||
const workflowThumbnail = useWorkflowThumbnail()
|
||||
const domWidgetStore = useDomWidgetStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const missingNodesErrorStore = useMissingNodesErrorStore()
|
||||
const workflowDraftStore = useWorkflowDraftStore()
|
||||
|
||||
@@ -253,13 +254,14 @@ export const useWorkflowService = () => {
|
||||
/* restore_view=*/ true,
|
||||
workflow,
|
||||
{
|
||||
showMissingModels: loadFromRemote,
|
||||
showMissingNodes: true,
|
||||
checkForRerouteMigration: false,
|
||||
deferWarnings: true
|
||||
deferWarnings: true,
|
||||
skipAssetScans: !loadFromRemote && !options.force
|
||||
}
|
||||
)
|
||||
showPendingWarnings()
|
||||
showPendingWarnings(undefined, {
|
||||
silent: !loadFromRemote && !options.force
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -389,6 +391,29 @@ export const useWorkflowService = () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cache missing model/media/node state for restore on tab switch.
|
||||
// Always overwrite to reflect the current store state (e.g. after
|
||||
// muting a node cleared its errors).
|
||||
const modelCandidates = useMissingModelStore().missingModelCandidates
|
||||
const mediaCandidates = useMissingMediaStore().missingMediaCandidates
|
||||
const nodeTypes = missingNodesErrorStore.missingNodesError?.nodeTypes
|
||||
activeWorkflow.pendingWarnings = {
|
||||
missingNodeTypes: nodeTypes?.length ? [...nodeTypes] : undefined,
|
||||
missingModelCandidates: modelCandidates?.length
|
||||
? modelCandidates
|
||||
: undefined,
|
||||
missingMediaCandidates: mediaCandidates?.length
|
||||
? mediaCandidates
|
||||
: undefined
|
||||
}
|
||||
if (
|
||||
!activeWorkflow.pendingWarnings.missingNodeTypes &&
|
||||
!activeWorkflow.pendingWarnings.missingModelCandidates &&
|
||||
!activeWorkflow.pendingWarnings.missingMediaCandidates
|
||||
) {
|
||||
activeWorkflow.pendingWarnings = null
|
||||
}
|
||||
|
||||
// Capture thumbnail before loading new graph
|
||||
void workflowThumbnail.storeThumbnail(activeWorkflow)
|
||||
domWidgetStore.clear()
|
||||
@@ -550,17 +575,43 @@ export const useWorkflowService = () => {
|
||||
* active workflow. Called after a workflow becomes visible so dialogs don't
|
||||
* overlap with subsequent loads.
|
||||
*/
|
||||
function showPendingWarnings(workflow?: ComfyWorkflow | null) {
|
||||
function showPendingWarnings(
|
||||
workflow?: ComfyWorkflow | null,
|
||||
options?: { silent?: boolean }
|
||||
) {
|
||||
const wf = workflow ?? workflowStore.activeWorkflow
|
||||
if (!wf?.pendingWarnings) return
|
||||
if (!wf) return
|
||||
|
||||
const { missingNodeTypes } = wf.pendingWarnings
|
||||
wf.pendingWarnings = null
|
||||
const { missingNodeTypes, missingModelCandidates, missingMediaCandidates } =
|
||||
wf.pendingWarnings ?? {}
|
||||
|
||||
if (missingNodeTypes?.length) {
|
||||
if (missingNodesErrorStore.surfaceMissingNodes(missingNodeTypes)) {
|
||||
executionErrorStore.showErrorOverlay()
|
||||
// Always sync missing nodes store (clear when empty).
|
||||
if (
|
||||
missingNodesErrorStore.surfaceMissingNodes(missingNodeTypes ?? []) &&
|
||||
!options?.silent
|
||||
) {
|
||||
useExecutionErrorStore().showErrorOverlay()
|
||||
}
|
||||
if (missingModelCandidates?.length) {
|
||||
useMissingModelStore().setMissingModels(missingModelCandidates)
|
||||
}
|
||||
if (missingMediaCandidates?.length) {
|
||||
useMissingMediaStore().setMissingMedia(missingMediaCandidates)
|
||||
}
|
||||
|
||||
// Keep cache for future tab switches
|
||||
if (
|
||||
missingNodeTypes?.length ||
|
||||
missingModelCandidates?.length ||
|
||||
missingMediaCandidates?.length
|
||||
) {
|
||||
wf.pendingWarnings = {
|
||||
missingNodeTypes,
|
||||
missingModelCandidates,
|
||||
missingMediaCandidates
|
||||
}
|
||||
} else {
|
||||
wf.pendingWarnings = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { UserFile } from '@/stores/userFileStore'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
|
||||
export interface LinearData {
|
||||
@@ -16,9 +17,8 @@ export interface LinearData {
|
||||
|
||||
export interface PendingWarnings {
|
||||
missingNodeTypes?: MissingNodeType[]
|
||||
// TODO: Currently unused — missing models are surfaced directly on every
|
||||
// graph load. Reserved for future per-workflow missing model state management.
|
||||
missingModelCandidates?: MissingModelCandidate[]
|
||||
missingMediaCandidates?: MissingMediaCandidate[]
|
||||
}
|
||||
|
||||
export class ComfyWorkflow extends UserFile {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 ?? '']) {
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { snapPoint } from '@/lib/litegraph/src/measure'
|
||||
import type { Vector2 } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
@@ -86,6 +87,7 @@ import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { graphToPrompt } from '@/utils/executionUtil'
|
||||
import { getCnrIdFromProperties } from '@/platform/nodeReplacement/cnrIdUtil'
|
||||
import { rescanAndSurfaceMissingNodes } from '@/platform/nodeReplacement/missingNodeScan'
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import {
|
||||
scanAllModelCandidates,
|
||||
enrichWithEmbeddedMetadata,
|
||||
@@ -93,6 +95,7 @@ import {
|
||||
} from '@/platform/missingModel/missingModelScan'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
||||
import {
|
||||
scanAllMediaCandidates,
|
||||
verifyCloudMediaCandidates
|
||||
@@ -1125,24 +1128,35 @@ export class ComfyApp {
|
||||
restore_view: boolean = true,
|
||||
workflow: string | null | ComfyWorkflow = null,
|
||||
options: {
|
||||
showMissingNodes?: boolean
|
||||
showMissingModels?: boolean
|
||||
checkForRerouteMigration?: boolean
|
||||
openSource?: WorkflowOpenSource
|
||||
deferWarnings?: boolean
|
||||
skipAssetScans?: boolean
|
||||
silentAssetErrors?: boolean
|
||||
} = {}
|
||||
) {
|
||||
const {
|
||||
showMissingNodes = true,
|
||||
showMissingModels = true,
|
||||
checkForRerouteMigration = false,
|
||||
openSource,
|
||||
deferWarnings = false
|
||||
deferWarnings = false,
|
||||
skipAssetScans = false,
|
||||
silentAssetErrors = false
|
||||
} = options
|
||||
useWorkflowService().beforeLoadNewGraph()
|
||||
|
||||
useMissingModelStore().clearMissingModels()
|
||||
useMissingMediaStore().clearMissingMedia()
|
||||
if (skipAssetScans) {
|
||||
// Only reset candidates; preserve UI state (fileSizes, urlInputs, etc.)
|
||||
// so cached results restored by showPendingWarnings still display sizes.
|
||||
// Abort any in-flight verification from the outgoing workflow so a late
|
||||
// result cannot repopulate the store after we've switched workflows.
|
||||
useMissingModelStore().createVerificationAbortController().abort()
|
||||
useMissingMediaStore().createVerificationAbortController().abort()
|
||||
useMissingModelStore().setMissingModels([])
|
||||
useMissingMediaStore().setMissingMedia([])
|
||||
} else {
|
||||
useMissingModelStore().clearMissingModels()
|
||||
useMissingMediaStore().clearMissingMedia()
|
||||
}
|
||||
|
||||
if (clean !== false) {
|
||||
// Reset canvas context before configuring a new graph so subgraph UI
|
||||
@@ -1218,24 +1232,31 @@ export class ComfyApp {
|
||||
}
|
||||
for (let n of nodes) {
|
||||
if (!(n.type in LiteGraph.registered_node_types)) {
|
||||
const replacement = nodeReplacementStore.getReplacementFor(n.type)
|
||||
const cnrId = getCnrIdFromProperties(
|
||||
n.properties as Record<string, unknown> | undefined
|
||||
)
|
||||
const executionId = pathPrefix
|
||||
? `${pathPrefix}:${n.id}`
|
||||
: String(n.id)
|
||||
// Always sanitize so configure() can handle unregistered types,
|
||||
// but only report as missing if the node is active.
|
||||
const isMuted =
|
||||
n.mode === LGraphEventMode.NEVER ||
|
||||
n.mode === LGraphEventMode.BYPASS
|
||||
if (!isMuted) {
|
||||
const replacement = nodeReplacementStore.getReplacementFor(n.type)
|
||||
const cnrId = getCnrIdFromProperties(
|
||||
n.properties as Record<string, unknown> | undefined
|
||||
)
|
||||
const executionId = pathPrefix
|
||||
? `${pathPrefix}:${n.id}`
|
||||
: String(n.id)
|
||||
|
||||
missingNodeTypes.push({
|
||||
type: n.type,
|
||||
nodeId: executionId,
|
||||
cnrId,
|
||||
...(displayName && {
|
||||
hint: t('g.inSubgraph', { name: displayName })
|
||||
}),
|
||||
isReplaceable: replacement !== null,
|
||||
replacement: replacement ?? undefined
|
||||
})
|
||||
missingNodeTypes.push({
|
||||
type: n.type,
|
||||
nodeId: executionId,
|
||||
cnrId,
|
||||
...(displayName && {
|
||||
hint: t('g.inSubgraph', { name: displayName })
|
||||
}),
|
||||
isReplaceable: replacement !== null,
|
||||
replacement: replacement ?? undefined
|
||||
})
|
||||
}
|
||||
|
||||
n.type = sanitizeNodeName(n.type)
|
||||
}
|
||||
@@ -1415,17 +1436,20 @@ export class ComfyApp {
|
||||
requestAnimationFrame(() => fitView())
|
||||
}
|
||||
|
||||
await this.runMissingModelPipeline(
|
||||
graphData,
|
||||
missingNodeTypes,
|
||||
showMissingNodes,
|
||||
showMissingModels
|
||||
)
|
||||
if (!skipAssetScans) {
|
||||
await this.runMissingModelPipeline(
|
||||
graphData,
|
||||
missingNodeTypes,
|
||||
silentAssetErrors
|
||||
)
|
||||
|
||||
await this.runMissingMediaPipeline()
|
||||
await this.runMissingMediaPipeline(silentAssetErrors)
|
||||
}
|
||||
|
||||
if (!deferWarnings) {
|
||||
useWorkflowService().showPendingWarnings()
|
||||
useWorkflowService().showPendingWarnings(undefined, {
|
||||
silent: silentAssetErrors
|
||||
})
|
||||
}
|
||||
|
||||
void useSubgraphNavigationStore().updateHash()
|
||||
@@ -1440,8 +1464,7 @@ export class ComfyApp {
|
||||
private async runMissingModelPipeline(
|
||||
graphData: ComfyWorkflowJSON,
|
||||
missingNodeTypes: MissingNodeType[],
|
||||
showMissingNodes: boolean,
|
||||
showMissingModels: boolean
|
||||
silent: boolean = false
|
||||
): Promise<{ missingModels: ModelFile[] }> {
|
||||
const missingModelStore = useMissingModelStore()
|
||||
|
||||
@@ -1491,33 +1514,36 @@ export class ComfyApp {
|
||||
|
||||
const activeWf = useWorkspaceStore().workflow.activeWorkflow
|
||||
if (activeWf) {
|
||||
const warnings: PendingWarnings = {}
|
||||
if (missingNodeTypes.length && showMissingNodes) {
|
||||
warnings.missingNodeTypes = missingNodeTypes
|
||||
}
|
||||
if (confirmedCandidates.length && showMissingModels) {
|
||||
warnings.missingModelCandidates = confirmedCandidates
|
||||
}
|
||||
if (warnings.missingNodeTypes || warnings.missingModelCandidates) {
|
||||
activeWf.pendingWarnings = warnings
|
||||
activeWf.pendingWarnings = {
|
||||
...activeWf.pendingWarnings,
|
||||
missingNodeTypes: missingNodeTypes.length
|
||||
? missingNodeTypes
|
||||
: undefined,
|
||||
missingModelCandidates: confirmedCandidates.length
|
||||
? confirmedCandidates
|
||||
: undefined
|
||||
}
|
||||
this.cleanupPendingWarnings(activeWf)
|
||||
}
|
||||
|
||||
// Intentionally runs on every graph load (including tab switches and
|
||||
// undo/redo) because missing model state depends on external asset data
|
||||
// that may change between workflow activations.
|
||||
if (enrichedCandidates.length) {
|
||||
if (isCloud) {
|
||||
const controller = missingModelStore.createVerificationAbortController()
|
||||
verifyAssetSupportedCandidates(enrichedCandidates, controller.signal)
|
||||
void verifyAssetSupportedCandidates(
|
||||
enrichedCandidates,
|
||||
controller.signal
|
||||
)
|
||||
.then(() => {
|
||||
if (controller.signal.aborted) return
|
||||
const confirmed = enrichedCandidates.filter(
|
||||
(c) => c.isMissing === true
|
||||
)
|
||||
if (confirmed.length) {
|
||||
useExecutionErrorStore().surfaceMissingModels(confirmed)
|
||||
useExecutionErrorStore().surfaceMissingModels(confirmed, {
|
||||
silent
|
||||
})
|
||||
}
|
||||
this.cacheModelCandidates(activeWf, confirmed)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn(
|
||||
@@ -1537,7 +1563,7 @@ export class ComfyApp {
|
||||
const controller = missingModelStore.createVerificationAbortController()
|
||||
const confirmed = enrichedCandidates.filter((c) => c.isMissing === true)
|
||||
if (confirmed.length) {
|
||||
api
|
||||
void api
|
||||
.getFolderPaths()
|
||||
.then((paths) => {
|
||||
if (controller.signal.aborted) return
|
||||
@@ -1551,7 +1577,10 @@ export class ComfyApp {
|
||||
})
|
||||
.finally(() => {
|
||||
if (controller.signal.aborted) return
|
||||
useExecutionErrorStore().surfaceMissingModels(confirmed)
|
||||
useExecutionErrorStore().surfaceMissingModels(confirmed, {
|
||||
silent
|
||||
})
|
||||
this.cacheModelCandidates(activeWf, confirmed)
|
||||
})
|
||||
|
||||
void Promise.allSettled(
|
||||
@@ -1573,11 +1602,53 @@ export class ComfyApp {
|
||||
return { missingModels }
|
||||
}
|
||||
|
||||
private async runMissingMediaPipeline(): Promise<void> {
|
||||
private cleanupPendingWarnings(wf: {
|
||||
pendingWarnings: PendingWarnings | null
|
||||
}) {
|
||||
if (
|
||||
!wf.pendingWarnings?.missingNodeTypes &&
|
||||
!wf.pendingWarnings?.missingModelCandidates &&
|
||||
!wf.pendingWarnings?.missingMediaCandidates
|
||||
) {
|
||||
wf.pendingWarnings = null
|
||||
}
|
||||
}
|
||||
|
||||
private cacheModelCandidates(
|
||||
wf: ComfyWorkflow | null,
|
||||
confirmed: MissingModelCandidate[]
|
||||
) {
|
||||
if (!wf) return
|
||||
wf.pendingWarnings = {
|
||||
...wf.pendingWarnings,
|
||||
missingModelCandidates: confirmed.length ? confirmed : undefined
|
||||
}
|
||||
this.cleanupPendingWarnings(wf)
|
||||
}
|
||||
|
||||
private cacheMediaCandidates(
|
||||
wf: ComfyWorkflow | null,
|
||||
confirmed: MissingMediaCandidate[]
|
||||
) {
|
||||
if (!wf) return
|
||||
wf.pendingWarnings = {
|
||||
...wf.pendingWarnings,
|
||||
missingMediaCandidates: confirmed.length ? confirmed : undefined
|
||||
}
|
||||
this.cleanupPendingWarnings(wf)
|
||||
}
|
||||
|
||||
private async runMissingMediaPipeline(
|
||||
silent: boolean = false
|
||||
): Promise<void> {
|
||||
const missingMediaStore = useMissingMediaStore()
|
||||
const activeWf = useWorkspaceStore().workflow.activeWorkflow
|
||||
const candidates = scanAllMediaCandidates(this.rootGraph, isCloud)
|
||||
|
||||
if (!candidates.length) return
|
||||
if (!candidates.length) {
|
||||
this.cacheMediaCandidates(activeWf, [])
|
||||
return
|
||||
}
|
||||
|
||||
if (isCloud) {
|
||||
const controller = missingMediaStore.createVerificationAbortController()
|
||||
@@ -1586,8 +1657,9 @@ export class ComfyApp {
|
||||
if (controller.signal.aborted) return
|
||||
const confirmed = candidates.filter((c) => c.isMissing === true)
|
||||
if (confirmed.length) {
|
||||
useExecutionErrorStore().surfaceMissingMedia(confirmed)
|
||||
useExecutionErrorStore().surfaceMissingMedia(confirmed, { silent })
|
||||
}
|
||||
this.cacheMediaCandidates(activeWf, confirmed)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn(
|
||||
@@ -1606,8 +1678,9 @@ export class ComfyApp {
|
||||
} else {
|
||||
const confirmed = candidates.filter((c) => c.isMissing === true)
|
||||
if (confirmed.length) {
|
||||
useExecutionErrorStore().surfaceMissingMedia(confirmed)
|
||||
useExecutionErrorStore().surfaceMissingMedia(confirmed, { silent })
|
||||
}
|
||||
this.cacheMediaCandidates(activeWf, confirmed)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -165,9 +165,8 @@ export class ChangeTracker {
|
||||
this._restoringState = true
|
||||
try {
|
||||
await app.loadGraphData(prevState, false, false, this.workflow, {
|
||||
showMissingModels: false,
|
||||
showMissingNodes: false,
|
||||
checkForRerouteMigration: false
|
||||
checkForRerouteMigration: false,
|
||||
silentAssetErrors: true
|
||||
})
|
||||
this.activeState = prevState
|
||||
this.updateModified()
|
||||
|
||||
@@ -351,6 +351,142 @@ describe('executionErrorStore — node error operations', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('surfaceMissingModels — silent option', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockShowErrorsTab.value = true
|
||||
})
|
||||
|
||||
it('opens error overlay when silent is not specified and setting is enabled', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.surfaceMissingModels([
|
||||
fromAny({
|
||||
name: 'model.safetensors',
|
||||
nodeId: '1',
|
||||
nodeType: 'Loader',
|
||||
widgetName: 'ckpt',
|
||||
isMissing: true,
|
||||
isAssetSupported: false
|
||||
})
|
||||
])
|
||||
|
||||
expect(store.isErrorOverlayOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('opens error overlay when silent is false and setting is enabled', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.surfaceMissingModels(
|
||||
[
|
||||
fromAny({
|
||||
name: 'model.safetensors',
|
||||
nodeId: '1',
|
||||
nodeType: 'Loader',
|
||||
widgetName: 'ckpt',
|
||||
isMissing: true,
|
||||
isAssetSupported: false
|
||||
})
|
||||
],
|
||||
{ silent: false }
|
||||
)
|
||||
|
||||
expect(store.isErrorOverlayOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('does NOT open error overlay when silent is true', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.surfaceMissingModels(
|
||||
[
|
||||
fromAny({
|
||||
name: 'model.safetensors',
|
||||
nodeId: '1',
|
||||
nodeType: 'Loader',
|
||||
widgetName: 'ckpt',
|
||||
isMissing: true,
|
||||
isAssetSupported: false
|
||||
})
|
||||
],
|
||||
{ silent: true }
|
||||
)
|
||||
|
||||
expect(store.isErrorOverlayOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('does NOT open error overlay for empty models even without silent', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.surfaceMissingModels([])
|
||||
|
||||
expect(store.isErrorOverlayOpen).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('surfaceMissingMedia — silent option', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockShowErrorsTab.value = true
|
||||
})
|
||||
|
||||
it('opens error overlay when silent is not specified and setting is enabled', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.surfaceMissingMedia([
|
||||
fromAny({
|
||||
name: 'photo.png',
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
isMissing: true
|
||||
})
|
||||
])
|
||||
|
||||
expect(store.isErrorOverlayOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('opens error overlay when silent is false and setting is enabled', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.surfaceMissingMedia(
|
||||
[
|
||||
fromAny({
|
||||
name: 'photo.png',
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
isMissing: true
|
||||
})
|
||||
],
|
||||
{ silent: false }
|
||||
)
|
||||
|
||||
expect(store.isErrorOverlayOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('does NOT open error overlay when silent is true', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.surfaceMissingMedia(
|
||||
[
|
||||
fromAny({
|
||||
name: 'photo.png',
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
isMissing: true
|
||||
})
|
||||
],
|
||||
{ silent: true }
|
||||
)
|
||||
|
||||
expect(store.isErrorOverlayOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('does NOT open error overlay for empty media even without silent', () => {
|
||||
const store = useExecutionErrorStore()
|
||||
store.surfaceMissingMedia([])
|
||||
|
||||
expect(store.isErrorOverlayOpen).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearAllErrors', () => {
|
||||
let executionErrorStore: ReturnType<typeof useExecutionErrorStore>
|
||||
let missingNodesStore: ReturnType<typeof useMissingNodesErrorStore>
|
||||
|
||||
@@ -163,10 +163,14 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
missingMediaStore.removeMissingMediaByWidget(executionId, widgetName)
|
||||
}
|
||||
|
||||
/** Set missing models and open the error overlay if the Errors tab is enabled. */
|
||||
function surfaceMissingModels(models: MissingModelCandidate[]) {
|
||||
/** Set missing models and optionally open the error overlay. */
|
||||
function surfaceMissingModels(
|
||||
models: MissingModelCandidate[],
|
||||
options?: { silent?: boolean }
|
||||
) {
|
||||
missingModelStore.setMissingModels(models)
|
||||
if (
|
||||
!options?.silent &&
|
||||
models.length &&
|
||||
useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')
|
||||
) {
|
||||
@@ -174,10 +178,14 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** Set missing media and open the error overlay if the Errors tab is enabled. */
|
||||
function surfaceMissingMedia(media: MissingMediaCandidate[]) {
|
||||
/** Set missing media and optionally open the error overlay. */
|
||||
function surfaceMissingMedia(
|
||||
media: MissingMediaCandidate[],
|
||||
options?: { silent?: boolean }
|
||||
) {
|
||||
missingMediaStore.setMissingMedia(media)
|
||||
if (
|
||||
!options?.silent &&
|
||||
media.length &&
|
||||
useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')
|
||||
) {
|
||||
|
||||
@@ -28,7 +28,8 @@ import {
|
||||
traverseSubgraphPath,
|
||||
triggerCallbackOnAllNodes,
|
||||
visitGraphNodes,
|
||||
getExecutionIdByNode
|
||||
getExecutionIdByNode,
|
||||
getExecutionIdForNodeInGraph
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
|
||||
import { createMockLGraphNode } from './__tests__/litegraphTestUtils'
|
||||
@@ -642,6 +643,86 @@ describe('graphTraversalUtil', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getExecutionIdForNodeInGraph', () => {
|
||||
it('returns local id when graph is rootGraph', () => {
|
||||
const node = createMockNode('42')
|
||||
const rootGraph = createMockGraph([node])
|
||||
expect(getExecutionIdForNodeInGraph(rootGraph, rootGraph, '42')).toBe(
|
||||
'42'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns local id when graph.isRootGraph is true', () => {
|
||||
const node = createMockNode('42')
|
||||
const rootGraph = createMockGraph([node])
|
||||
const otherRoot = createMockGraph([])
|
||||
expect(getExecutionIdForNodeInGraph(otherRoot, rootGraph, '42')).toBe(
|
||||
'42'
|
||||
)
|
||||
})
|
||||
|
||||
it('builds parentPath:nodeId for a single-level subgraph', () => {
|
||||
const interior = createMockNode('63')
|
||||
const subgraph = createMockSubgraph('sub-uuid', [interior])
|
||||
const subgraphNode = createMockNode('65', {
|
||||
isSubgraph: true,
|
||||
subgraph
|
||||
})
|
||||
const rootGraph = createMockGraph([subgraphNode])
|
||||
|
||||
expect(getExecutionIdForNodeInGraph(rootGraph, subgraph, '63')).toBe(
|
||||
'65:63'
|
||||
)
|
||||
})
|
||||
|
||||
it('builds nested parentPath:nodeId for deeply-nested subgraph', () => {
|
||||
const interior = createMockNode('999')
|
||||
const deep = createMockSubgraph('deep', [interior])
|
||||
const midNode = createMockNode('456', {
|
||||
isSubgraph: true,
|
||||
subgraph: deep
|
||||
})
|
||||
const mid = createMockSubgraph('mid', [midNode])
|
||||
const topNode = createMockNode('123', {
|
||||
isSubgraph: true,
|
||||
subgraph: mid
|
||||
})
|
||||
const rootGraph = createMockGraph([topNode])
|
||||
|
||||
expect(getExecutionIdForNodeInGraph(rootGraph, deep, '999')).toBe(
|
||||
'123:456:999'
|
||||
)
|
||||
})
|
||||
|
||||
it('works when node is detached (node.graph = null)', () => {
|
||||
// This is the primary use case — onNodeRemoved fires after
|
||||
// LiteGraph nulls node.graph, but the hook closure still has
|
||||
// the local graph instance, which is enough.
|
||||
const interior = createMockNode('63')
|
||||
const subgraph = createMockSubgraph('sub-uuid', [interior])
|
||||
const subgraphNode = createMockNode('65', {
|
||||
isSubgraph: true,
|
||||
subgraph
|
||||
})
|
||||
const rootGraph = createMockGraph([subgraphNode])
|
||||
interior.graph = null as unknown as LGraph
|
||||
|
||||
expect(
|
||||
getExecutionIdForNodeInGraph(rootGraph, subgraph, interior.id)
|
||||
).toBe('65:63')
|
||||
})
|
||||
|
||||
it('falls back to local id when graph is not reachable from root', () => {
|
||||
const interior = createMockNode('63')
|
||||
const orphanSubgraph = createMockSubgraph('orphan', [interior])
|
||||
const rootGraph = createMockGraph([])
|
||||
|
||||
expect(
|
||||
getExecutionIdForNodeInGraph(rootGraph, orphanSubgraph, '63')
|
||||
).toBe('63')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getExecutionIdFromNodeData', () => {
|
||||
it('should return the correct execution ID for a normal node', () => {
|
||||
const node = createMockNode('123')
|
||||
|
||||
@@ -362,6 +362,27 @@ export function getExecutionIdByNode(
|
||||
return `${parentPath}:${node.id}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the execution ID for a node identified by its (graph, nodeId) pair.
|
||||
*
|
||||
* Unlike {@link getExecutionIdByNode}, this does not rely on `node.graph`.
|
||||
* Use this when the node reference may be detached (e.g. inside
|
||||
* `onNodeRemoved`, which LiteGraph fires after clearing `node.graph`).
|
||||
*
|
||||
* @param rootGraph - The root graph to resolve from
|
||||
* @param graph - The graph the node currently lives in (or lived in)
|
||||
* @param nodeId - The local node ID within `graph`
|
||||
*/
|
||||
export function getExecutionIdForNodeInGraph(
|
||||
rootGraph: LGraph,
|
||||
graph: LGraph | Subgraph,
|
||||
nodeId: string | number
|
||||
): string {
|
||||
if (graph === rootGraph || graph.isRootGraph) return String(nodeId)
|
||||
const parentPath = findPartialExecutionPathToGraph(graph as LGraph, rootGraph)
|
||||
return parentPath !== undefined ? `${parentPath}:${nodeId}` : String(nodeId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the execution ID for a node described by plain data (id + subgraphId),
|
||||
* without requiring a pre-existing {@link LGraphNode} reference.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user