Compare commits

...

11 Commits

Author SHA1 Message Date
jaeone94
4ce4bfc081 test: assert promoted missing model badge visibility 2026-06-25 03:34:25 +09:00
jaeone94
9066206c00 refactor: trim promoted missing model indirection 2026-06-25 03:32:53 +09:00
jaeone94
b731807184 fix: isolate shared promoted missing model hosts 2026-06-25 02:47:04 +09:00
jaeone94
49d5bea87f test: click promoted host asset widget in e2e 2026-06-25 02:42:06 +09:00
jaeone94
381550c14f test: reduce promoted missing model e2e matrix 2026-06-25 02:42:06 +09:00
jaeone94
4f3be82d8e fix: scope promoted missing models by source 2026-06-25 02:42:06 +09:00
jaeone94
65d4e0dd6e fix: simplify promoted missing model clearing 2026-06-25 02:42:06 +09:00
jaeone94
1b44c0a5f7 test: align promoted model test helpers 2026-06-25 02:41:41 +09:00
jaeone94
51a547e657 test: cover promoted missing model flows 2026-06-25 02:41:41 +09:00
jaeone94
7515e907dc fix: scan promoted model widgets by host 2026-06-25 02:41:41 +09:00
jaeone94
6b30bf801d test: cover promoted subgraph missing model scan 2026-06-25 02:37:14 +09:00
27 changed files with 2459 additions and 322 deletions

View File

@@ -0,0 +1,197 @@
{
"id": "test-missing-model-nested-promoted-widget",
"revision": 0,
"last_node_id": 4,
"last_link_id": 0,
"nodes": [
{
"id": 3,
"type": "outer-subgraph-with-promoted-missing-model",
"pos": [10, 250],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"title": "Resolved Shared Outer Subgraph",
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": ["resolved_model.safetensors"]
},
{
"id": 4,
"type": "outer-subgraph-with-promoted-missing-model",
"pos": [450, 250],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"title": "Outer Subgraph with Promoted Missing Model",
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": ["fake_model.safetensors"]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "outer-subgraph-with-promoted-missing-model",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 2,
"lastLinkId": 2,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Outer Subgraph with Promoted Missing Model",
"inputNode": {
"id": -10,
"bounding": [100, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [600, 200, 120, 60]
},
"inputs": [
{
"id": "outer-ckpt-name-input-id",
"name": "ckpt_name",
"type": "COMBO",
"linkIds": [2],
"pos": { "0": 150, "1": 220 }
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 2,
"type": "inner-subgraph-with-promoted-missing-model",
"pos": [250, 180],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "ckpt_name",
"name": "ckpt_name",
"type": "COMBO",
"widget": {
"name": "ckpt_name"
},
"link": 2
}
],
"outputs": [],
"properties": {},
"widgets_values": ["fake_model.safetensors"]
}
],
"links": [
{
"id": 2,
"origin_id": -10,
"origin_slot": 0,
"target_id": 2,
"target_slot": 0,
"type": "COMBO"
}
]
},
{
"id": "inner-subgraph-with-promoted-missing-model",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 1,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Inner Subgraph with Promoted Missing Model",
"inputNode": {
"id": -10,
"bounding": [100, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [500, 200, 120, 60]
},
"inputs": [
{
"id": "inner-ckpt-name-input-id",
"name": "ckpt_name",
"type": "COMBO",
"linkIds": [1],
"pos": { "0": 150, "1": 220 }
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [250, 180],
"size": [315, 98],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "ckpt_name",
"name": "ckpt_name",
"type": "COMBO",
"widget": {
"name": "ckpt_name"
},
"link": 1
}
],
"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": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "COMBO"
}
]
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"models": [
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
}
],
"version": 0.4
}

View File

@@ -0,0 +1,136 @@
import type { Page, Route } from '@playwright/test'
import {
getComboSpecComboOptions,
isComboInputSpec,
isComboInputSpecV1
} from '@/schemas/nodeDefSchema'
import type {
ComboInputSpec,
ComboInputSpecV2,
ComfyNodeDef,
InputSpec
} from '@/schemas/nodeDefSchema'
export type ObjectInfoResponse = Record<string, ComfyNodeDef>
type ComboInput = ComboInputSpec | ComboInputSpecV2
const OBJECT_INFO_ROUTE = '**/object_info'
function getRequiredInput(
objectInfo: ObjectInfoResponse,
nodeType: string,
inputName: string
): InputSpec {
const nodeInfo = objectInfo[nodeType]
if (!nodeInfo) {
throw new Error(`Missing object_info entry for ${nodeType}`)
}
const requiredInputs = nodeInfo.input?.required
if (!requiredInputs) {
throw new Error(`Missing required inputs for ${nodeType}`)
}
const input = requiredInputs[inputName]
if (!input) {
throw new Error(`Missing input ${nodeType}.${inputName}`)
}
return input
}
function getComboInput(
objectInfo: ObjectInfoResponse,
nodeType: string,
inputName: string
): ComboInput {
const input = getRequiredInput(objectInfo, nodeType, inputName)
if (isComboInputSpec(input)) {
return input
}
throw new Error(`Expected ${nodeType}.${inputName} to be a combo input`)
}
export function setComboInputOptions(
objectInfo: ObjectInfoResponse,
nodeType: string,
inputName: string,
values: ReadonlyArray<string | number>
): void {
const input = getComboInput(objectInfo, nodeType, inputName)
const nextValues = [...values]
if (isComboInputSpecV1(input)) {
input[0] = nextValues
return
}
input[1] = { ...input[1], options: nextValues }
}
export function appendComboInputOptions(
objectInfo: ObjectInfoResponse,
nodeType: string,
inputName: string,
values: ReadonlyArray<string | number>
): void {
const input = getComboInput(objectInfo, nodeType, inputName)
setComboInputOptions(objectInfo, nodeType, inputName, [
...getComboSpecComboOptions(input),
...values
])
}
export async function routeObjectInfoFromSetupApi(
page: Page,
customize?: (objectInfo: ObjectInfoResponse) => void | Promise<void>
): Promise<() => Promise<void>> {
const setupApiUrl =
process.env.PLAYWRIGHT_SETUP_API_URL ?? 'http://127.0.0.1:8188'
const objectInfoUrl = new URL('/object_info', setupApiUrl).toString()
const objectInfoRouteHandler = async (route: Route) => {
let objectInfo: ObjectInfoResponse
try {
const response = await fetch(objectInfoUrl, {
signal: AbortSignal.timeout(5_000)
})
if (!response.ok) {
await route.fulfill({
status: response.status,
contentType: response.headers.get('content-type') ?? 'text/plain',
body: await response.text()
})
return
}
objectInfo = (await response.json()) as ObjectInfoResponse
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
await route.fulfill({
status: 502,
contentType: 'application/json',
body: JSON.stringify({
error: `Failed to fetch setup object_info from ${objectInfoUrl}: ${message}`
})
})
return
}
await customize?.(objectInfo)
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(objectInfo)
})
}
await page.route(OBJECT_INFO_ROUTE, objectInfoRouteHandler)
return async () => {
if (page.isClosed()) return
await page.unroute(OBJECT_INFO_ROUTE, objectInfoRouteHandler)
}
}

View File

