mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-25 09:09:14 +00:00
Compare commits
11 Commits
codex/cove
...
jaeone/sub
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ce4bfc081 | ||
|
|
9066206c00 | ||
|
|
b731807184 | ||
|
|
49d5bea87f | ||
|
|
381550c14f | ||
|
|
4f3be82d8e | ||
|
|
65d4e0dd6e | ||
|
|
1b44c0a5f7 | ||
|
|
51a547e657 | ||
|
|
7515e907dc | ||
|
|
6b30bf801d |
@@ -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
|
||||
}
|
||||
136
browser_tests/fixtures/utils/objectInfo.ts
Normal file
136
browser_tests/fixtures/utils/objectInfo.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
424
browser_tests/fixtures/utils/promotedMissingModel.ts
Normal file
424
browser_tests/fixtures/utils/promotedMissingModel.ts
Normal 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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}) => {
|
||||
|
||||
173
src/components/rightSidePanel/parameters/SectionWidgets.test.ts
Normal file
173
src/components/rightSidePanel/parameters/SectionWidgets.test.ts
Normal 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 }
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
export interface ResolvedPromotedWidget {
|
||||
node: LGraphNode
|
||||
nodePath: string[]
|
||||
widget: IBaseWidget
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
67
src/core/graph/subgraph/resolvePromotedWidgetSource.ts
Normal file
67
src/core/graph/subgraph/resolvePromotedWidgetSource.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user