@@ -0,0 +1,424 @@
import { readFileSync } from 'fs'
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import { assetPath } from '@e2e/fixtures/utils/paths'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
const PROMOTED_MODEL_WIDGET_NAME = 'ckpt_name'
export interface PromotedMissingModelWorkflow {
workflowName: string
hostNodeId: number
hostNodeTitle: string
sharedDefinitionSiblingHostNodeId?: number
sharedDefinitionSiblingHostNodeTitle?: string
}
type RootWorkflowNode = {
id: number | string
widgets_values?: unknown[] | Record<string, unknown>
}
type RootWorkflowData = ComfyWorkflowJSON & {
nodes?: RootWorkflowNode[]
}
export const NESTED_PROMOTED_MISSING_MODEL_WORKFLOW: PromotedMissingModelWorkflow =
{
workflowName: 'missing/missing_model_nested_promoted_widget',
hostNodeId: 4,
hostNodeTitle: 'Outer Subgraph with Promoted Missing Model',
sharedDefinitionSiblingHostNodeId: 3,
sharedDefinitionSiblingHostNodeTitle: 'Resolved Shared Outer Subgraph'
}
export function getMissingModelLabel(group: Locator, modelName: string) {
return group.getByRole('button', { name: modelName, exact: true })
}
export async function expectSingleMissingModelReference(
group: Locator,
modelName: string
) {
await expectMissingModelReferenceCount(group, modelName, 1)
}
export async function expectMissingModelReferenceCount(
group: Locator,
modelName: string,
count: number
) {
await expect(getMissingModelLabel(group, modelName)).toHaveCount(1)
const badge = group.getByTestId(TestIds.dialogs.missingModelReferenceCount)
if (count === 1) {
await expect(badge).toBeHidden()
return
}
await expect(badge).toBeVisible()
await expect(badge).toHaveText(String(count))
}
export async function loadPromotedMissingModelAndOpenErrorsTab(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow,
modelName: string
): Promise<Locator> {
await loadWorkflowAndOpenErrorsTab(comfyPage, workflow.workflowName)
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expectSingleMissingModelReference(missingModelGroup, modelName)
return missingModelGroup
}
export async function loadPromotedMissingModelWithHostValuesAndOpenErrorsTab(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow,
hostValues: Record<number, string>,
modelName: string,
expectedReferenceCount: number
): Promise<Locator> {
await loadPromotedMissingModelWithHostValues(comfyPage, workflow, hostValues)
const errorOverlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
await expect(errorOverlay).toBeVisible()
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
await expect(errorOverlay).toBeHidden()
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expectMissingModelReferenceCount(
missingModelGroup,
modelName,
expectedReferenceCount
)
return missingModelGroup
}
export async function expectNoMissingModelUi(comfyPage: ComfyPage) {
const panel = new PropertiesPanelHelper(comfyPage.page)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
).toBeHidden()
await panel.open(comfyPage.actionbar.propertiesButton)
await expect(
panel.root.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeHidden()
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
).toBeHidden()
}
export async function selectVueComboPromotedModelByTitle(
comfyPage: ComfyPage,
nodeTitle: string,
modelName: string
) {
await comfyPage.vueNodes.selectComboOption(
nodeTitle,
PROMOTED_MODEL_WIDGET_NAME,
modelName
)
}
export async function selectVueAssetPromotedModel(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow,
currentModelName: string,
modelName: string
) {
await selectModelFromFormDropdown(
comfyPage,
comfyPage.vueNodes.getNodeByTitle(workflow.hostNodeTitle),
currentModelName,
modelName
)
}
export async function selectSectionComboPromotedModel(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow,
modelName: string
) {
const panel = await openHostNodeParametersPanel(comfyPage, workflow)
const combo = panel.contentArea.getByRole('combobox', {
name: PROMOTED_MODEL_WIDGET_NAME,
exact: true
})
await combo.click()
await comfyPage.page
.getByRole('option', { name: modelName, exact: true })
.click()
}
export async function selectSectionAssetPromotedModel(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow,
currentModelName: string,
modelName: string
) {
const panel = await openHostNodeParametersPanel(comfyPage, workflow)
await selectModelFromFormDropdown(
comfyPage,
panel.contentArea,
currentModelName,
modelName
)
}
export async function setLegacyPromotedComboModel(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow,
modelName: string
) {
await comfyPage.page.evaluate(
({ hostNodeId, widgetName, value }) => {
type LegacyPromotedWidget = {
name?: string
value?: unknown
callback?: (value: string) => void
setValue?: (
value: string,
options: {
e: PointerEvent
node: unknown
canvas: unknown
}
) => void
}
type LegacyPromotedNode = {
onWidgetChanged?: (
name: string,
newValue: string,
oldValue: unknown,
widget: LegacyPromotedWidget
) => void
widgets?: LegacyPromotedWidget[]
}
type LegacyPromotedGraph = {
getNodeById: (nodeId: number) => LegacyPromotedNode | undefined
}
const currentGraph = window.app?.graph as LegacyPromotedGraph | undefined
const hostNode: LegacyPromotedNode | undefined =
currentGraph?.getNodeById(hostNodeId)
if (!hostNode) {
throw new Error(`Expected subgraph host node ${hostNodeId}`)
}
const widget = hostNode.widgets?.find(
(entry) => entry.name === widgetName
) as LegacyPromotedWidget | undefined
if (!widget) {
throw new Error(`Expected host ${widgetName} widget`)
}
const oldValue = widget.value
if (widget.setValue) {
widget.setValue(value, {
e: new PointerEvent('pointerup'),
node: hostNode,
canvas: window.app!.canvas
})
return
}
widget.value = value
widget.callback?.(value)
hostNode.onWidgetChanged?.(
widget.name ?? widgetName,
value,
oldValue,
widget
)
},
{
hostNodeId: workflow.hostNodeId,
widgetName: PROMOTED_MODEL_WIDGET_NAME,
value: modelName
}
)
}
export async function selectLegacyPromotedAssetModel(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow,
assetId: string
) {
await clickLegacyHostPromotedWidget(comfyPage, workflow)
const modal = comfyPage.page.locator(
'[data-component-id="AssetBrowserModal"]'
)
await expect(modal).toBeVisible()
const assetCard = modal.locator(`[data-asset-id="${assetId}"]`)
await expect(assetCard).toBeVisible()
await assetCard.getByRole('button', { name: 'Use', exact: true }).click()
await expect(modal).toBeHidden()
}
export async function expectResolvedPromotedModelSuppressesStaleInteriorErrors(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow,
expectedStaleInteriorWidgets: Array<{
subgraphNodeIdToEnter: string
nodeTitle: string
}>,
resolvedModelName: string,
staleModelName: string
) {
await loadPromotedMissingModelWithHostValues(comfyPage, workflow, {
[workflow.hostNodeId]: resolvedModelName
})
const promotedModelCombo = comfyPage.vueNodes
.getNodeByTitle(workflow.hostNodeTitle)
.getByRole('combobox', { name: PROMOTED_MODEL_WIDGET_NAME, exact: true })
await expect(promotedModelCombo).toContainText(resolvedModelName)
await expectNoMissingModelUi(comfyPage)
for (const step of expectedStaleInteriorWidgets) {
await enterSubgraphForStaleInteriorCheck(
comfyPage,
step.subgraphNodeIdToEnter
)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.nextFrame()
const node = comfyPage.vueNodes.getNodeByTitle(step.nodeTitle)
await expect(node).toBeVisible()
const staleCombo = node.getByRole('combobox', {
name: PROMOTED_MODEL_WIDGET_NAME,
exact: true
})
await expect(
staleCombo,
`${step.nodeTitle} should expose the stale linked interior widget`
).toBeDisabled()
await expect(
staleCombo,
`${step.nodeTitle} should keep the stale interior value`
).toContainText(staleModelName)
await expectNoMissingModelUi(comfyPage)
}
}
async function openHostNodeParametersPanel(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow
): Promise<PropertiesPanelHelper> {
await comfyPage.vueNodes.selectNode(String(workflow.hostNodeId))
const panel = new PropertiesPanelHelper(comfyPage.page)
await panel.open(comfyPage.actionbar.propertiesButton)
await expect(panel.getTab('Parameters')).toBeVisible()
await panel.switchToTab('Parameters')
return panel
}
async function loadPromotedMissingModelWithHostValues(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow,
hostValues: Record<number, string>
) {
const graphData = readPromotedMissingModelWorkflow(workflow.workflowName)
for (const [hostNodeId, value] of Object.entries(hostValues)) {
setRootHostWidgetValue(graphData, Number(hostNodeId), value)
}
await comfyPage.workflow.loadGraphData(graphData)
await comfyPage.vueNodes.waitForNodes()
}
function readPromotedMissingModelWorkflow(workflowName: string) {
return JSON.parse(
readFileSync(assetPath(`${workflowName}.json`), 'utf-8')
) as RootWorkflowData
}
function setRootHostWidgetValue(
graphData: RootWorkflowData,
hostNodeId: number,
value: string
) {
const hostNode = graphData.nodes?.find(
(node) => Number(node.id) === hostNodeId
)
if (!hostNode) throw new Error(`Expected host node ${hostNodeId}`)
if (Array.isArray(hostNode.widgets_values)) {
hostNode.widgets_values[0] = value
return
}
hostNode.widgets_values = {
...(hostNode.widgets_values ?? {}),
[PROMOTED_MODEL_WIDGET_NAME]: value
}
}
async function selectModelFromFormDropdown(
comfyPage: ComfyPage,
root: Locator,
currentModelName: string,
nextModelName: string
) {
const trigger = root
.getByRole('button', { name: currentModelName, exact: true })
.first()
await expect(trigger).toBeVisible()
await trigger.click()
const menu = comfyPage.page.getByTestId('form-dropdown-menu')
await expect(menu).toBeVisible()
await menu.getByText(nextModelName, { exact: true }).click()
await expect(menu).toBeHidden()
}
async function clickLegacyHostPromotedWidget(
comfyPage: ComfyPage,
workflow: PromotedMissingModelWorkflow
) {
const hostNode = await comfyPage.nodeOps.getNodeRefById(workflow.hostNodeId)
await hostNode.centerOnNode()
const widget = await hostNode.getWidgetByName(PROMOTED_MODEL_WIDGET_NAME)
await widget.click()
}
async function enterSubgraphForStaleInteriorCheck(
comfyPage: ComfyPage,
nodeId: string
) {
const numericNodeId = Number(nodeId)
if (Number.isNaN(numericNodeId)) {
throw new Error(`Expected numeric subgraph node id, got ${nodeId}`)
}
const normalizedNodeId = String(numericNodeId)
const enterButton =
comfyPage.vueNodes.getSubgraphEnterButton(normalizedNodeId)
if ((await enterButton.count()) > 0) {
await comfyPage.vueNodes.enterSubgraph(normalizedNodeId)
return
}
await comfyPage.page.evaluate((targetNodeId) => {
const graph = window.app?.canvas.graph
const node = graph?.getNodeById(targetNodeId)
if (!node?.isSubgraphNode()) {
throw new Error(`Expected visible subgraph node ${targetNodeId}`)
}
window.app!.canvas.setGraph(node.subgraph)
}, numericNodeId)
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
}

View File

@@ -10,7 +10,18 @@ import {
countAssetRequestsByTag,
createCloudAssetsFixture
} from '@e2e/fixtures/assetApiFixture'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import {
cleanupFakeModel,
loadWorkflowAndOpenErrorsTab
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
import {
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
expectNoMissingModelUi,
loadPromotedMissingModelAndOpenErrorsTab,
selectLegacyPromotedAssetModel,
selectSectionAssetPromotedModel,
selectVueAssetPromotedModel
} from '@e2e/fixtures/utils/promotedMissingModel'
import { TestIds } from '@e2e/fixtures/selectors'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
@@ -20,6 +31,8 @@ const WORKFLOW = 'missing/nested_subgraph_installed_model'
const IMPORT_SECTIONS_WORKFLOW = 'missing/cloud_missing_model_import_sections'
const OUTER_SUBGRAPH_NODE_ID = '205'
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
const FAKE_MODEL_NAME = 'fake_model.safetensors'
const RESOLVED_PROMOTED_MODEL_NAME = 'resolved_model.safetensors'
const CLOUD_IMPORTABLE_MODEL_NAME = 'cloud_importable_model.safetensors'
const CLOUD_UNKNOWN_MODEL_NAME = 'cloud_unknown_model.safetensors'
const CLOUD_IMPORTED_CANONICAL_MODEL_NAME =
@@ -55,7 +68,25 @@ const EXISTING_CLOUD_IMPORTABLE_MODEL: Asset & { hash?: string } = {
}
}
const RESOLVED_PROMOTED_MODEL_ASSET: Asset & { hash?: string } = {
id: 'test-resolved-promoted-model',
name: RESOLVED_PROMOTED_MODEL_NAME,
hash: 'blake3:0000000000000000000000000000000000000000000000000000000000000205',
size: 1_024,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
created_at: '2026-05-05T00:00:00Z',
updated_at: '2026-05-05T00:00:00Z',
last_access_time: '2026-05-05T00:00:00Z',
user_metadata: {
filename: RESOLVED_PROMOTED_MODEL_NAME
}
}
const test = createCloudAssetsFixture([LOTUS_DIFFUSION_MODEL])
const promotedModelTest = createCloudAssetsFixture([
RESOLVED_PROMOTED_MODEL_ASSET
])
function getRequestedIncludeTags(requestUrl: string): string[] {
return (
@@ -363,3 +394,84 @@ test.describe(
})
}
)
promotedModelTest.describe(
'Errors tab - Cloud promoted subgraph missing models',
{ tag: '@cloud' },
() => {
promotedModelTest.beforeEach(async ({ comfyPage }) => {
await cleanupFakeModel(comfyPage)
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
})
promotedModelTest.afterEach(async ({ comfyPage }) => {
await cleanupFakeModel(comfyPage)
})
promotedModelTest(
'Changing a Cloud Vue promoted asset widget clears a nested subgraph error',
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
async ({ comfyPage }) => {
await loadPromotedMissingModelAndOpenErrorsTab(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
FAKE_MODEL_NAME
)
await selectVueAssetPromotedModel(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
FAKE_MODEL_NAME,
RESOLVED_PROMOTED_MODEL_NAME
)
await expectNoMissingModelUi(comfyPage)
}
)
promotedModelTest(
'Changing a Cloud Vue promoted asset from the Parameters tab clears a nested subgraph error',
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
async ({ comfyPage }) => {
await loadPromotedMissingModelAndOpenErrorsTab(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
FAKE_MODEL_NAME
)
await selectSectionAssetPromotedModel(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
FAKE_MODEL_NAME,
RESOLVED_PROMOTED_MODEL_NAME
)
await expectNoMissingModelUi(comfyPage)
}
)
promotedModelTest(
'Changing a Cloud legacy promoted asset clears a nested subgraph error',
{ tag: ['@canvas', '@widget', '@subgraph'] },
async ({ comfyPage }) => {
await loadPromotedMissingModelAndOpenErrorsTab(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
FAKE_MODEL_NAME
)
await selectLegacyPromotedAssetModel(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
RESOLVED_PROMOTED_MODEL_ASSET.id
)
await expectNoMissingModelUi(comfyPage)
}
)
}
)

View File

@@ -8,6 +8,10 @@ import {
} from '@e2e/fixtures/assetApiFixture'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
routeObjectInfoFromSetupApi,
setComboInputOptions
} from '@e2e/fixtures/utils/objectInfo'
import {
createRouteMockJob,
jobsRouteFixture
@@ -86,50 +90,6 @@ interface CloudUploadAssetState {
isUploadedAssetAvailable: boolean
}
type ObjectInfoResponse = Record<
string,
{ input?: { required?: Record<string, unknown> } }
>
function setComboInputOptions(
objectInfo: ObjectInfoResponse,
nodeType: string,
inputName: string,
values: string[]
) {
const nodeInfo = objectInfo[nodeType]
if (!nodeInfo) {
throw new Error(`Missing object_info entry for ${nodeType}`)
}
const requiredInputs = nodeInfo.input?.required
if (!requiredInputs) {
throw new Error(`Missing required inputs for ${nodeType}`)
}
const input = requiredInputs[inputName]
if (!Array.isArray(input)) {
throw new Error(`Expected ${nodeType}.${inputName} to be a combo input`)
}
const [valuesOrType, options] = input
const optionsObject =
options && typeof options === 'object' && !Array.isArray(options)
if (Array.isArray(valuesOrType)) {
input[0] = values
} else if (valuesOrType !== 'COMBO') {
throw new Error(`Expected ${nodeType}.${inputName} to have combo options`)
}
if (optionsObject) {
Object.assign(options, { options: values })
} else if (!Array.isArray(valuesOrType)) {
throw new Error(
`Expected ${nodeType}.${inputName} to have options metadata`
)
}
}
async function routeCloudBootstrapApis(page: Page) {
await page.route('**/api/settings**', async (route) => {
await route.fulfill({
@@ -161,57 +121,10 @@ async function routeCloudBootstrapApis(page: Page) {
})
}
async function routeSetupObjectInfo(
page: Page,
customize?: (objectInfo: ObjectInfoResponse) => void
) {
const setupApiUrl =
process.env.PLAYWRIGHT_SETUP_API_URL ?? 'http://127.0.0.1:8188'
const objectInfoUrl = new URL('/object_info', setupApiUrl).toString()
const objectInfoRouteHandler = async (route: Route) => {
try {
const response = await fetch(objectInfoUrl, {
signal: AbortSignal.timeout(5_000)
})
if (!response.ok) {
await route.fulfill({
status: response.status,
contentType: response.headers.get('content-type') ?? 'text/plain',
body: await response.text()
})
return
}
const objectInfo = (await response.json()) as ObjectInfoResponse
customize?.(objectInfo)
await route.fulfill({
status: response.status,
contentType: 'application/json',
body: JSON.stringify(objectInfo)
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
await route.fulfill({
status: 502,
contentType: 'application/json',
body: JSON.stringify({
error: `Failed to fetch setup object_info from ${objectInfoUrl}: ${message}`
})
})
}
}
await page.route('**/object_info', objectInfoRouteHandler)
return async () =>
await page.unroute('**/object_info', objectInfoRouteHandler)
}
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset]).extend({
page: async ({ page }, use) => {
await routeCloudBootstrapApis(page)
const unrouteObjectInfo = await routeSetupObjectInfo(page)
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(page)
try {
await use(page)
@@ -225,13 +138,16 @@ const cloudEmptyMediaInputsTest = createCloudAssetsFixture([]).extend({
page: async ({ page }, use) => {
await routeCloudBootstrapApis(page)
const unrouteObjectInfo = await routeSetupObjectInfo(page, (objectInfo) => {
for (const node of emptyMediaLoaderNodes) {
setComboInputOptions(objectInfo, node.nodeType, node.widgetName, [
node.serverOnlyOption
])
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(
page,
(objectInfo) => {
for (const node of emptyMediaLoaderNodes) {
setComboInputOptions(objectInfo, node.nodeType, node.widgetName, [
node.serverOnlyOption
])
}
}
})
)
try {
await use(page)
@@ -246,7 +162,7 @@ const cloudUploadRaceTest = comfyPageFixture.extend<{
}>({
page: async ({ page }, use) => {
await routeCloudBootstrapApis(page)
const unrouteObjectInfo = await routeSetupObjectInfo(page)
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(page)
const state: CloudUploadAssetState = {
isUploadedAssetAvailable: false

View File

@@ -8,12 +8,46 @@ import {
openErrorsTab,
loadWorkflowAndOpenErrorsTab
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
import {
appendComboInputOptions,
routeObjectInfoFromSetupApi
} from '@e2e/fixtures/utils/objectInfo'
import {
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
expectMissingModelReferenceCount,
expectNoMissingModelUi,
expectResolvedPromotedModelSuppressesStaleInteriorErrors,
expectSingleMissingModelReference,
getMissingModelLabel,
loadPromotedMissingModelAndOpenErrorsTab,
loadPromotedMissingModelWithHostValuesAndOpenErrorsTab,
selectSectionComboPromotedModel,
selectVueComboPromotedModelByTitle,
setLegacyPromotedComboModel
} from '@e2e/fixtures/utils/promotedMissingModel'
const FAKE_MODEL_NAME = 'fake_model.safetensors'
const RESOLVED_PROMOTED_MODEL_NAME = 'resolved_model.safetensors'
function getModelLabel(group: Locator, modelName: string = FAKE_MODEL_NAME) {
return group.getByRole('button', { name: modelName, exact: true })
}
const promotedModelTest = test.extend({
page: async ({ page }, use) => {
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(
page,
(objectInfo) =>
appendComboInputOptions(
objectInfo,
'CheckpointLoaderSimple',
'ckpt_name',
[RESOLVED_PROMOTED_MODEL_NAME]
)
)
try {
await use(page)
} finally {
await unrouteObjectInfo()
}
}
})
async function expectReferenceBadge(group: Locator, count: number) {
await expect(
@@ -169,7 +203,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toBeVisible()
await expect(getModelLabel(missingModelGroup)).toBeVisible()
await expect(
getMissingModelLabel(missingModelGroup, FAKE_MODEL_NAME)
).toBeVisible()
const node = await comfyPage.nodeOps.getNodeRefById('1')
await node.click('title')
@@ -265,7 +301,9 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
const node1 = await comfyPage.nodeOps.getNodeRefById('1')
await node1.click('title')
await expect(getModelLabel(missingModelGroup)).toBeVisible()
await expect(
getMissingModelLabel(missingModelGroup, FAKE_MODEL_NAME)
).toBeVisible()
await expect(
missingModelGroup.getByTestId(TestIds.dialogs.missingModelLocate)
).toHaveCount(1)
@@ -381,92 +419,184 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
await cleanupFakeModel(comfyPage)
})
test(
'Resolving a promoted missing model widget through the legacy canvas path clears its error',
{ tag: ['@canvas', '@widget', '@subgraph'] },
promotedModelTest(
'Changing an OSS Vue promoted model clears a nested subgraph error',
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
async ({ comfyPage }) => {
const resolvedModelName = 'v1-5-pruned-emaonly-fp16.safetensors'
let missingModelGroup: Locator
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_model_promoted_widget'
)
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(getModelLabel(missingModelGroup)).toBeVisible()
await comfyPage.page.evaluate((value) => {
const hostNode = window.app!.graph!.getNodeById(2)
if (!hostNode?.isSubgraphNode()) {
throw new Error('Expected subgraph host node')
}
const interiorNode = hostNode.subgraph.getNodeById(1)
const widget = interiorNode?.widgets?.find(
(entry) => entry.name === 'ckpt_name'
await test.step('A: shared-definition active host reports the missing model', async () => {
missingModelGroup = await loadPromotedMissingModelAndOpenErrorsTab(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
FAKE_MODEL_NAME
)
type SettableWidget = typeof widget & {
setValue?: (
value: string,
options: {
e: PointerEvent
node: unknown
canvas: unknown
}
) => void
}
const settableWidget = widget as SettableWidget | undefined
})
if (!settableWidget?.setValue) {
throw new Error('Expected concrete ckpt_name widget')
await test.step('B: bypassing the resolved sibling host keeps the active host error visible', async () => {
const siblingHostNodeId =
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.sharedDefinitionSiblingHostNodeId
if (siblingHostNodeId === undefined) {
throw new Error('Expected a shared-definition sibling host')
}
settableWidget.setValue(value, {
e: new PointerEvent('pointerup'),
node: hostNode,
canvas: window.app!.canvas
})
}, resolvedModelName)
const siblingHost =
await comfyPage.nodeOps.getNodeRefById(siblingHostNodeId)
await siblingHost.centerOnNode()
await siblingHost.click('title')
await comfyPage.keyboard.bypass()
await expect.poll(() => siblingHost.isBypassed()).toBeTruthy()
await comfyPage.canvas.click({ position: { x: 700, y: 650 } })
await openErrorsTab(comfyPage)
await expectSingleMissingModelReference(
missingModelGroup,
FAKE_MODEL_NAME
)
})
await expect(missingModelGroup).toBeHidden()
await test.step('C: changing the active host promoted widget resolves the model', async () => {
const activeHost = await comfyPage.nodeOps.getNodeRefById(
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeId
)
await activeHost.centerOnNode()
await selectVueComboPromotedModelByTitle(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeTitle,
RESOLVED_PROMOTED_MODEL_NAME
)
})
await test.step('D: the missing model UI clears', async () => {
await expectNoMissingModelUi(comfyPage)
})
await test.step('E: two missing shared-definition hosts report two references', async () => {
const siblingHostNodeId =
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.sharedDefinitionSiblingHostNodeId
if (siblingHostNodeId === undefined) {
throw new Error('Expected a shared-definition sibling host')
}
missingModelGroup =
await loadPromotedMissingModelWithHostValuesAndOpenErrorsTab(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
{
[siblingHostNodeId]: FAKE_MODEL_NAME,
[NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeId]:
FAKE_MODEL_NAME
},
FAKE_MODEL_NAME,
2
)
})
await test.step('F: changing one missing host leaves the other missing reference', async () => {
await selectVueComboPromotedModelByTitle(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeTitle,
RESOLVED_PROMOTED_MODEL_NAME
)
await expectMissingModelReferenceCount(
missingModelGroup,
FAKE_MODEL_NAME,
1
)
})
await test.step('G: changing the remaining missing host clears the model error', async () => {
const siblingHostTitle =
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.sharedDefinitionSiblingHostNodeTitle
if (siblingHostTitle === undefined) {
throw new Error('Expected a shared-definition sibling host title')
}
await selectVueComboPromotedModelByTitle(
comfyPage,
siblingHostTitle,
RESOLVED_PROMOTED_MODEL_NAME
)
await expectNoMissingModelUi(comfyPage)
})
}
)
test(
promotedModelTest(
'Changing an OSS Vue promoted model from the Parameters tab clears a nested subgraph error',
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
async ({ comfyPage }) => {
await loadPromotedMissingModelAndOpenErrorsTab(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
FAKE_MODEL_NAME
)
await selectSectionComboPromotedModel(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
RESOLVED_PROMOTED_MODEL_NAME
)
await expectNoMissingModelUi(comfyPage)
}
)
promotedModelTest(
'Changing an OSS legacy promoted model clears a nested subgraph error',
{ tag: ['@canvas', '@widget', '@subgraph'] },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await loadPromotedMissingModelAndOpenErrorsTab(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
FAKE_MODEL_NAME
)
await setLegacyPromotedComboModel(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
RESOLVED_PROMOTED_MODEL_NAME
)
await expectNoMissingModelUi(comfyPage)
}
)
promotedModelTest(
'Refreshing a resolved promoted missing model clears the combo invalid state',
{ tag: ['@widget', '@subgraph'] },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_model_promoted_widget'
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.workflowName
)
await comfyPage.vueNodes.waitForNodes()
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(getModelLabel(missingModelGroup)).toBeVisible()
await expect(
getMissingModelLabel(missingModelGroup, FAKE_MODEL_NAME)
).toBeVisible()
const promotedModelCombo = comfyPage.vueNodes
.getNodeByTitle('Subgraph with Promoted Missing Model')
.getNodeByTitle(NESTED_PROMOTED_MISSING_MODEL_WORKFLOW.hostNodeTitle)
.getByRole('combobox', { name: 'ckpt_name', exact: true })
await expect(promotedModelCombo).toHaveAttribute('aria-invalid', 'true')
const objectInfoRoute = /\/object_info$/
try {
await comfyPage.page.route(objectInfoRoute, async (route) => {
const response = await route.fetch()
const objectInfo = await response.json()
const ckptName =
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
await route.fulfill({ response, json: objectInfo })
})
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(
comfyPage.page,
(objectInfo) =>
appendComboInputOptions(
objectInfo,
'CheckpointLoaderSimple',
'ckpt_name',
[FAKE_MODEL_NAME, RESOLVED_PROMOTED_MODEL_NAME]
)
)
try {
await comfyPage.page
.getByTestId(TestIds.dialogs.missingModelRefresh)
.click()
@@ -478,11 +608,31 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
'true'
)
} finally {
await comfyPage.page.unroute(objectInfoRoute)
await unrouteObjectInfo()
}
}
)
promotedModelTest(
'Reloading a resolved nested promoted model ignores stale interior values',
{ tag: ['@vue-nodes', '@widget', '@subgraph'] },
async ({ comfyPage }) => {
await expectResolvedPromotedModelSuppressesStaleInteriorErrors(
comfyPage,
NESTED_PROMOTED_MISSING_MODEL_WORKFLOW,
[
{
subgraphNodeIdToEnter: '4',
nodeTitle: 'Inner Subgraph with Promoted Missing Model'
},
{ subgraphNodeIdToEnter: '2', nodeTitle: 'Load Checkpoint' }
],
RESOLVED_PROMOTED_MODEL_NAME,
FAKE_MODEL_NAME
)
}
)
test('Bypassing a subgraph hides interior errors, un-bypassing restores them', async ({
comfyPage
}) => {

View File

@@ -0,0 +1,173 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { createI18n } from 'vue-i18n'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
import SectionWidgets from './SectionWidgets.vue'
const setDirty = vi.fn()
const selectedItems: unknown[] = []
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: { setDirty },
selectedItems
})
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => ({
getInputSpecForWidget: vi.fn()
})
}))
const WidgetItemStub = defineComponent({
inheritAttrs: false,
emits: ['update:widget-value', 'reset-to-default'],
template: `
<button
data-testid="widget-edit"
@click="$emit('update:widget-value', 'real_model.safetensors')"
/>
`
})
const PropertiesAccordionItemStub = defineComponent({
inheritAttrs: false,
emits: ['update:collapse'],
template: '<section><slot name="label" /><slot /></section>'
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
rightSidePanel: {
inputs: 'Inputs',
resetAllParameters: 'Reset all',
seeError: 'See error'
}
}
}
})
function createHostWithPromotedModel(): {
host: SubgraphNode
promotedWidget: IBaseWidget
sourceWidget: IBaseWidget
sourceExecutionId: NodeExecutionId
hostExecutionId: NodeExecutionId
} {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph, { id: 65 })
const graph = host.graph as LGraph
graph.add(host)
const sourceNode = new LGraphNode('CheckpointLoaderSimple')
sourceNode.id = 42
const sourceInput = sourceNode.addInput('ckpt_name', 'COMBO')
const sourceWidget = sourceNode.addWidget(
'combo',
'ckpt_name',
'missing_model.safetensors',
() => {},
{ values: ['real_model.safetensors'] }
)
sourceInput.widget = { name: sourceWidget.name }
subgraph.add(sourceNode)
expect(
promoteValueWidgetViaSubgraphInput(host, sourceNode, sourceWidget).ok
).toBe(true)
const promotedWidget = host.widgets?.find(
(widget) => widget.name === sourceWidget.name
)
if (!promotedWidget) throw new Error('Expected promoted widget')
const rootGraph = host.rootGraph
const sourceExecutionId = getExecutionIdByNode(rootGraph, sourceNode)
const hostExecutionId = getExecutionIdByNode(rootGraph, host)
if (!sourceExecutionId || !hostExecutionId) {
throw new Error('Expected execution ids')
}
return {
host,
promotedWidget,
sourceWidget,
sourceExecutionId,
hostExecutionId
}
}
describe('SectionWidgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
setDirty.mockClear()
selectedItems.length = 0
})
it('clears promoted widget validation by source and missing model by host', async () => {
const {
host,
promotedWidget,
sourceWidget,
sourceExecutionId,
hostExecutionId
} = createHostWithPromotedModel()
const executionErrorStore = useExecutionErrorStore()
const clearSpy = vi.spyOn(executionErrorStore, 'clearWidgetRelatedErrors')
const user = userEvent.setup()
render(SectionWidgets, {
props: {
widgets: [{ widget: promotedWidget, node: host }]
},
global: {
plugins: [i18n],
stubs: {
Button: true,
WidgetItem: WidgetItemStub,
PropertiesAccordionItem: PropertiesAccordionItemStub
}
}
})
await user.click(screen.getByTestId('widget-edit'))
expect(clearSpy).toHaveBeenNthCalledWith(
1,
sourceExecutionId,
sourceWidget.name,
sourceWidget.name,
'real_model.safetensors',
{ min: undefined, max: undefined }
)
expect(clearSpy).toHaveBeenNthCalledWith(
2,
hostExecutionId,
promotedWidget.name,
promotedWidget.name,
'real_model.safetensors',
{ min: undefined, max: undefined }
)
})
})

View File

@@ -14,6 +14,7 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import { isWidgetPromotedOnSubgraphNode } from '@/core/graph/subgraph/promotionUtils'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -25,6 +26,7 @@ import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { cn } from '@comfyorg/tailwind-utils'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
@@ -233,12 +235,49 @@ function navigateToErrorTab() {
rightSidePanelStore.openPanel('errors')
}
function setWidgetValue(widget: IBaseWidget, value: WidgetValue) {
function clearWidgetErrors(
widgetNode: LGraphNode,
widget: IBaseWidget,
value: WidgetValue
) {
const rootGraph = widgetNode.graph?.rootGraph
if (!rootGraph) return
const executionId = getExecutionIdByNode(rootGraph, widgetNode)
if (!executionId) return
const options = { min: widget.options?.min, max: widget.options?.max }
const source = resolvePromotedWidgetSource(rootGraph, widgetNode, widget)
if (source?.sourceExecutionId) {
executionErrorStore.clearWidgetRelatedErrors(
source.sourceExecutionId,
source.sourceWidgetName,
source.sourceWidgetName,
value,
options
)
}
executionErrorStore.clearWidgetRelatedErrors(
executionId,
widget.name,
widget.name,
value,
options
)
}
function setWidgetValue(
widgetNode: LGraphNode,
widget: IBaseWidget,
value: WidgetValue
) {
// Store-backed widgets (interior node widgets and promoted subgraph inputs)
// are addressed by widgetId; writing there keeps the displayed value in sync.
if (widget.widgetId) useWidgetValueStore().setValue(widget.widgetId, value)
widget.value = value
widget.callback?.(value)
clearWidgetErrors(widgetNode, widget, value)
canvasStore.canvas?.setDirty(true, true)
}
@@ -247,18 +286,26 @@ function handleResetAllWidgets() {
const spec = nodeDefStore.getInputSpecForWidget(widgetNode, widget.name)
const defaultValue = getWidgetDefaultValue(spec)
if (defaultValue !== undefined) {
setWidgetValue(widget, defaultValue)
setWidgetValue(widgetNode, widget, defaultValue)
}
}
}
function handleWidgetValueUpdate(widget: IBaseWidget, newValue: WidgetValue) {
function handleWidgetValueUpdate(
widgetNode: LGraphNode,
widget: IBaseWidget,
newValue: WidgetValue
) {
if (newValue === undefined) return
setWidgetValue(widget, newValue)
setWidgetValue(widgetNode, widget, newValue)
}
function handleWidgetReset(widget: IBaseWidget, newValue: WidgetValue) {
setWidgetValue(widget, newValue)
function handleWidgetReset(
widgetNode: LGraphNode,
widget: IBaseWidget,
newValue: WidgetValue
) {
setWidgetValue(widgetNode, widget, newValue)
}
defineExpose({
@@ -354,8 +401,8 @@ defineExpose({
:show-node-name="showNodeName"
:parents="parents"
:is-shown-on-parents="isWidgetShownOnParents(node, widget)"
@update:widget-value="handleWidgetValueUpdate(widget, $event)"
@reset-to-default="handleWidgetReset(widget, $event)"
@update:widget-value="handleWidgetValueUpdate(node, widget, $event)"
@reset-to-default="handleWidgetReset(node, widget, $event)"
/>
</TransitionGroup>
</div>

View File

@@ -30,6 +30,28 @@ beforeEach(() => {
vi.restoreAllMocks()
})
function createNestedSubgraphRuntime() {
const rootGraph = new LGraph()
const outerSubgraph = createTestSubgraph({ rootGraph })
const innerSubgraph = createTestSubgraph({ rootGraph })
const leafNode = new LGraphNode('CheckpointLoaderSimple')
innerSubgraph.add(leafNode)
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
parentGraph: outerSubgraph,
id: 77
})
outerSubgraph.add(innerSubgraphNode)
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, {
parentGraph: rootGraph,
id: 65
})
rootGraph.add(outerSubgraphNode)
return { rootGraph, outerSubgraph, innerSubgraphNode, outerSubgraphNode }
}
describe('Connection error clearing via onConnectionsChange', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -918,7 +940,7 @@ describe('scan skips interior of bypassed subgraph containers', () => {
expect(useMissingModelStore().missingModelCandidates).toBeNull()
})
it('skips nested subgraph containers during parent subgraph replay scan', async () => {
it('scans nested subgraph containers during parent subgraph replay scan', async () => {
const rootGraph = new LGraph()
const outerSubgraph = createTestSubgraph({ rootGraph })
const innerSubgraph = createTestSubgraph({ rootGraph })
@@ -950,25 +972,100 @@ describe('scan skips interior of bypassed subgraph containers', () => {
rootGraph.onNodeAdded?.(outerSubgraphNode)
await new Promise((r) => setTimeout(r, 0))
expect(modelScanSpy).toHaveBeenCalledWith(
rootGraph,
outerSubgraphNode,
expect.any(Function),
expect.any(Function)
)
expect(modelScanSpy).toHaveBeenCalledWith(
rootGraph,
leafNode,
expect.any(Function),
expect.any(Function)
)
expect(modelScanSpy).not.toHaveBeenCalledWith(
expect(modelScanSpy).toHaveBeenCalledWith(
rootGraph,
innerSubgraphNode,
expect.any(Function),
expect.any(Function)
)
expect(mediaScanSpy).toHaveBeenCalledWith(
rootGraph,
outerSubgraphNode,
false
)
expect(mediaScanSpy).toHaveBeenCalledWith(rootGraph, leafNode, false)
expect(mediaScanSpy).not.toHaveBeenCalledWith(
expect(mediaScanSpy).toHaveBeenCalledWith(
rootGraph,
innerSubgraphNode,
false
)
})
it('removes host-keyed promoted missing models when a source ancestor is bypassed', () => {
const { rootGraph, outerSubgraph, innerSubgraphNode } =
createNestedSubgraphRuntime()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
installErrorClearingHooks(outerSubgraph)
const modelStore = useMissingModelStore()
modelStore.setMissingModels([
fromAny<MissingModelCandidate, unknown>({
nodeId: '65',
sourceExecutionId: createNodeExecutionId([65, 77, 1]),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'outer_ckpt',
isAssetSupported: false,
name: 'fake.safetensors',
isMissing: true
})
])
innerSubgraphNode.mode = LGraphEventMode.BYPASS
outerSubgraph.onTrigger?.({
type: 'node:property:changed',
nodeId: innerSubgraphNode.id,
property: 'mode',
oldValue: LGraphEventMode.ALWAYS,
newValue: LGraphEventMode.BYPASS
})
expect(modelStore.missingModelCandidates).toBeNull()
})
it('rescans ancestor hosts when a promoted source ancestor is un-bypassed', () => {
const { rootGraph, outerSubgraph, innerSubgraphNode, outerSubgraphNode } =
createNestedSubgraphRuntime()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
const hostCandidate = fromAny<MissingModelCandidate, unknown>({
nodeId: '65',
sourceExecutionId: createNodeExecutionId([65, 77, 1]),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'outer_ckpt',
isAssetSupported: false,
name: 'fake.safetensors',
isMissing: true
})
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockImplementation(
(_rootGraph, node) => (node === outerSubgraphNode ? [hostCandidate] : [])
)
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
installErrorClearingHooks(outerSubgraph)
innerSubgraphNode.mode = LGraphEventMode.ALWAYS
outerSubgraph.onTrigger?.({
type: 'node:property:changed',
nodeId: innerSubgraphNode.id,
property: 'mode',
oldValue: LGraphEventMode.BYPASS,
newValue: LGraphEventMode.ALWAYS
})
expect(useMissingModelStore().missingModelCandidates).toEqual([
hostCandidate
])
})
})
describe('clearWidgetRelatedErrors parameter routing', () => {
@@ -1004,7 +1101,7 @@ describe('clearWidgetRelatedErrors parameter routing', () => {
clearSpy.mockRestore()
})
it('clears promoted widget errors by interior execution id', () => {
it('clears promoted widget errors by host execution id', () => {
const subgraph = createTestSubgraph()
const graph = subgraph.rootGraph
const host = createTestSubgraphNode(subgraph, { id: 2 })
@@ -1032,7 +1129,7 @@ describe('clearWidgetRelatedErrors parameter routing', () => {
const missingModelStore = useMissingModelStore()
missingModelStore.setMissingModels([
{
nodeId: '2:1',
nodeId: '2',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,

View File

@@ -6,7 +6,7 @@
* works in legacy canvas mode as well.
*/
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
@@ -34,15 +34,16 @@ import { useNodeReplacementStore } from '@/platform/nodeReplacement/nodeReplacem
import { getCnrIdFromNode } from '@/platform/nodeReplacement/cnrIdUtil'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { appendNodeExecutionId } from '@/types/nodeIdentification'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import {
collectAllNodes,
getExecutionIdByNode,
getExecutionIdForNodeInGraph,
getNodeByExecutionId,
isAncestorPathActive
isExecutionPathActive,
isMissingCandidateActive
} from '@/utils/graphTraversalUtil'
import { getParentExecutionIds } from '@/types/nodeIdentification'
const hookedNodes = new WeakSet<LGraphNode>()
@@ -77,23 +78,29 @@ function installNodeHooks(node: LGraphNode): void {
node.onWidgetChanged = useChainCallback(
node.onWidgetChanged,
function (_name, newValue, _oldValue, widget) {
function (name, newValue, _oldValue, widget) {
if (!app.rootGraph) return
const hostExecId = getExecutionIdByNode(app.rootGraph, node)
if (!hostExecId) return
const promotedSource = widgetPromotedSource(node, widget)
const executionId = promotedSource
? appendNodeExecutionId(hostExecId, promotedSource.nodeId)
: hostExecId
const widgetName = promotedSource?.widgetName ?? widget.name
const options = { min: widget.options?.min, max: widget.options?.max }
const source = resolvePromotedWidgetSource(app.rootGraph, node, widget)
if (source?.sourceExecutionId) {
useExecutionErrorStore().clearWidgetRelatedErrors(
source.sourceExecutionId,
source.sourceWidgetName,
source.sourceWidgetName,
newValue,
options
)
}
useExecutionErrorStore().clearWidgetRelatedErrors(
executionId,
widgetName,
widgetName,
hostExecId,
name,
widget.name,
newValue,
{ min: widget.options?.min, max: widget.options?.max }
options
)
}
)
@@ -137,8 +144,8 @@ function scanNodeErrorTargets(
if (!app.rootGraph) return
if (node.isSubgraphNode?.() && node.subgraph) {
scanNode(node)
for (const innerNode of collectAllNodes(node.subgraph)) {
if (innerNode.isSubgraphNode?.()) continue
if (isNodeInactive(innerNode.mode)) continue
scanNode(innerNode)
}
@@ -157,7 +164,7 @@ function getActiveExecutionId(node: LGraphNode): string | null {
// execId means the node has no current graph (e.g. detached mid
// lifecycle) — also skip, since we cannot verify its scope.
const execId = getExecutionIdByNode(app.rootGraph, node)
if (!execId || !isAncestorPathActive(app.rootGraph, execId)) return null
if (!execId || !isExecutionPathActive(app.rootGraph, execId)) return null
return execId
}
@@ -198,6 +205,8 @@ function scanSingleNodeModelsAndTypes(node: LGraphNode): void {
void verifyAndAddPendingModels(pendingModels)
}
if (node.isSubgraphNode?.()) return
const originalType = node.last_serialization?.type ?? node.type ?? 'Unknown'
if (!(originalType in LiteGraph.registered_node_types)) {
const nodeReplacementStore = useNodeReplacementStore()
@@ -240,17 +249,19 @@ function scanSingleNodeMedia(node: LGraphNode): void {
* 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 execId = String(nodeId)
const node = getNodeByExecutionId(app.rootGraph, execId)
if (!node) return false
if (isNodeInactive(node.mode)) return false
// Also reject if any enclosing subgraph was bypassed between scan
// kick-off and verification resolving — mirrors the pipeline-level
// ancestor post-filter so realtime and initial-load paths stay
// symmetric.
return isAncestorPathActive(app.rootGraph, execId)
function isModelCandidateStillActive(
candidate: MissingModelCandidate
): boolean {
return isMissingCandidateActive(app.rootGraph, candidate)
}
function isNodeCandidateStillActive(nodeId: unknown): boolean {
return (
app.rootGraph !== null &&
app.rootGraph !== undefined &&
nodeId != null &&
isExecutionPathActive(app.rootGraph, String(nodeId))
)
}
async function verifyAndAddPendingModels(
@@ -264,7 +275,7 @@ async function verifyAndAddPendingModels(
await verifyAssetSupportedCandidates(pending)
if (app.rootGraph !== rootGraphAtScan) return
const verified = pending.filter(
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)
(c) => c.isMissing === true && isModelCandidateStillActive(c)
)
if (verified.length) useMissingModelStore().addMissingModels(verified)
} catch (error: unknown) {
@@ -280,7 +291,7 @@ async function verifyAndAddPendingMedia(
await verifyMediaCandidates(pending, { isCloud })
if (app.rootGraph !== rootGraphAtScan) return
const verified = pending.filter(
(c) => c.isMissing === true && isCandidateStillActive(c.nodeId)
(c) => c.isMissing === true && isNodeCandidateStillActive(c.nodeId)
)
if (verified.length) useMissingMediaStore().addMissingMedia(verified)
} catch (error: unknown) {
@@ -332,6 +343,7 @@ function handleNodeModeChange(
removeNodeErrors(node, execId)
} else {
scanAndAddNodeErrors(node)
scanAncestorSubgraphHosts(execId)
if (
useMissingModelStore().hasMissingModels ||
useMissingMediaStore().hasMissingMedia ||
@@ -342,6 +354,15 @@ function handleNodeModeChange(
}
}
function scanAncestorSubgraphHosts(execId: string): void {
if (!app.rootGraph) return
for (const ancestorId of getParentExecutionIds(execId)) {
if (!isExecutionPathActive(app.rootGraph, ancestorId)) continue
const ancestor = getNodeByExecutionId(app.rootGraph, ancestorId)
if (ancestor?.isSubgraphNode?.()) scanSingleNodeErrors(ancestor)
}
}
/** 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 {
@@ -350,6 +371,7 @@ function removeNodeErrors(node: LGraphNode, execId: string): void {
const nodesStore = useMissingNodesErrorStore()
modelStore.removeMissingModelsByNodeId(execId)
modelStore.removeMissingModelsBySourceScope(execId)
mediaStore.removeMissingMediaByNodeId(execId)
nodesStore.removeMissingNodesByNodeId(execId)

View File

@@ -7,12 +7,8 @@ import cloneDeep from 'es-toolkit/compat/cloneDeep'
import { reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import {
inputForWidget,
promotedInputSource,
promotedInputWidgets
} from '@/core/graph/subgraph/promotedInputWidget'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { promotedInputWidgets } from '@/core/graph/subgraph/promotedInputWidget'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import type {
INodeInputSlot,
INodeOutputSlot
@@ -46,7 +42,6 @@ import type {
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { app } from '@/scripts/app'
import { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
export interface WidgetSlotMetadata {
index: number
@@ -92,15 +87,13 @@ export interface SafeWidgetData {
slotMetadata?: WidgetSlotMetadata
/**
* Execution ID of the interior node that owns the source widget.
* Only set for promoted widgets where the source node differs from the
* host subgraph node. Used for missing-model lookups that key by
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
* Only set for promoted widgets where the source node differs from the host
* subgraph node. Retained for source-scoped validation errors.
*/
sourceExecutionId?: NodeExecutionId
/**
* Interior source widget name. Only set for promoted widgets, where `name`
* is the host input slot name; missing-model lookups key by the interior
* widget name, which can differ from the slot name (e.g. after a rename).
* Interior source widget name. Only set for promoted widgets, where `name` is
* the host input slot name and the source widget name can differ.
*/
sourceWidgetName?: string
/** Tooltip text from the resolved widget. */
@@ -241,31 +234,20 @@ function resolvePromotedMetadata(
node: SubgraphNode,
widget: IBaseWidget
): PromotedWidgetMetadata | undefined {
const input = inputForWidget(node, widget)
if (!input?.widgetId) return undefined
const source = promotedInputSource(node, input)
const source = resolvePromotedWidgetSource(app.rootGraph, node, widget)
if (!source) return undefined
const resolution = resolveConcretePromotedWidget(
node,
source.nodeId,
source.widgetName
ensurePromotedHostWidgetState(
source.input.widgetId,
source.input,
source.sourceWidget
)
const resolved =
resolution.status === 'resolved' ? resolution.resolved : undefined
const sourceWidget = resolved?.widget
const sourceNode = resolved?.node
ensurePromotedHostWidgetState(input.widgetId, input, sourceWidget)
return {
controlWidget: sourceWidget ? getControlWidget(sourceWidget) : undefined,
isDOMWidget: sourceWidget ? isDOMBackedWidget(sourceWidget) : false,
sourceExecutionId:
sourceNode && app.rootGraph
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
: undefined,
sourceWidgetName: sourceWidget?.name
controlWidget: getControlWidget(source.sourceWidget),
isDOMWidget: isDOMBackedWidget(source.sourceWidget),
sourceExecutionId: source.sourceExecutionId,
sourceWidgetName: source.sourceWidgetName
}
}

View File

@@ -3,6 +3,7 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
export interface ResolvedPromotedWidget {
node: LGraphNode
nodePath: string[]
widget: IBaseWidget
}

View File

@@ -55,6 +55,7 @@ describe('resolveConcretePromotedWidget', () => {
expect(result.status).toBe('resolved')
if (result.status !== 'resolved') return
expect(result.resolved.node.id).toBe(concreteNode.id)
expect(result.resolved.nodePath).toEqual([String(concreteNode.id)])
expect(result.resolved.widget.name).toBe('seed')
})
@@ -91,6 +92,10 @@ describe('resolveConcretePromotedWidget', () => {
expect(result.status).toBe('resolved')
if (result.status !== 'resolved') return
expect(result.resolved.node.id).toBe(leaf.id)
expect(result.resolved.nodePath).toEqual([
String(innerNode.id),
String(leaf.id)
])
expect(result.resolved.widget.name).toBe('seed')
expect(result.resolved.widget.type).toBe('combo')
})

View File

@@ -2,6 +2,8 @@ import type { ResolvedPromotedWidget } from '@/core/graph/subgraph/promotedWidge
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { createNodeExecutionId } from '@/types/nodeIdentification'
type PromotedWidgetResolutionFailure =
| 'invalid-host'
@@ -25,6 +27,7 @@ function traversePromotedWidgetChain(
let currentHost = hostNode
let currentNodeId = nodeId
let currentWidgetName = widgetName
const nodePath: string[] = []
for (let depth = 0; depth < MAX_PROMOTED_WIDGET_CHAIN_DEPTH; depth++) {
const key = `${currentNodeId}:${currentWidgetName}`
@@ -39,6 +42,7 @@ function traversePromotedWidgetChain(
if (!sourceNode) {
return { status: 'failure', failure: 'missing-node' }
}
nodePath.push(String(sourceNode.id))
if (sourceNode.isSubgraphNode()) {
const target = resolveSubgraphInputTarget(sourceNode, currentWidgetName)
@@ -60,7 +64,7 @@ function traversePromotedWidgetChain(
return {
status: 'resolved',
resolved: { node: sourceNode, widget: sourceWidget }
resolved: { node: sourceNode, nodePath, widget: sourceWidget }
}
}
@@ -77,3 +81,12 @@ export function resolveConcretePromotedWidget(
}
return traversePromotedWidgetChain(hostNode, nodeId, widgetName)
}
export function buildPromotedSourceExecutionId(
hostExecutionId: string,
nodePath: readonly string[]
): NodeExecutionId | undefined {
return nodePath.length
? createNodeExecutionId([...hostExecutionId.split(':'), ...nodePath])
: undefined
}

View File

@@ -0,0 +1,67 @@
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
import { inputForWidget, promotedInputSource } from './promotedInputWidget'
import {
buildPromotedSourceExecutionId,
resolveConcretePromotedWidget
} from './resolveConcretePromotedWidget'
type PromotedWidgetInput = INodeInputSlot & {
widgetId: NonNullable<INodeInputSlot['widgetId']>
}
function hasWidgetId(
input: INodeInputSlot | undefined
): input is PromotedWidgetInput {
return input?.widgetId !== undefined
}
interface ResolvedPromotedWidgetSource {
input: PromotedWidgetInput
sourceExecutionId?: NodeExecutionId
sourceNode: LGraphNode
sourceWidget: IBaseWidget
sourceWidgetName: string
}
export function resolvePromotedWidgetSource(
rootGraph: LGraph | null | undefined,
node: LGraphNode,
widget: IBaseWidget
): ResolvedPromotedWidgetSource | undefined {
if (!node.isSubgraphNode?.()) return undefined
const input = inputForWidget(node, widget)
if (!hasWidgetId(input)) return undefined
const source = promotedInputSource(node, input)
if (!source) return undefined
const resolution = resolveConcretePromotedWidget(
node,
source.nodeId,
source.widgetName
)
if (resolution.status !== 'resolved') return undefined
const { node: sourceNode, widget: sourceWidget } = resolution.resolved
const hostExecutionId = rootGraph
? getExecutionIdByNode(rootGraph, node)
: undefined
return {
input,
sourceExecutionId: hostExecutionId
? buildPromotedSourceExecutionId(
hostExecutionId,
resolution.resolved.nodePath
)
: undefined,
sourceNode,
sourceWidget,
sourceWidgetName: sourceWidget.name
}
}

View File

@@ -10,8 +10,18 @@ import {
refreshMissingModelPipeline,
runMissingModelPipeline
} from '@/platform/missingModel/missingModelPipeline'
import { createNodeExecutionId } from '@/types/nodeIdentification'
const { mockHandles } = vi.hoisted(() => {
const isAncestorPathActive = vi.fn((_graph: LGraph, _nodeId: string) => true)
const isCandidateScopeActive = vi.fn(
(graph: LGraph, candidate: MissingModelCandidate) => {
const executionId = candidate.sourceExecutionId ?? candidate.nodeId
return (
executionId == null || isAncestorPathActive(graph, String(executionId))
)
}
)
const state = {
enrichedCandidates: [] as MissingModelCandidate[]
}
@@ -66,7 +76,8 @@ const { mockHandles } = vi.hoisted(() => {
getFolderPaths: vi.fn()
},
fetchModelMetadata: vi.fn(),
isAncestorPathActive: vi.fn((_graph: LGraph, _nodeId: string) => true),
isAncestorPathActive,
isCandidateScopeActive,
isMissingCandidateActive: vi.fn(
(_graph: LGraph, _candidate: MissingModelCandidate) => true
)
@@ -133,6 +144,8 @@ vi.mock('@/platform/missingModel/missingModelDownload', () => ({
vi.mock('@/utils/graphTraversalUtil', () => ({
isAncestorPathActive: (graph: LGraph, nodeId: string) =>
mockHandles.isAncestorPathActive(graph, nodeId),
isCandidateScopeActive: (graph: LGraph, candidate: MissingModelCandidate) =>
mockHandles.isCandidateScopeActive(graph, candidate),
isMissingCandidateActive: (graph: LGraph, candidate: MissingModelCandidate) =>
mockHandles.isMissingCandidateActive(graph, candidate)
}))
@@ -172,6 +185,15 @@ describe('missingModelPipeline', () => {
mockHandles.api.getFolderPaths.mockResolvedValue({})
mockHandles.fetchModelMetadata.mockResolvedValue({ fileSize: null })
mockHandles.isAncestorPathActive.mockReturnValue(true)
mockHandles.isCandidateScopeActive.mockImplementation(
(graph: LGraph, candidate: MissingModelCandidate) => {
const executionId = candidate.sourceExecutionId ?? candidate.nodeId
return (
executionId == null ||
mockHandles.isAncestorPathActive(graph, String(executionId))
)
}
)
mockHandles.isMissingCandidateActive.mockReturnValue(true)
})
@@ -525,6 +547,38 @@ describe('missingModelPipeline', () => {
})
})
it('drops host-keyed promoted candidates whose source path is inactive', async () => {
const promotedCandidate = {
nodeId: '65',
sourceExecutionId: createNodeExecutionId([65, 77, 42]),
nodeType: 'CheckpointLoaderSimple',
widgetName: 'outer_ckpt',
name: 'inactive-source.safetensors',
directory: 'checkpoints',
isMissing: true,
isAssetSupported: true
}
const activeWorkflow = {
activeState: null,
pendingWarnings: null
}
const graph = createGraph()
mockHandles.state.enrichedCandidates = [promotedCandidate]
mockHandles.workspaceWorkflow.activeWorkflow = activeWorkflow
mockHandles.isAncestorPathActive.mockImplementation(
(_graph: LGraph, nodeId: string) => nodeId !== '65:77:42'
)
const result = await runMissingModelPipeline({
graph,
graphData: createWorkflowGraphData(),
missingModelStore: mockHandles.missingModelStore
})
expect(result.confirmedCandidates).toEqual([])
expect(activeWorkflow.pendingWarnings).toBeNull()
})
it('skips post-fetch surface when folder path refresh is aborted', async () => {
const controller = new AbortController()
const confirmedCandidate = {

View File

@@ -19,7 +19,7 @@ import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { MissingNodeType } from '@/types/comfy'
import {
isAncestorPathActive,
isCandidateScopeActive,
isMissingCandidateActive
} from '@/utils/graphTraversalUtil'
@@ -122,15 +122,15 @@ export async function runMissingModelPipeline({
const enrichedAll = enrichWithEmbeddedMetadata(candidates, graphData)
// Drop candidates whose enclosing subgraph is muted/bypassed. Per-node
// scans only checked each node's own mode; the cascade from an
// inactive container to its interior happens here.
// Drop candidates whose active scope is muted/bypassed. Normal candidates
// use nodeId; promoted host candidates use sourceExecutionId so host-keyed
// errors still respect inactive interior subgraph containers.
// Asymmetric on purpose: a candidate dropped here is not resurrected if
// the user un-bypasses the container mid-verification. The realtime
// mode-change path (handleNodeModeChange → scanAndAddNodeErrors) is
// responsible for surfacing errors after an un-bypass.
const enrichedCandidates = enrichedAll.filter(
(c) => c.nodeId == null || isAncestorPathActive(graph, String(c.nodeId))
const enrichedCandidates = enrichedAll.filter((c) =>
isCandidateScopeActive(graph, c)
)
const confirmedCandidates = enrichedCandidates.filter(

View File

@@ -1,6 +1,9 @@
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type {
@@ -17,14 +20,52 @@ import {
} from '@/platform/missingModel/missingModelScan'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
vi.mock('@/utils/graphTraversalUtil', () => ({
collectAllNodes: (graph: { _testNodes: LGraphNode[] }) => graph._testNodes,
getExecutionIdByNode: (
_graph: unknown,
node: { _testExecutionId?: string; id: number }
) => node._testExecutionId ?? String(node.id)
}))
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
vi.mock('@/utils/graphTraversalUtil', () => {
type TestNode = LGraphNode & {
_testExecutionId?: string
_testActiveExecutionIds?: string[]
}
type TestGraph = { _testNodes: TestNode[] }
const getNodeExecutionIds = (node: TestNode) => [
node._testExecutionId ?? String(node.id),
...(node._testActiveExecutionIds ?? [])
]
const findNodeByExecutionId = (graph: TestGraph, executionId: string) =>
graph._testNodes.find((node) =>
getNodeExecutionIds(node).includes(executionId)
)
const isInactive = (node: LGraphNode | undefined) =>
node?.mode === 2 || node?.mode === 4
const isAncestorPathActive = (graph: TestGraph, executionId: string) => {
const path = executionId.split(':')
const prefixes = path
.slice(0, -1)
.map((_, index) => path.slice(0, index + 1).join(':'))
return prefixes.every((prefix) => {
const node = findNodeByExecutionId(graph, prefix)
return !isInactive(node)
})
}
return {
collectAllNodes: (graph: TestGraph) => graph._testNodes,
getExecutionIdByNode: (_graph: unknown, node: TestNode) =>
node._testExecutionId ?? String(node.id),
isAncestorPathActive,
isExecutionPathActive: (graph: TestGraph, executionId: string) => {
const node = findNodeByExecutionId(graph, executionId)
return (
!!node && !isInactive(node) && isAncestorPathActive(graph, executionId)
)
}
}
})
/** Helper: create a combo widget mock */
function makeComboWidget(
@@ -80,6 +121,118 @@ function makeGraph(nodes: LGraphNode[]): LGraph {
return fromAny<LGraph, unknown>({ _testNodes: nodes })
}
function makeNestedPromotedModelGraph({
hostValue = 'missing_model.safetensors',
leafActiveExecutionIds,
leafExecutionId = '65:77:42',
innerMode = 0,
innerExecutionId = '65:77',
sourceOptions = ['existing_model.safetensors'],
sourceValue = 'stale_model.safetensors'
}: {
hostValue?: string
leafActiveExecutionIds?: string[]
leafExecutionId?: string
innerMode?: number
innerExecutionId?: string
sourceOptions?: string[]
sourceValue?: string
} = {}): LGraph {
const outerLinkId = 12
const innerLinkId = 13
const sourceWidget = makeComboWidget('ckpt_name', sourceValue, sourceOptions)
const sourceInput = fromAny<INodeInputSlot, unknown>({
name: 'ckpt_name',
link: innerLinkId
})
const leafNode = makeNode(
42,
'CheckpointLoaderSimple',
[sourceWidget],
leafExecutionId
)
Object.assign(leafNode, {
_testActiveExecutionIds: leafActiveExecutionIds,
inputs: [sourceInput],
isSubgraphNode: () => false,
getSlotFromWidget: (widget: IBaseWidget | undefined) =>
widget?.name === sourceWidget.name ? sourceInput : undefined,
getWidgetFromSlot: (input: INodeInputSlot | undefined) =>
input === sourceInput ? sourceWidget : undefined
})
const innerWidgetId = widgetId('graph', 77, 'inner_ckpt')
const innerInput = fromAny<INodeInputSlot, unknown>({
name: 'inner_ckpt',
link: outerLinkId,
widget: { name: 'inner_ckpt' },
widgetId: innerWidgetId
})
const innerNode = fromAny<LGraphNode, unknown>({
id: 77,
type: 'inner-subgraph-uuid',
inputs: [innerInput],
mode: innerMode,
isSubgraphNode: () => true,
getSlotFromWidget: (widget: IBaseWidget | undefined) =>
widget?.widgetId === innerInput.widgetId ||
widget?.name === innerInput.name
? innerInput
: undefined,
subgraph: {
inputNode: {
slots: [{ name: 'inner_ckpt', linkIds: [innerLinkId] }]
},
getLink: (id: number) =>
id === innerLinkId
? { resolve: () => ({ inputNode: leafNode }) }
: null,
getNodeById: (id: string | number) =>
String(id) === String(leafNode.id) ? leafNode : null
},
_testExecutionId: innerExecutionId
})
const outerWidgetId = widgetId('graph', 65, 'outer_ckpt')
useWidgetValueStore().registerWidget(outerWidgetId, {
type: 'combo',
value: hostValue,
options: {},
label: 'Outer checkpoint'
})
const outerInput = fromAny<INodeInputSlot, unknown>({
name: 'outer_ckpt',
link: null,
widget: { name: 'outer_ckpt' },
widgetId: outerWidgetId
})
const outerNode = fromAny<LGraphNode, unknown>({
id: 65,
type: 'outer-subgraph-uuid',
inputs: [outerInput],
isSubgraphNode: () => true,
getSlotFromWidget: (widget: IBaseWidget | undefined) =>
widget?.widgetId === outerInput.widgetId ||
widget?.name === outerInput.name
? outerInput
: undefined,
subgraph: {
inputNode: {
slots: [{ name: 'outer_ckpt', linkIds: [outerLinkId] }]
},
getLink: (id: number) =>
id === outerLinkId
? { resolve: () => ({ inputNode: innerNode }) }
: null,
getNodeById: (id: string | number) =>
String(id) === String(innerNode.id) ? innerNode : null
},
_testExecutionId: '65'
})
return makeGraph([outerNode, innerNode, leafNode])
}
const noAssetSupport = () => false
describe('isModelFileName', () => {
@@ -627,30 +780,331 @@ describe('scanAllModelCandidates', () => {
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,
type: 'abc-def-uuid',
widgets: [makeComboWidget('ckpt_name', 'model.safetensors', [])],
isSubgraphNode: () => true,
_testExecutionId: '65'
})
it('scans unlinked promoted subgraph widgets using host identity and source metadata', () => {
const linkId = 12
const sourceWidget = makeComboWidget(
'ckpt_name',
'stale_model.safetensors',
['existing_model.safetensors']
)
const sourceInput = fromAny({ name: 'ckpt_name', link: linkId })
const interiorNode = makeNode(
42,
'CheckpointLoaderSimple',
[
makeComboWidget('ckpt_name', 'model.safetensors', ['model.safetensors'])
],
[sourceWidget],
'65:42'
)
Object.assign(interiorNode, {
inputs: [sourceInput],
isSubgraphNode: () => false,
getSlotFromWidget: (widget: IBaseWidget | undefined) =>
widget?.name === sourceWidget.name ? sourceInput : undefined,
getWidgetFromSlot: () => sourceWidget,
properties: {
models: [
{
name: 'missing_model.safetensors',
url: 'https://example.com/missing_model',
directory: 'checkpoints'
}
]
}
})
const hostWidgetId = widgetId('graph', 65, 'promoted_ckpt')
useWidgetValueStore().registerWidget(hostWidgetId, {
type: 'combo',
value: 'missing_model.safetensors',
options: {},
label: 'Promoted checkpoint'
})
const hostInput = fromAny<INodeInputSlot, unknown>({
name: 'promoted_ckpt',
link: null,
widget: { name: 'promoted_ckpt' },
widgetId: hostWidgetId
})
const hostNode = fromAny<LGraphNode, unknown>({
id: 65,
type: 'abc-def-uuid',
inputs: [hostInput],
isSubgraphNode: () => true,
getSlotFromWidget: (widget: IBaseWidget | undefined) =>
widget?.widgetId === hostInput.widgetId ||
widget?.name === hostInput.name
? hostInput
: undefined,
subgraph: {
inputNode: {
slots: [{ name: 'promoted_ckpt', linkIds: [linkId] }]
},
getLink: (id: number) =>
id === linkId
? { resolve: () => ({ inputNode: interiorNode }) }
: null,
getNodeById: (id: string | number) =>
String(id) === String(interiorNode.id) ? interiorNode : null
},
_testExecutionId: '65'
})
const isAssetSupported = vi.fn(() => false)
const graph = makeGraph([hostNode, interiorNode])
const result = scanAllModelCandidates(
graph,
isAssetSupported,
() => 'checkpoints'
)
expect(result).toHaveLength(1)
expect(result[0].nodeId).toBe('65')
expect(result[0].nodeType).toBe('CheckpointLoaderSimple')
expect(result[0].widgetName).toBe('promoted_ckpt')
expect(result[0].name).toBe('missing_model.safetensors')
expect(result[0].isMissing).toBe(true)
expect(result[0].url).toBe('https://example.com/missing_model')
expect(isAssetSupported).toHaveBeenCalledWith(
'CheckpointLoaderSimple',
'ckpt_name'
)
})
it('skips promoted subgraph widgets whose concrete source node is inactive', () => {
const linkId = 12
const sourceWidget = makeComboWidget('ckpt_name', 'stale_model.safetensors')
const sourceInput = fromAny({ name: 'ckpt_name', link: linkId })
const interiorNode = makeNode(
42,
'CheckpointLoaderSimple',
[sourceWidget],
'65:42'
)
interiorNode.mode = 4
Object.assign(interiorNode, {
inputs: [sourceInput],
isSubgraphNode: () => false,
getSlotFromWidget: (widget: IBaseWidget | undefined) =>
widget?.name === sourceWidget.name ? sourceInput : undefined,
getWidgetFromSlot: () => sourceWidget
})
const hostWidgetId = widgetId('graph', 65, 'promoted_ckpt')
useWidgetValueStore().registerWidget(hostWidgetId, {
type: 'combo',
value: 'missing_model.safetensors',
options: {},
label: 'Promoted checkpoint'
})
const hostInput = fromAny<INodeInputSlot, unknown>({
name: 'promoted_ckpt',
link: null,
widget: { name: 'promoted_ckpt' },
widgetId: hostWidgetId
})
const hostNode = fromAny<LGraphNode, unknown>({
id: 65,
type: 'abc-def-uuid',
inputs: [hostInput],
isSubgraphNode: () => true,
getSlotFromWidget: (widget: IBaseWidget | undefined) =>
widget?.widgetId === hostInput.widgetId ||
widget?.name === hostInput.name
? hostInput
: undefined,
subgraph: {
inputNode: {
slots: [{ name: 'promoted_ckpt', linkIds: [linkId] }]
},
getLink: (id: number) =>
id === linkId
? { resolve: () => ({ inputNode: interiorNode }) }
: null,
getNodeById: (id: string | number) =>
String(id) === String(interiorNode.id) ? interiorNode : null
},
_testExecutionId: '65'
})
const result = scanAllModelCandidates(
makeGraph([hostNode, interiorNode]),
noAssetSupport
)
expect(result).toHaveLength(0)
})
it('scans nested promoted subgraph widgets through the outer host identity', () => {
const outerLinkId = 12
const innerLinkId = 13
const sourceWidget = makeComboWidget(
'ckpt_name',
'stale_model.safetensors',
['existing_model.safetensors']
)
const sourceInput = fromAny<INodeInputSlot, unknown>({
name: 'ckpt_name',
link: innerLinkId
})
const leafNode = makeNode(
42,
'CheckpointLoaderSimple',
[sourceWidget],
'65:77:42'
)
Object.assign(leafNode, {
inputs: [sourceInput],
isSubgraphNode: () => false,
getSlotFromWidget: (widget: IBaseWidget | undefined) =>
widget?.name === sourceWidget.name ? sourceInput : undefined,
getWidgetFromSlot: (input: INodeInputSlot | undefined) =>
input === sourceInput ? sourceWidget : undefined,
properties: {
models: [
{
name: 'missing_model.safetensors',
url: 'https://example.com/nested_missing_model',
directory: 'checkpoints'
}
]
}
})
const innerWidgetId = widgetId('graph', 77, 'inner_ckpt')
const innerInput = fromAny<INodeInputSlot, unknown>({
name: 'inner_ckpt',
link: outerLinkId,
widget: { name: 'inner_ckpt' },
widgetId: innerWidgetId
})
const innerNode = fromAny<LGraphNode, unknown>({
id: 77,
type: 'inner-subgraph-uuid',
inputs: [innerInput],
isSubgraphNode: () => true,
getSlotFromWidget: (widget: IBaseWidget | undefined) =>
widget?.widgetId === innerInput.widgetId ||
widget?.name === innerInput.name
? innerInput
: undefined,
subgraph: {
inputNode: {
slots: [{ name: 'inner_ckpt', linkIds: [innerLinkId] }]
},
getLink: (id: number) =>
id === innerLinkId
? { resolve: () => ({ inputNode: leafNode }) }
: null,
getNodeById: (id: string | number) =>
String(id) === String(leafNode.id) ? leafNode : null
},
_testExecutionId: '65:77'
})
const outerWidgetId = widgetId('graph', 65, 'outer_ckpt')
useWidgetValueStore().registerWidget(outerWidgetId, {
type: 'combo',
value: 'missing_model.safetensors',
options: {},
label: 'Outer checkpoint'
})
const outerInput = fromAny<INodeInputSlot, unknown>({
name: 'outer_ckpt',
link: null,
widget: { name: 'outer_ckpt' },
widgetId: outerWidgetId
})
const outerNode = fromAny<LGraphNode, unknown>({
id: 65,
type: 'outer-subgraph-uuid',
inputs: [outerInput],
isSubgraphNode: () => true,
getSlotFromWidget: (widget: IBaseWidget | undefined) =>
widget?.widgetId === outerInput.widgetId ||
widget?.name === outerInput.name
? outerInput
: undefined,
subgraph: {
inputNode: {
slots: [{ name: 'outer_ckpt', linkIds: [outerLinkId] }]
},
getLink: (id: number) =>
id === outerLinkId
? { resolve: () => ({ inputNode: innerNode }) }
: null,
getNodeById: (id: string | number) =>
String(id) === String(innerNode.id) ? innerNode : null
},
_testExecutionId: '65'
})
const isAssetSupported = vi.fn(() => false)
const graph = makeGraph([outerNode, innerNode, leafNode])
const result = scanAllModelCandidates(
graph,
isAssetSupported,
() => 'checkpoints'
)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
nodeId: '65',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'outer_ckpt',
name: 'missing_model.safetensors',
isMissing: true,
url: 'https://example.com/nested_missing_model'
})
expect(isAssetSupported).toHaveBeenCalledWith(
'CheckpointLoaderSimple',
'ckpt_name'
)
})
it('skips nested promoted widgets when an intermediate subgraph is bypassed', () => {
const graph = makeNestedPromotedModelGraph({
innerMode: 4
})
const result = scanAllModelCandidates(graph, noAssetSupport)
expect(result).toHaveLength(0)
})
it('uses the host value with source options for resolved nested promoted widgets', () => {
const graph = makeNestedPromotedModelGraph({
hostValue: 'existing_model.safetensors',
sourceOptions: ['existing_model.safetensors'],
sourceValue: 'missing_model.safetensors'
})
const graph = makeGraph([containerNode, interiorNode])
const result = scanAllModelCandidates(graph, noAssetSupport)
expect(result).toHaveLength(1)
expect(result[0].nodeId).toBe('65:42')
expect(result[0].nodeType).toBe('CheckpointLoaderSimple')
expect(result[0]).toMatchObject({
nodeId: '65',
sourceExecutionId: '65:77:42',
widgetName: 'outer_ckpt',
name: 'existing_model.safetensors',
isMissing: false
})
})
it('uses the host-relative path when the source node is shared by another host instance', () => {
const graph = makeNestedPromotedModelGraph({
leafExecutionId: '3:77:42',
leafActiveExecutionIds: ['65:77:42']
})
const result = scanAllModelCandidates(graph, noAssetSupport)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
nodeId: '65',
sourceExecutionId: '65:77:42',
widgetName: 'outer_ckpt',
name: 'missing_model.safetensors',
isMissing: true
})
})
})

View File

@@ -6,6 +6,11 @@ import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
// eslint-disable-next-line import-x/no-restricted-paths
import { getSelectedModelsMetadata } from '@/workbench/utils/modelMetadataUtil'
import {
inputForWidget,
promotedInputWidgets
} from '@/core/graph/subgraph/promotedInputWidget'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type {
@@ -16,7 +21,8 @@ import type {
import type { NodeExecutionId } from '@/types/nodeIdentification'
import {
collectAllNodes,
getExecutionIdByNode
getExecutionIdByNode,
isExecutionPathActive
} from '@/utils/graphTraversalUtil'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { resolveComboValues } from '@/utils/litegraphUtil'
@@ -71,6 +77,23 @@ function isInactiveMode(mode: number | undefined): boolean {
return mode === LGraphEventMode.NEVER || mode === LGraphEventMode.BYPASS
}
interface ModelWidgetScanTarget {
executionId: NodeExecutionId
nodeType: string
candidateWidgetName: string
definitionWidgetName: string
sourceExecutionId?: NodeExecutionId
valueWidget: IBaseWidget
definitionWidget: IBaseWidget
embeddedModels?: ModelFile[]
}
type NodeWithEmbeddedModels = {
properties?: {
models?: ModelFile[]
}
}
// Full set of model file extensions used for scanning candidate widgets.
// Intentionally broader than ALLOWED_SUFFIXES in missingModelDownload.ts,
// which restricts which files are eligible for download.
@@ -108,10 +131,6 @@ export function scanAllModelCandidates(
const candidates: MissingModelCandidate[] = []
for (const node of allNodes) {
if (!node.widgets?.length) continue
// Skip subgraph container nodes: their promoted widgets are synthetic
// views of interior widgets, which are already scanned via recursion.
if (node.isSubgraphNode?.()) continue
if (isInactiveMode(node.mode)) continue
candidates.push(
@@ -134,32 +153,36 @@ export function scanNodeModelCandidates(
isAssetSupported: (nodeType: string, widgetName: string) => boolean,
getDirectory?: (nodeType: string) => string | undefined
): MissingModelCandidate[] {
if (!node.widgets?.length) return []
const widgets = node.isSubgraphNode?.()
? promotedInputWidgets(node)
: (node.widgets ?? [])
if (!widgets.length) return []
const executionId = getExecutionIdByNode(rootGraph, node)
if (!executionId) return []
const candidates: MissingModelCandidate[] = []
const embeddedModels = (node as { properties?: { models?: ModelFile[] } })
.properties?.models
for (const widget of node.widgets) {
for (const widget of widgets) {
const target = getModelWidgetScanTarget(
rootGraph,
node,
widget,
executionId
)
if (!target) continue
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 (isAssetScanTarget(target)) {
candidate = scanAssetWidget(target, getDirectory)
} else if (isComboScanTarget(target)) {
candidate = scanComboWidget(target, isAssetSupported, getDirectory)
}
if (candidate) {
candidates.push(
enrichCandidateFromNodeProperties(candidate, embeddedModels)
enrichCandidateFromNodeProperties(candidate, target.embeddedModels)
)
}
}
@@ -167,49 +190,115 @@ export function scanNodeModelCandidates(
return candidates
}
function getModelWidgetScanTarget(
rootGraph: LGraph,
node: LGraphNode,
widget: IBaseWidget,
executionId: NodeExecutionId
): ModelWidgetScanTarget | null {
const input = getInputForWidget(node, widget)
if (input?.link != null) return null
if (!node.isSubgraphNode?.()) {
return {
executionId,
nodeType: node.type,
candidateWidgetName: widget.name,
definitionWidgetName: widget.name,
valueWidget: widget,
definitionWidget: widget,
embeddedModels: getEmbeddedModels(node)
}
}
if (!input) return null
const source = resolvePromotedWidgetSource(rootGraph, node, widget)
const sourceExecutionId = source?.sourceExecutionId
if (!sourceExecutionId) return null
if (!isExecutionPathActive(rootGraph, sourceExecutionId)) return null
return {
executionId,
nodeType: source.sourceNode.type,
candidateWidgetName: widget.name,
definitionWidgetName: source.sourceWidgetName,
sourceExecutionId,
valueWidget: widget,
definitionWidget: source.sourceWidget,
embeddedModels: getEmbeddedModels(source.sourceNode)
}
}
function getInputForWidget(node: LGraphNode, widget: IBaseWidget) {
if (typeof node.getSlotFromWidget !== 'function') return undefined
return inputForWidget(node, widget)
}
function getEmbeddedModels(node: LGraphNode): ModelFile[] | undefined {
return (node as NodeWithEmbeddedModels).properties?.models
}
function isAssetScanTarget(
target: ModelWidgetScanTarget
): target is ModelWidgetScanTarget & { definitionWidget: IAssetWidget } {
return isAssetWidget(target.definitionWidget)
}
function isComboScanTarget(
target: ModelWidgetScanTarget
): target is ModelWidgetScanTarget & { definitionWidget: IComboWidget } {
return isComboWidget(target.definitionWidget)
}
function scanAssetWidget(
node: { type: string },
widget: IAssetWidget,
executionId: NodeExecutionId,
target: ModelWidgetScanTarget & { definitionWidget: IAssetWidget },
getDirectory: ((nodeType: string) => string | undefined) | undefined
): MissingModelCandidate | null {
const value = widget.value
const value = target.valueWidget.value
if (typeof value !== 'string' || !value.trim()) return null
if (!isModelFileName(value)) return null
return {
nodeId: executionId,
nodeType: node.type,
widgetName: widget.name,
nodeId: target.executionId,
...(target.sourceExecutionId && {
sourceExecutionId: target.sourceExecutionId
}),
nodeType: target.nodeType,
widgetName: target.candidateWidgetName,
isAssetSupported: true,
name: value,
directory: getDirectory?.(node.type),
directory: getDirectory?.(target.nodeType),
isMissing: undefined
}
}
function scanComboWidget(
node: { type: string },
widget: IComboWidget,
executionId: NodeExecutionId,
target: ModelWidgetScanTarget & { definitionWidget: IComboWidget },
isAssetSupported: (nodeType: string, widgetName: string) => boolean,
getDirectory: ((nodeType: string) => string | undefined) | undefined
): MissingModelCandidate | null {
const value = widget.value
const value = target.valueWidget.value
if (typeof value !== 'string' || !value.trim()) return null
if (!isModelFileName(value)) return null
const nodeIsAssetSupported = isAssetSupported(node.type, widget.name)
const options = resolveComboValues(widget)
const nodeIsAssetSupported = isAssetSupported(
target.nodeType,
target.definitionWidgetName
)
const options = resolveComboValues(target.definitionWidget)
const inOptions = options.includes(value)
return {
nodeId: executionId,
nodeType: node.type,
widgetName: widget.name,
nodeId: target.executionId,
...(target.sourceExecutionId && {
sourceExecutionId: target.sourceExecutionId
}),
nodeType: target.nodeType,
widgetName: target.candidateWidgetName,
isAssetSupported: nodeIsAssetSupported,
name: value,
directory: getDirectory?.(node.type),
directory: getDirectory?.(target.nodeType),
isMissing: nodeIsAssetSupported ? undefined : !inOptions
}
}

View File

@@ -1,7 +1,11 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import {
createNodeExecutionId,
createNodeLocatorId
} from '@/types/nodeIdentification'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
@@ -32,6 +36,7 @@ function makeModelCandidate(
name: string,
opts: {
nodeId?: string | number
sourceExecutionId?: NodeExecutionId
nodeType?: string
widgetName?: string
isAssetSupported?: boolean
@@ -40,6 +45,9 @@ function makeModelCandidate(
return {
name,
nodeId: opts.nodeId ?? '1',
...(opts.sourceExecutionId !== undefined && {
sourceExecutionId: opts.sourceExecutionId
}),
nodeType: opts.nodeType ?? 'CheckpointLoaderSimple',
widgetName: opts.widgetName ?? 'ckpt_name',
isAssetSupported: opts.isAssetSupported ?? false,
@@ -566,4 +574,36 @@ describe('missingModelStore', () => {
)
})
})
describe('removeMissingModelsBySourceScope', () => {
it('removes host-keyed candidates whose source path is in the scope', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('a.safetensors', {
nodeId: '65',
sourceExecutionId: createNodeExecutionId([65, 77, 42])
}),
makeModelCandidate('b.safetensors', {
nodeId: '80',
sourceExecutionId: createNodeExecutionId([80, 77, 42])
})
])
store.removeMissingModelsBySourceScope('65:77')
expect(store.missingModelCandidates).toHaveLength(1)
expect(store.missingModelCandidates![0].name).toBe('b.safetensors')
})
it('does not remove candidates by host nodeId alone', () => {
const store = useMissingModelStore()
store.setMissingModels([
makeModelCandidate('a.safetensors', { nodeId: '65' })
])
store.removeMissingModelsBySourceScope('65')
expect(store.missingModelCandidates).toHaveLength(1)
})
})
})

View File

@@ -181,6 +181,34 @@ export const useMissingModelStore = defineStore('missingModel', () => {
}
}
function removeMissingModelsBySourceScope(executionId: string) {
if (!missingModelCandidates.value) return
const prefix = `${executionId}:`
const removedNames = new Set<string>()
const remaining: MissingModelCandidate[] = []
for (const candidate of missingModelCandidates.value) {
const sourceExecutionId =
candidate.sourceExecutionId == null
? undefined
: String(candidate.sourceExecutionId)
if (
sourceExecutionId === executionId ||
sourceExecutionId?.startsWith(prefix)
) {
removedNames.add(candidate.name)
} else {
remaining.push(candidate)
}
}
if (removedNames.size === 0) return
missingModelCandidates.value = remaining.length ? remaining : null
for (const name of removedNames) {
if (!remaining.some((candidate) => candidate.name === name)) {
clearInteractionStateForName(name)
}
}
}
function addMissingModels(models: MissingModelCandidate[]) {
if (!models.length) return
const existing = missingModelCandidates.value ?? []
@@ -267,6 +295,7 @@ export const useMissingModelStore = defineStore('missingModel', () => {
removeMissingModelByWidget,
removeMissingModelsByNodeId,
removeMissingModelsByPrefix,
removeMissingModelsBySourceScope,
clearMissingModels,
refreshMissingModels,
createVerificationAbortController,

View File

@@ -1,4 +1,5 @@
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { NodeExecutionId } from '@/types/nodeIdentification'
/**
* A single (node, widget, model) binding detected by the missing model pipeline.
@@ -7,6 +8,7 @@ import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSche
export interface MissingModelCandidate {
/** Undefined for workflow-level models not tied to a specific node. */
nodeId?: NodeId
sourceExecutionId?: NodeExecutionId
nodeType: string
widgetName: string
isAssetSupported: boolean

View File

@@ -222,7 +222,7 @@ describe('hasWidgetError', () => {
).toBe(true)
})
it('matches missing models by the interior source widget name', () => {
it('matches missing models by the host widget name', () => {
const widget = createMockWidget({
name: 'display_slot',
sourceExecutionId: createNodeExecutionId([65, 18]),
@@ -240,7 +240,7 @@ describe('hasWidgetError', () => {
missingModelStore
)
).toBe(true)
expect(spy).toHaveBeenCalledWith('65:18', 'ckpt_name')
expect(spy).toHaveBeenCalledWith('1', 'display_slot')
})
})
@@ -597,6 +597,36 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
expect(state?.value).toBe(99)
})
it('clears promoted missing models through the host widget identity', () => {
const widget = createMockWidget({
name: 'display_slot',
nodeId: NODE_ID,
sourceExecutionId: createNodeExecutionId([65, 18]),
sourceWidgetName: 'ckpt_name'
})
const executionErrorStore = useExecutionErrorStore()
const clearSpy = vi.spyOn(executionErrorStore, 'clearWidgetRelatedErrors')
const [processed] = processWidgets([widget])
processed.updateHandler('real_model.safetensors')
expect(clearSpy).toHaveBeenCalledWith(
createNodeExecutionId([65, 18]),
'ckpt_name',
'ckpt_name',
'real_model.safetensors',
{ min: undefined, max: undefined }
)
expect(clearSpy).toHaveBeenCalledWith(
createNodeExecutionId([NODE_ID]),
'display_slot',
'display_slot',
'real_model.safetensors',
{ min: undefined, max: undefined }
)
})
it('clears execution errors on update', () => {
const widget = createMockWidget({
name: 'seed',

View File

@@ -90,13 +90,23 @@ function createWidgetUpdateHandler(
return (newValue: WidgetValue) => {
if (widgetState) widgetState.value = newValue
widget.callback?.(newValue)
const effectiveExecId = widget.sourceExecutionId ?? nodeExecId
const options = { min: widgetOptions?.min, max: widgetOptions?.max }
if (widget.sourceExecutionId) {
const sourceWidgetName = widget.sourceWidgetName ?? widget.name
executionErrorStore.clearWidgetRelatedErrors(
widget.sourceExecutionId,
sourceWidgetName,
sourceWidgetName,
newValue,
options
)
}
executionErrorStore.clearWidgetRelatedErrors(
effectiveExecId,
nodeExecId,
widget.name,
widget.name,
widget.sourceWidgetName ?? widget.name,
newValue,
{ min: widgetOptions?.min, max: widgetOptions?.max }
options
)
}
}
@@ -115,10 +125,7 @@ export function hasWidgetError(
: nodeErrors?.errors
return (
!!errors?.some((e) => e.extra_info?.input_name === widget.name) ||
missingModelStore.isWidgetMissingModel(
widget.sourceExecutionId ?? nodeExecId,
widget.sourceWidgetName ?? widget.name
)
missingModelStore.isWidgetMissingModel(nodeExecId, widget.name)
)
}

View File

@@ -155,9 +155,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
* Clears both validation errors and missing model state for a widget.
*
* @param errorInputName Name matched against `error.extra_info.input_name`.
* For promoted subgraph widgets this is the resolved interior widget name.
* @param widgetName The actual widget name, used for missing model lookup.
* At the legacy canvas call site both names are identical (`widget.name`).
* @param widgetName Widget name used for missing model/media lookup.
*/
function clearWidgetRelatedErrors(
executionId: NodeExecutionId,

View File

@@ -31,6 +31,8 @@ import {
getExecutionIdByNode,
getExecutionIdForNodeInGraph,
isAncestorPathActive,
isCandidateScopeActive,
isExecutionPathActive,
isMissingCandidateActive
} from '@/utils/graphTraversalUtil'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
@@ -795,6 +797,32 @@ describe('graphTraversalUtil', () => {
})
})
describe('isExecutionPathActive', () => {
it('returns false when the target node itself is bypassed', () => {
const node = createMockLGraphNode({
id: 42,
mode: LGraphEventMode.BYPASS
}) satisfies Partial<LGraphNode> as LGraphNode
const rootGraph = createMockGraph([node])
expect(isExecutionPathActive(rootGraph, '42')).toBe(false)
})
it('returns false when an ancestor container is bypassed', () => {
const interior = createMockNode('63')
const subgraph = createMockSubgraph('sub', [interior])
const container = createMockLGraphNode({
id: 65,
isSubgraphNode: () => true,
subgraph,
mode: LGraphEventMode.BYPASS
}) satisfies Partial<LGraphNode> as LGraphNode
const rootGraph = createMockGraph([container])
expect(isExecutionPathActive(rootGraph, '65:63')).toBe(false)
})
})
describe('isMissingCandidateActive', () => {
function makeBypassedContainer(interiorId: string) {
const interior = createMockNode(interiorId)
@@ -859,6 +887,35 @@ describe('graphTraversalUtil', () => {
})
).toBe(true)
})
it('uses sourceExecutionId for host-keyed promoted candidates', () => {
const rootGraph = makeBypassedContainer('63')
expect(
isMissingCandidateActive(rootGraph, {
nodeId: '65',
sourceExecutionId: '65:63',
isMissing: true
})
).toBe(false)
})
})
describe('isCandidateScopeActive', () => {
it('uses sourceExecutionId before nodeId', () => {
const rootNode = createMockNode('65')
const sourceNode = createMockLGraphNode({
id: 42,
mode: LGraphEventMode.BYPASS
}) satisfies Partial<LGraphNode> as LGraphNode
const rootGraph = createMockGraph([rootNode, sourceNode])
expect(
isCandidateScopeActive(rootGraph, {
nodeId: '65',
sourceExecutionId: '42'
})
).toBe(false)
})
})
describe('getExecutionIdFromNodeData', () => {

View File

@@ -395,6 +395,41 @@ export function isAncestorPathActive(
return true
}
export function isExecutionPathActive(
rootGraph: LGraph | null | undefined,
executionId: string
): boolean {
if (!rootGraph) return true
const node = getNodeByExecutionId(rootGraph, executionId)
if (!node) return false
if (
node.mode === LGraphEventMode.NEVER ||
node.mode === LGraphEventMode.BYPASS
) {
return false
}
return isAncestorPathActive(rootGraph, executionId)
}
function getCandidateActivityExecutionId(candidate: {
nodeId?: string | number | null | undefined
sourceExecutionId?: string | number | null | undefined
}): string | null {
const executionId = candidate.sourceExecutionId ?? candidate.nodeId
return executionId == null ? null : String(executionId)
}
export function isCandidateScopeActive(
rootGraph: LGraph | null | undefined,
candidate: {
nodeId?: string | number | null | undefined
sourceExecutionId?: string | number | null | undefined
}
): boolean {
const executionId = getCandidateActivityExecutionId(candidate)
return executionId == null || isExecutionPathActive(rootGraph, executionId)
}
/**
* Predicate used after async verification resolves: a missing-asset
* candidate is surfaceable when it is confirmed missing and its
@@ -407,12 +442,12 @@ export function isMissingCandidateActive(
rootGraph: LGraph | null | undefined,
candidate: {
nodeId?: string | number | null | undefined
sourceExecutionId?: string | number | null | undefined
isMissing?: boolean | undefined
}
): boolean {
if (candidate.isMissing !== true) return false
if (candidate.nodeId == null) return true
return isAncestorPathActive(rootGraph, String(candidate.nodeId))
return isCandidateScopeActive(rootGraph, candidate)
}
/**