Compare commits

..

2 Commits

Author SHA1 Message Date
coderabbitai[bot]
d38450d8bd CodeRabbit Generated Unit Tests: Add Generated Unit Tests for PR Changes 2026-07-14 16:56:33 +00:00
jaeone94
74147d7ee2 feat: lift boundary-exposed validation errors to the subgraph host (#13542)
## Summary

Backend validation errors (`node_errors`) are reported against the
**flattened** prompt, so an error whose real fix lives on a subgraph
host node gets attached to an interior node the user may never open —
and, after ADR 0009, often *cannot* meaningfully fix there. This PR
re-surfaces a validation error onto the subgraph host node **when — and
only when — the error's subject (the specific input/widget named by
`extra_info.input_name`) is exposed through the subgraph boundary**.

## Why this is needed

Two concrete situations motivated this, both observed in real workflows:

1. **Broken link at the host.** Root node A should feed subgraph host B,
whose boundary input is linked to interior node C. If the A→B link is
missing, the backend flattens the prompt, sees C with no resolved input,
and raises `required_input_missing` **on C** (`"B:C"`). The actual fix —
connect B's input — is one level up, on a node the error never points
at.
2. **Host-owned widget values.** Per ADR 0009 (subgraph promoted widgets
use linked inputs), a promoted widget's value is owned by the host
`SubgraphNode`; the interior widget only supplies schema/defaults. When
the backend raises `value_not_in_list` (or min/max violations) for that
value, attributing it to the interior node is factually wrong — the
value that failed validation *is the host's value*.

This continues the direction of #13059, which moved **missing-model**
detection identity to `{hostExecutionId, hostWidgetName}` with the
interior path kept as diagnostics. That was possible in the FE pre-scan;
this PR applies the same ownership principle to **backend-received**
errors via a receive-side mapping, since the backend cannot know about
subgraph boundaries in a flattened prompt.

## The rule (design)

> Lift an error from interior node N to host H **iff** N's input slot
named by the error is linked to the containing subgraph's boundary
(`SubgraphInput`). Apply the same test again at H (boundary-by-boundary,
matching ADR 0009's chaining principle) and stop at the first level
where the subject is no longer boundary-linked — that node is where the
user can actually fix it.

The predicate is **structural (boundary exposure), not data-flow**.

### In scope — examples

- `required_input_missing` on interior `"12:5"` whose input is fed by
the boundary → surfaces on host `12`'s input slot (red slot ring on the
host, errors-tab card titled/located at the host, message names the
host's `SubgraphInput.name`).
- `value_not_in_list` / `value_smaller_than_min` /
`value_bigger_than_max` on a promoted interior widget → surfaces on the
host's promoted widget. Nested hosts chain: `"1:2:3"` lifts to `"1:2"`,
and further to `"1"` only if `1:2`'s own slot is boundary-linked too.
- Clearing follows the surface: connecting the highlighted host input or
fixing the host widget clears the underlying interior (raw) error —
range-guarded per target, so a still-out-of-range host value does
**not** clear.

### Out of scope — examples

- **No value-flow ancestry.** All in the root graph: A's widget links to
B, B's to C, and C rejects the value that originated at A → the error
**stays on C**. Following same-graph links to a "root cause" node is
explicitly not this feature.
- Errors without an `input_name` subject, node-level types
(`exception_during_validation`, `dependency_cycle`, image-not-loaded),
and unknown validation types — never lifted. Unknown types stay
node-scoped to match how the error catalog renders them (the shared
`isNodeLevelValidationError` in `executionErrorUtil` encodes this, and
the catalog derives its node-level rules from the same set).
- Runtime execution errors (exceptions during a run) — validation
responses only.
- Interior errors whose input is fed by another interior node — fixable
in place, stay in place.
- Fan-out display dedupe: when one boundary input feeds multiple
interior nodes and the host slot is unconnected, each interior error
lifts to the same host slot as a separate panel line. A single fix
(connecting the host input) clears all of them — the clearing
translation already fans out — so the duplication is cosmetic.
Display-level dedupe is a follow-up; deduping inside the lift would
break the one-source-per-error clearing contract.
- Reactive re-lifting on graph topology edits while errors are displayed
(invariant documented on the computed; follow-up), and deriving the
error catalog's full validation rule table from the shared
classification (follow-up; the node-level type set and the
image-not-loaded predicate are already single-sourced in
`executionErrorUtil` and consumed by both the lift and the catalog).

## Changes

- **What**: New pure module
`core/graph/subgraph/liftNodeErrorsToBoundary.ts` — per-error, fail-open
record transform (unresolvable ids/slots/links leave the error where the
backend put it; raw payload is never mutated). `executionErrorStore`
derives `surfacedNodeErrors` from it and display consumers switch over
(errors tab grouping, canvas node/slot flags, Vue node badges,
app-mode/linear hints); raw `lastNodeErrors` remains the source of truth
for mutation. Host-side clearing translates through the lift's
diagnostics fields (`source_execution_id` / `source_input_name`) with a
per-target range guard.
- **Breaking**: None. No persistence/serialization changes; interior
identity survives as diagnostics metadata only (ADR 0009 language).

## Review Focus

- The lift predicate lives entirely on link topology
(`LLink.originIsIoNode` → `SubgraphInput`) — no
`proxyWidgets`/promotion-store style source authority is reintroduced.
- `clearSlotErrorsWithRangeCheck` now resolves clear targets first and
range-checks each target's raw errors; the lifted-path twin of the
existing range-retention test pins this.
- `useProcessedWidgets` deliberately stays on the raw record: host
promoted widgets already map to interior errors via
`widget.sourceExecutionId`, so an interior widget keeps its local red
hint when the user opens the subgraph (hint layer vs surface layer).
- Unit coverage is carried by the pure module (real litegraph subgraph
fixtures, incl. nested recursion, promoted widgets via
`promoteValueWidgetViaSubgraphInput`, ordering, fail-open/no-mutation);
one e2e pins the user-visible contract (host ring + host slot dot +
interior clean).

## Screenshots

### Before 


https://github.com/user-attachments/assets/81e5c4db-515d-4f1f-8f8a-e07ac490510f

### After



https://github.com/user-attachments/assets/2949da06-a049-41c1-a480-98ee28333bf2
2026-07-14 14:39:20 +00:00
23 changed files with 1528 additions and 229 deletions

View File

@@ -1,79 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
test.describe('Properties panel - Widget actions menu', { tag: '@ui' }, () => {
let panel: PropertiesPanelHelper
test.beforeEach(async ({ comfyPage }) => {
panel = new PropertiesPanelHelper(comfyPage.page)
await comfyPage.actionbar.propertiesButton.click()
await expect(panel.root).toBeVisible()
await comfyPage.nodeOps.selectNodes(['KSampler'])
})
test('menu opens when clicking the more button', async ({ comfyPage }) => {
const moreButton = panel.root
.getByTestId(TestIds.subgraphEditor.widgetActionsMenuButton)
.first()
await expect(moreButton).toBeVisible()
await moreButton.click()
const menu = comfyPage.page.getByTestId(TestIds.menu.moreMenuContent)
await expect(menu).toBeVisible()
await expect(menu.getByText('Rename')).toBeVisible()
})
test('menu items are left-aligned', async ({ comfyPage }) => {
const moreButton = panel.root
.getByTestId(TestIds.subgraphEditor.widgetActionsMenuButton)
.first()
await moreButton.click()
const menu = comfyPage.page.getByTestId(TestIds.menu.moreMenuContent)
await expect(menu).toBeVisible()
const menuButtons = menu.getByRole('button')
const count = await menuButtons.count()
expect(count).toBeGreaterThan(0)
for (let i = 0; i < count; i++) {
const button = menuButtons.nth(i)
await expect
.poll(() =>
button.evaluate((el) => {
const style = getComputedStyle(el)
return style.justifyContent
})
)
.toBe('flex-start')
}
})
test('menu shows Rename and Favorite actions', async ({ comfyPage }) => {
const moreButton = panel.root
.getByTestId(TestIds.subgraphEditor.widgetActionsMenuButton)
.first()
await moreButton.click()
const menu = comfyPage.page.getByTestId(TestIds.menu.moreMenuContent)
await expect(menu).toBeVisible()
await expect(menu.getByText('Rename')).toBeVisible()
await expect(menu.getByText('Favorite')).toBeVisible()
})
test('menu closes after clicking an action', async ({ comfyPage }) => {
const moreButton = panel.root
.getByTestId(TestIds.subgraphEditor.widgetActionsMenuButton)
.first()
await moreButton.click()
const menu = comfyPage.page.getByTestId(TestIds.menu.moreMenuContent)
await expect(menu).toBeVisible()
await menu.getByText('Favorite').click()
await expect(menu).toBeHidden()
})
})

View File

@@ -23,6 +23,7 @@ import { webSocketFixture } from '@e2e/fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
const ERROR_CLASS = /ring-destructive-background/
const SLOT_ERROR_CLASS = /before:ring-error/
const UNKNOWN_NODE_ID = '1'
const INNER_EXECUTION_ID = '2:1'
const KSAMPLER_MODEL_INPUT_NAME = 'model'
@@ -69,6 +70,25 @@ async function selectLoadImageNodeForPaste(
}, localLoadImageId)
}
async function getInputSlotIndexByName(
comfyPage: ComfyPage,
nodeId: string,
inputName: string
): Promise<number> {
return comfyPage.page.evaluate(
({ inputName, nodeId }) => {
const graph = window.app!.canvas.graph ?? window.app!.graph
const node = graph.getNodeById(nodeId)
const index = node?.findInputSlot(inputName) ?? -1
if (index < 0) {
throw new Error(`Input slot "${inputName}" not found`)
}
return index
},
{ inputName, nodeId: toNodeId(nodeId) }
)
}
async function setupLoadImageErrorScenario(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
const loadImageNode = (
@@ -139,17 +159,10 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
async ({ comfyPage }) => {
const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
const ksamplerNode = comfyPage.vueNodes.getNodeLocator(ksamplerId)
const modelInputIndex = await comfyPage.page.evaluate(
({ nodeId, inputName }) => {
const node = window.app!.graph.getNodeById(nodeId)
const index =
node?.inputs?.findIndex((input) => input.name === inputName) ?? -1
if (index < 0) {
throw new Error(`Input slot "${inputName}" not found`)
}
return index
},
{ nodeId: toNodeId(ksamplerId), inputName: KSAMPLER_MODEL_INPUT_NAME }
const modelInputIndex = await getInputSlotIndexByName(
comfyPage,
ksamplerId,
KSAMPLER_MODEL_INPUT_NAME
)
const modelInputSlotRow = comfyPage.vueNodes.getInputSlotRow(
ksamplerId,
@@ -175,7 +188,7 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
await expect(modelInputSlotRow).toBeVisible()
await expect(modelInputSlotRow).toBeInViewport()
await expect(modelInputSlotHighlight).toHaveClass(/before:ring-error/)
await expect(modelInputSlotHighlight).toHaveClass(SLOT_ERROR_CLASS)
await expect(
comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId)
).toHaveClass(ERROR_CLASS)
@@ -407,5 +420,76 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
})
test('boundary-linked validation error surfaces on the subgraph host', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphParentId =
await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
const innerWrapper =
comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId)
const hostInputIndex = await getInputSlotIndexByName(
comfyPage,
subgraphParentId,
'positive'
)
const hostInputSlotHighlight =
comfyPage.vueNodes.getInputSlotConnectionDot(
subgraphParentId,
hostInputIndex
)
await expect(
innerWrapper,
'subgraph host must mount before injecting validation errors'
).toBeVisible()
await expect(
innerWrapper,
'subgraph host should start without an error ring'
).not.toHaveClass(ERROR_CLASS)
await test.step('surface the boundary-linked error on the host', async () => {
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[INNER_EXECUTION_ID]: buildKSamplerError(
'required_input_missing',
'positive',
'Required input is missing: positive'
)
})
await comfyPage.runButton.click()
await dismissErrorOverlay(comfyPage)
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
await expect(hostInputSlotHighlight).toHaveClass(SLOT_ERROR_CLASS)
})
await test.step('confirm the interior node does not show the surfaced ring', async () => {
await comfyPage.vueNodes.enterSubgraph(subgraphParentId)
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
const interiorKSamplerId =
await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
const interiorPositiveInputIndex = await getInputSlotIndexByName(
comfyPage,
interiorKSamplerId,
'positive'
)
const interiorPositiveSlotHighlight =
comfyPage.vueNodes.getInputSlotConnectionDot(
interiorKSamplerId,
interiorPositiveInputIndex
)
const interiorInnerWrapper =
comfyPage.vueNodes.getNodeInnerWrapper(interiorKSamplerId)
await expect(interiorInnerWrapper).toBeVisible()
await expect(interiorInnerWrapper).not.toHaveClass(ERROR_CLASS)
await expect(interiorPositiveSlotHighlight).toBeVisible()
await expect(interiorPositiveSlotHighlight).not.toHaveClass(
SLOT_ERROR_CLASS
)
})
})
})
})

View File

@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import {
appendWorkflowJsonExt,
ensureWorkflowSuffix,
escapeVueI18nMessageSyntax,
formatLocalizedMediumDate,
formatLocalizedNumber,
getFilePathSeparatorVariants,
@@ -477,4 +478,76 @@ describe('formatUtil', () => {
expect(formatLocalizedMediumDate('not a date', 'en')).toBe('—')
})
})
describe('escapeVueI18nMessageSyntax', () => {
it('escapes a literal @ that would break linked-message compilation', () => {
expect(
escapeVueI18nMessageSyntax('clips (tagged @Audio1-3 in the prompt)')
).toBe("clips (tagged {'@'}Audio1-3 in the prompt)")
})
it('escapes @ in an email address', () => {
expect(escapeVueI18nMessageSyntax('support@comfy.org')).toBe(
"support{'@'}comfy.org"
)
})
it('escapes interpolation braces', () => {
expect(escapeVueI18nMessageSyntax('size {w}x{h}')).toBe(
"size {'{'}w{'}'}x{'{'}h{'}'}"
)
})
it('escapes the plural pipe separator', () => {
expect(escapeVueI18nMessageSyntax('foreground | background')).toBe(
"foreground {'|'} background"
)
})
it('escapes the modulo percent so it cannot re-form %{', () => {
expect(escapeVueI18nMessageSyntax('50%{done}')).toBe(
"50{'%'}{'{'}done{'}'}"
)
})
it('escapes every occurrence in a single pass', () => {
expect(escapeVueI18nMessageSyntax('@a @b @c')).toBe(
"{'@'}a {'@'}b {'@'}c"
)
})
it('leaves strings without syntax characters unchanged', () => {
expect(escapeVueI18nMessageSyntax('no special chars here')).toBe(
'no special chars here'
)
expect(escapeVueI18nMessageSyntax('')).toBe('')
})
it('escapes a mix of different syntax characters in one string', () => {
expect(escapeVueI18nMessageSyntax('@{a|b}%c')).toBe(
"{'@'}{'{'}a{'|'}b{'}'}{'%'}c"
)
})
it('escapes back-to-back occurrences of the same character', () => {
expect(escapeVueI18nMessageSyntax('@@')).toBe("{'@'}{'@'}")
})
it('does not escape characters that are not vue-i18n syntax', () => {
const text = 'price: $10 #tag & "quoted" \'single\''
expect(escapeVueI18nMessageSyntax(text)).toBe(text)
})
it('is not idempotent: re-escaping already-escaped output changes it further', () => {
const once = escapeVueI18nMessageSyntax('@')
const twice = escapeVueI18nMessageSyntax(once)
expect(once).toBe("{'@'}")
expect(twice).not.toBe(once)
})
it('does not leak regex match state across successive calls', () => {
expect(escapeVueI18nMessageSyntax('@first')).toBe("{'@'}first")
expect(escapeVueI18nMessageSyntax('@second')).toBe("{'@'}second")
})
})
})

View File

@@ -126,7 +126,7 @@ function nodeToNodeData(node: LGraphNode) {
return {
...nodeData,
hasErrors: !!executionErrorStore.lastNodeErrors?.[node.id],
hasErrors: !!executionErrorStore.surfacedNodeErrors?.[node.id],
dropIndicator,
onDragDrop: node.onDragDrop,
onDragOver: node.onDragOver

View File

@@ -5,9 +5,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MissingNodeType } from '@/types/comfy'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import type * as GraphTraversalUtil from '@/utils/graphTraversalUtil'
vi.mock('@/scripts/app', () => ({
app: {
isGraphReady: true,
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
@@ -127,6 +129,8 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers'
import { createBoundaryLinkedSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import {
getExecutionIdByNode,
getNodeByExecutionId
@@ -493,6 +497,47 @@ describe('useErrorGroups', () => {
)
})
it('groups lifted boundary errors under the host node card', async () => {
const { store, groups } = createErrorGroups()
const { rootGraph, host } = createBoundaryLinkedSubgraph({
interiorType: 'InteriorClass'
})
const { getNodeByExecutionId: actualGetNodeByExecutionId } =
await vi.importActual<typeof GraphTraversalUtil>(
'@/utils/graphTraversalUtil'
)
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) => {
return actualGetNodeByExecutionId(rootGraph, String(nodeId))
})
store.lastNodeErrors = {
'12:5': nodeError(
[
validationError(
'required_input_missing',
'seed_input',
{},
'Required input is missing'
)
],
'InteriorClass'
)
}
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution'
)
expect(execGroup?.type).toBe('execution')
if (execGroup?.type !== 'execution') return
const card = execGroup.cards[0]
expect(card.nodeId).toBe('12')
expect(card.title).toBe(host.title)
expect(card.errors[0].displayDetails).toBe(
`${host.title} is missing a required input: seed`
)
})
it('groups node validation errors by catalog id across node types', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {

View File

@@ -382,10 +382,10 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
groupsMap: Map<string, GroupEntry>,
filterBySelection = false
) {
if (!executionErrorStore.lastNodeErrors) return
if (!executionErrorStore.surfacedNodeErrors) return
for (const [rawNodeId, nodeError] of Object.entries(
executionErrorStore.lastNodeErrors
executionErrorStore.surfacedNodeErrors
)) {
const nodeId = tryNormalizeNodeExecutionId(rawNodeId)
if (!nodeId) continue

View File

@@ -130,7 +130,7 @@ function handleResetToDefault() {
<Button
variant="textonly"
size="unset"
class="flex w-full items-center justify-start gap-2 rounded-sm px-3 py-2 text-sm transition-all active:scale-95"
class="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-sm transition-all active:scale-95"
@click="
() => {
handleRename()
@@ -146,7 +146,7 @@ function handleResetToDefault() {
v-if="canToggleVisibility"
variant="textonly"
size="unset"
class="flex w-full items-center justify-start gap-2 rounded-sm px-3 py-2 text-sm transition-all active:scale-95"
class="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-sm transition-all active:scale-95"
@click="
() => {
if (isShownOnParents) handleHideInput()
@@ -168,7 +168,7 @@ function handleResetToDefault() {
<Button
variant="textonly"
size="unset"
class="flex w-full items-center justify-start gap-2 rounded-sm px-3 py-2 text-sm transition-all active:scale-95"
class="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-sm transition-all active:scale-95"
@click="
() => {
handleToggleFavorite()
@@ -190,7 +190,7 @@ function handleResetToDefault() {
v-if="hasDefault"
variant="textonly"
size="unset"
class="flex w-full items-center justify-start gap-2 rounded-sm px-3 py-2 text-sm transition-all active:scale-95"
class="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-sm transition-all active:scale-95"
:disabled="isCurrentValueDefault"
@click="
() => {

View File

@@ -84,7 +84,7 @@ function reconcileNodeErrorFlags(
}
export function useNodeErrorFlagSync(
lastNodeErrors: Ref<Record<string, NodeError> | null>,
nodeErrors: Ref<Record<string, NodeError> | null>,
missingModelStore: ReturnType<typeof useMissingModelStore>,
missingMediaStore: ReturnType<typeof useMissingMediaStore>
): () => void {
@@ -95,7 +95,7 @@ export function useNodeErrorFlagSync(
const stop = watch(
[
lastNodeErrors,
nodeErrors,
() => missingModelStore.missingModelNodeIds,
() => missingMediaStore.missingMediaNodeIds,
showErrorsTab
@@ -108,7 +108,7 @@ export function useNodeErrorFlagSync(
// Vue nodes compute hasAnyError independently and are unaffected.
reconcileNodeErrorFlags(
app.rootGraph,
lastNodeErrors.value,
nodeErrors.value,
showErrorsTab.value
? missingModelStore.missingModelAncestorExecutionIds
: new Set(),

View File

@@ -0,0 +1,299 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createBoundaryLinkedSubgraph,
createTestRootGraph,
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { toNodeId } from '@/types/nodeId'
import { liftNodeErrorsToBoundary } from './liftNodeErrorsToBoundary'
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('liftNodeErrorsToBoundary', () => {
it('lifts a boundary-linked slot error to the host', () => {
const { host, rootGraph } = createBoundaryLinkedSubgraph()
const errors = {
'12:5': nodeError([
validationError('required_input_missing', 'seed_input')
])
}
const result = liftNodeErrorsToBoundary(rootGraph, errors)
expect(result).toEqual({
'12': {
class_type: host.title,
dependent_outputs: [],
errors: [
expect.objectContaining({
type: 'required_input_missing',
extra_info: expect.objectContaining({
input_name: 'seed',
source_execution_id: '12:5',
source_input_name: 'seed_input'
})
})
]
}
})
})
it('lifts a promoted-widget value error to the host input', () => {
const rootGraph = createTestRootGraph()
const subgraph = createTestSubgraph({ rootGraph })
const host = createTestSubgraphNode(subgraph, { id: 12 })
rootGraph.add(host)
const interior = new LGraphNode('CheckpointLoaderSimple')
interior.id = toNodeId(5)
const input = interior.addInput('ckpt_name', 'COMBO')
const widget = interior.addWidget('combo', 'ckpt_name', '', () => {}, {
values: ['present.safetensors']
})
input.widget = { name: widget.name }
subgraph.add(interior)
expect(promoteValueWidgetViaSubgraphInput(host, interior, widget).ok).toBe(
true
)
const result = liftNodeErrorsToBoundary(rootGraph, {
'12:5': nodeError([
validationError('value_not_in_list', 'ckpt_name', {
received_value: 'missing.safetensors',
input_config: ['COMBO', { values: ['present.safetensors'] }]
})
])
})
expect(result['12'].errors[0].extra_info).toMatchObject({
input_name: 'ckpt_name',
source_execution_id: '12:5',
source_input_name: 'ckpt_name',
received_value: 'missing.safetensors',
input_config: ['COMBO', { values: ['present.safetensors'] }]
})
})
it('recurses through nested boundary-linked hosts', () => {
const rootGraph = createTestRootGraph()
const outerSubgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'seed', type: '*' }]
})
const outerHost = createTestSubgraphNode(outerSubgraph, { id: 1 })
outerHost.title = 'Outer Host'
rootGraph.add(outerHost)
const middleSubgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'seed', type: '*' }]
})
const middleHost = createTestSubgraphNode(middleSubgraph, {
id: 2,
parentGraph: outerSubgraph
})
outerSubgraph.add(middleHost)
outerSubgraph.inputNode.slots[0].connect(middleHost.inputs[0], middleHost)
const leaf = new LGraphNode('LeafNode')
leaf.id = toNodeId(3)
const leafInput = leaf.addInput('seed_input', '*')
middleSubgraph.add(leaf)
middleSubgraph.inputNode.slots[0].connect(leafInput, leaf)
const result = liftNodeErrorsToBoundary(rootGraph, {
'1:2:3': nodeError([
validationError('required_input_missing', 'seed_input')
])
})
expect(Object.keys(result)).toEqual(['1'])
expect(result['1'].class_type).toBe(outerHost.title)
expect(result['1'].errors[0].extra_info).toMatchObject({
input_name: 'seed',
source_execution_id: '1:2:3',
source_input_name: 'seed_input'
})
})
it('keeps errors on ordinary interior data-flow links', () => {
const rootGraph = createTestRootGraph()
const subgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'seed', type: '*' }]
})
const host = createTestSubgraphNode(subgraph, { id: 12 })
rootGraph.add(host)
const source = new LGraphNode('SourceNode')
source.id = toNodeId(4)
source.addOutput('seed', '*')
subgraph.add(source)
const target = new LGraphNode('TargetNode')
target.id = toNodeId(5)
target.addInput('seed_input', '*')
subgraph.add(target)
source.connect(0, target, 0)
const errors = {
'12:5': nodeError([
validationError('required_input_missing', 'seed_input')
])
}
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
})
it('keeps errors without a liftable subject on the interior node', () => {
const { rootGraph } = createBoundaryLinkedSubgraph()
const errors = {
'12:5': nodeError([
validationError('required_input_missing'),
validationError('exception_during_validation', 'seed_input'),
validationError('dependency_cycle', 'seed_input'),
validationError(
'custom_validation_failed',
'seed_input',
{ received_value: 'image.png' },
'Invalid image file'
)
])
}
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
})
it('keeps unknown typed validation errors on the interior node', () => {
const { rootGraph } = createBoundaryLinkedSubgraph()
const errors = {
'12:5': nodeError([
validationError('future_backend_validation_type', 'seed_input')
])
}
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
})
it('splits liftable and non-liftable errors from the same node entry', () => {
const { rootGraph } = createBoundaryLinkedSubgraph()
const result = liftNodeErrorsToBoundary(rootGraph, {
'12:5': nodeError([
validationError('required_input_missing', 'seed_input'),
validationError('exception_during_validation', 'seed_input')
])
})
expect(result['12'].errors).toHaveLength(1)
expect(result['12'].errors[0].type).toBe('required_input_missing')
expect(result['12:5'].errors).toHaveLength(1)
expect(result['12:5'].errors[0].type).toBe('exception_during_validation')
})
it('merges a lifted error into an existing host entry', () => {
const { rootGraph } = createBoundaryLinkedSubgraph()
const errors = {
'12': {
class_type: 'ExistingHostClass',
dependent_outputs: ['existing-output'],
errors: [validationError('value_smaller_than_min', 'other')]
},
'12:5': nodeError([
validationError('required_input_missing', 'seed_input')
])
}
const result = liftNodeErrorsToBoundary(rootGraph, errors)
expect(result['12']).toMatchObject({
class_type: 'ExistingHostClass',
dependent_outputs: ['existing-output']
})
expect(result['12'].errors.map((error) => error.type)).toEqual([
'value_smaller_than_min',
'required_input_missing'
])
})
it('keeps own errors before lifted errors for nested host keys', () => {
const rootGraph = createTestRootGraph()
const outerSubgraph = createTestSubgraph({ rootGraph })
const outerHost = createTestSubgraphNode(outerSubgraph, { id: 1 })
rootGraph.add(outerHost)
const middleSubgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'seed', type: '*' }]
})
const middleHost = createTestSubgraphNode(middleSubgraph, {
id: 2,
parentGraph: outerSubgraph
})
outerSubgraph.add(middleHost)
const leaf = new LGraphNode('LeafNode')
leaf.id = toNodeId(3)
const leafInput = leaf.addInput('seed_input', '*')
middleSubgraph.add(leaf)
middleSubgraph.inputNode.slots[0].connect(leafInput, leaf)
const result = liftNodeErrorsToBoundary(rootGraph, {
'1:2:3': nodeError([
validationError('required_input_missing', 'seed_input')
]),
'1:2': nodeError([validationError('value_smaller_than_min', 'seed')])
})
expect(result['1:2'].errors.map((error) => error.type)).toEqual([
'value_smaller_than_min',
'required_input_missing'
])
})
it('preserves empty error entries unchanged', () => {
const rootGraph = createTestRootGraph()
const errors = {
'12': nodeError([], 'ExtraRootNode')
}
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
})
it('fails open without mutating the input record', () => {
const rootGraph = createTestRootGraph()
const subgraph = createTestSubgraph({ rootGraph })
const host = createTestSubgraphNode(subgraph, { id: 12 })
rootGraph.add(host)
const interior = new LGraphNode('InteriorNode')
interior.id = toNodeId(5)
interior.addInput('unlinked', '*')
subgraph.add(interior)
const errors = {
'99:5': nodeError([validationError('required_input_missing', 'x')]),
'12:5': nodeError([
validationError('required_input_missing', 'missing'),
validationError('value_not_in_list', 'unlinked')
])
}
const original = structuredClone(errors)
const result = liftNodeErrorsToBoundary(rootGraph, errors)
expect(result).toEqual(original)
expect(errors).toEqual(original)
expect(result).not.toBe(errors)
})
})

View File

@@ -0,0 +1,193 @@
import { groupBy, partition } from 'es-toolkit'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import type { NodeError } from '@/schemas/apiSchema'
import { tryNormalizeNodeExecutionId } from '@/types/nodeIdentification'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { isNodeLevelValidationError } from '@/utils/executionErrorUtil'
import type { NodeValidationError } from '@/utils/executionErrorUtil'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { isSubgraph } from '@/utils/typeGuardUtil'
export interface LiftedErrorExtraInfo {
input_name: string
source_execution_id: string
source_input_name: string
}
export interface LiftedSurface {
hostExecId: NodeExecutionId
hostInputName: string
}
interface ErrorPlacement {
kind: 'own' | 'lifted'
targetExecId: string
error: NodeValidationError
}
export function getLiftedErrorSource(
error: NodeValidationError
): LiftedErrorExtraInfo | null {
const extraInfo = error.extra_info
if (!extraInfo) return null
const { input_name, source_execution_id, source_input_name } = extraInfo
if (
typeof input_name !== 'string' ||
typeof source_execution_id !== 'string' ||
typeof source_input_name !== 'string'
) {
return null
}
return { input_name, source_execution_id, source_input_name }
}
function getHostExecutionId(executionId: string): NodeExecutionId | null {
const separatorIndex = executionId.lastIndexOf(':')
if (separatorIndex <= 0) return null
return tryNormalizeNodeExecutionId(executionId.slice(0, separatorIndex))
}
/**
* Boundary surfaces that expose `(executionId, inputName)`, innermost first.
* Walks one host per level and stops at the last resolvable surface, so an
* unresolvable deeper host falls back to the shallower one (fail-open).
*/
export function resolveLiftChain(
rootGraph: LGraph,
executionId: string,
inputName: string
): LiftedSurface[] {
const chain: LiftedSurface[] = []
let currentExecId = executionId
let currentInputName = inputName
for (;;) {
const node = getNodeByExecutionId(rootGraph, currentExecId)
const graph = node?.graph
if (!node || !graph || !isSubgraph(graph)) break
const slot = node.inputs?.find((input) => input.name === currentInputName)
if (slot?.link == null) break
const subgraphInput = graph
.getLink(slot.link)
?.resolve(graph)?.subgraphInput
if (!subgraphInput) break
const hostExecId = getHostExecutionId(currentExecId)
if (!hostExecId || !getNodeByExecutionId(rootGraph, hostExecId)) break
chain.push({ hostExecId, hostInputName: subgraphInput.name })
currentExecId = hostExecId
currentInputName = subgraphInput.name
}
return chain
}
function createEmptyNodeError(nodeError: NodeError): NodeError {
return {
...nodeError,
errors: []
}
}
// Lifted host entries use the host title for display; SubgraphNode.type is a UUID.
function createLiftedHostEntry(
rootGraph: LGraph,
hostExecId: string
): NodeError {
return {
class_type:
getNodeByExecutionId(rootGraph, hostExecId)?.title ?? hostExecId,
dependent_outputs: [],
errors: []
}
}
function toErrorPlacement(
rootGraph: LGraph,
executionId: string,
error: NodeValidationError
): ErrorPlacement {
const inputName = error.extra_info?.input_name
const surface =
inputName && !isNodeLevelValidationError(error)
? resolveLiftChain(rootGraph, executionId, inputName).at(-1)
: undefined
if (!inputName || !surface) {
return {
kind: 'own',
targetExecId: executionId,
error
}
}
const liftedExtraInfo: LiftedErrorExtraInfo = {
input_name: surface.hostInputName,
source_execution_id: executionId,
source_input_name: inputName
}
return {
kind: 'lifted',
targetExecId: surface.hostExecId,
error: {
...error,
extra_info: {
...error.extra_info,
...liftedExtraInfo
}
}
}
}
export function liftNodeErrorsToBoundary(
rootGraph: LGraph,
nodeErrors: Record<string, NodeError>
): Record<string, NodeError> {
const output: Record<string, NodeError> = {}
const placements = Object.entries(nodeErrors).flatMap(
([executionId, nodeError]) =>
nodeError.errors.map((error) =>
toErrorPlacement(rootGraph, executionId, error)
)
)
for (const [executionId, nodeError] of Object.entries(nodeErrors)) {
if (nodeError.errors.length === 0) {
output[executionId] = createEmptyNodeError(nodeError)
}
}
const placementsByTarget = groupBy(
placements,
(placement) => placement.targetExecId
)
for (const [targetExecId, targetPlacements] of Object.entries(
placementsByTarget
)) {
const baseEntry = nodeErrors[targetExecId]
? createEmptyNodeError(nodeErrors[targetExecId])
: createLiftedHostEntry(rootGraph, targetExecId)
const [ownErrors, liftedErrors] = partition(
targetPlacements,
(placement) => placement.kind === 'own'
)
output[targetExecId] = {
...baseEntry,
errors: [...ownErrors, ...liftedErrors].map(
(placement) => placement.error
)
}
}
return output
}

View File

@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { i18n, st, stRaw } from './i18n'
const TEST_NAMESPACE = 'safeTranslationTest'
beforeEach(() => {
i18n.global.locale.value = 'en'
const messages = i18n.global.getLocaleMessage('en')
delete (messages as Record<string, unknown>)[TEST_NAMESPACE]
i18n.global.setLocaleMessage('en', messages)
})
describe('st', () => {
it('returns the fallback when the key is not found', () => {
expect(st('safeTranslationTest.missing', 'Fallback value')).toBe(
'Fallback value'
)
})
it('uses compiled translations for valid locale messages', () => {
i18n.global.mergeLocaleMessage('en', {
safeTranslationTest: {
valid: 'Translated value'
}
})
expect(st('safeTranslationTest.valid', 'Fallback value')).toBe(
'Translated value'
)
})
it('returns raw locale messages when vue-i18n compilation fails', () => {
const message = 'Provided by @acme/model with JSON such as {"mode":"fast"}'
i18n.global.mergeLocaleMessage('en', {
safeTranslationTest: {
invalidLinkedFormat: message
}
})
expect(
st('safeTranslationTest.invalidLinkedFormat', 'Fallback value')
).toBe(message)
})
})
describe('stRaw', () => {
it('returns raw locale messages for valid keys', () => {
i18n.global.mergeLocaleMessage('en', {
safeTranslationTest: {
rawValue: 'Raw value'
}
})
expect(stRaw('safeTranslationTest.rawValue', 'Fallback value')).toBe(
'Raw value'
)
})
it('returns raw messages containing vue-i18n syntax', () => {
const message = 'Provided by @acme/model with JSON such as {"mode":"fast"}'
i18n.global.mergeLocaleMessage('en', {
safeTranslationTest: {
rawSyntax: message
}
})
expect(stRaw('safeTranslationTest.rawSyntax', 'Fallback value')).toBe(
message
)
})
it('returns the fallback when the key is not found', () => {
expect(stRaw('safeTranslationTest.rawMissing', 'Fallback value')).toBe(
'Fallback value'
)
})
it('returns the fallback when the resolved locale message is not a string', () => {
i18n.global.mergeLocaleMessage('en', {
safeTranslationTest: {
nested: { child: 'value' }
}
})
// The key exists (te() is true), but tm() resolves to an object rather
// than a string, so rawTranslationOrFallback must fall back.
expect(stRaw('safeTranslationTest.nested', 'Fallback value')).toBe(
'Fallback value'
)
})
})

View File

@@ -18,6 +18,7 @@ import {
SUBGRAPH_OUTPUT_ID
} from '@/lib/litegraph/src/constants'
import type { SerializedNodeId } from '@/types/nodeId'
import { toNodeId } from '@/types/nodeId'
import {
LGraph,
LGraphNode,
@@ -86,6 +87,23 @@ interface TestSubgraphNodeOptions {
size?: [number, number]
}
interface BoundaryLinkedSubgraphOptions {
rootGraph?: LGraph
hostId?: SerializedNodeId
interiorId?: SerializedNodeId
boundaryName?: string
inputName?: string
hostTitle?: string
interiorType?: string
}
export interface BoundaryLinkedSubgraphFixture {
rootGraph: LGraph
subgraph: Subgraph
host: SubgraphNode
interior: LGraphNode
}
interface NestedSubgraphOptions {
depth?: number
nodesPerLevel?: number
@@ -269,6 +287,32 @@ export function createTestSubgraphNode(
return new SubgraphNode(parentGraph, subgraph, instanceData)
}
export function createBoundaryLinkedSubgraph({
rootGraph = createTestRootGraph(),
hostId = 12,
interiorId = 5,
boundaryName = 'seed',
inputName = 'seed_input',
hostTitle = 'Host Subgraph',
interiorType = 'InteriorNode'
}: BoundaryLinkedSubgraphOptions = {}): BoundaryLinkedSubgraphFixture {
const subgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: boundaryName, type: '*' }]
})
const host = createTestSubgraphNode(subgraph, { id: hostId })
host.title = hostTitle
rootGraph.add(host)
const interior = new LGraphNode(interiorType)
interior.id = toNodeId(interiorId)
const input = interior.addInput(inputName, '*')
subgraph.add(interior)
subgraph.inputNode.slots[0].connect(input, interior)
return { rootGraph, subgraph, host, interior }
}
export function setupComplexPromotionFixture(): {
graph: LGraph
subgraph: Subgraph

View File

@@ -0,0 +1,42 @@
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import { escapeVueI18nMessageSyntax } from '@comfyorg/shared-frontend-utils/formatUtil'
/**
* Node descriptions are compiled by vue-i18n via `t()`/`st()`, which parses
* `@ { } | %` as message syntax — a literal `@` even crashes the compiler with
* `Invalid linked format` (this broke the whole app after the 1.47.7 locale
* sync). `collect-i18n-node-defs.ts` escapes such values with
* `escapeVueI18nMessageSyntax` before writing them; this guards that the escaped
* output actually compiles and renders the original literal text.
*/
describe('escapeVueI18nMessageSyntax output is compiled safely by vue-i18n', () => {
const compile = (message: string) => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { value: message } }
})
return i18n.global.t('value')
}
it.for([
'clips (tagged @Audio1-3 in the prompt)',
'support@comfy.org',
'resolution {width}x{height}',
'foreground | background',
'50%{done}',
'all of @ { } | % together',
'no special chars here',
// Regression fixture for the actual 1.47.7 crash: a node description
// referencing a model repo alongside a JSON example.
'Provided by @acme/model with JSON such as {"mode":"fast"}',
// Node category paths (e.g. `nodeDef.category.split('/')`) are escaped
// per-segment; a segment itself may still contain syntax characters.
'image/@custom-pack',
''
])('renders %s as the original literal text', (raw) => {
expect(compile(escapeVueI18nMessageSyntax(raw))).toBe(raw)
})
})

View File

@@ -1,13 +1,10 @@
import type {
ExecutionErrorWsMessage,
NodeError,
PromptError
} from '@/schemas/apiSchema'
import type { ExecutionErrorWsMessage, PromptError } from '@/schemas/apiSchema'
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
import type { MissingModelGroup } from '@/platform/missingModel/types'
import type { MissingNodeType } from '@/types/comfy'
import type { NodeValidationError } from '@/utils/executionErrorUtil'
export type NodeValidationError = NodeError['errors'][number]
export type { NodeValidationError }
export interface ResolvedErrorMessage {
catalogId?: string

View File

@@ -11,6 +11,12 @@ import {
translateOptionalCatalogMessage
} from './catalogI18n'
import type { CatalogParams, ErrorResolveContext } from './catalogI18n'
import {
INPUT_LEVEL_VALIDATION_ERROR_TYPES,
NODE_LEVEL_VALIDATION_ERROR_TYPES,
getInputConfigBounds,
isImageNotLoadedValidationError
} from '@/utils/executionErrorUtil'
const REQUIRED_INPUT_MISSING_TYPE = 'required_input_missing'
@@ -62,51 +68,31 @@ const VALUE_SPECIFIC_COPY_RULES: Record<
}
}
const NODE_LEVEL_VALIDATION_ERROR_RULES: Record<string, ValidationCatalogRule> =
Object.fromEntries(
Array.from(NODE_LEVEL_VALIDATION_ERROR_TYPES, (type) => [
type,
{ catalogId: type, itemLabel: 'node' } satisfies ValidationCatalogRule
])
)
const INPUT_LEVEL_VALIDATION_ERROR_RULES: Record<
string,
ValidationCatalogRule
> = Object.fromEntries(
Array.from(INPUT_LEVEL_VALIDATION_ERROR_TYPES, (type) => [
type,
{ catalogId: type, itemLabel: 'nodeInput' } satisfies ValidationCatalogRule
])
)
const VALIDATION_ERROR_RULES: Record<string, ValidationCatalogRule> = {
...INPUT_LEVEL_VALIDATION_ERROR_RULES,
[REQUIRED_INPUT_MISSING_TYPE]: {
catalogId: MISSING_CONNECTION_CATALOG_ID,
itemLabel: 'nodeInput'
},
bad_linked_input: {
catalogId: 'bad_linked_input',
itemLabel: 'nodeInput'
},
return_type_mismatch: {
catalogId: 'return_type_mismatch',
itemLabel: 'nodeInput'
},
invalid_input_type: {
catalogId: 'invalid_input_type',
itemLabel: 'nodeInput'
},
value_smaller_than_min: {
catalogId: 'value_smaller_than_min',
itemLabel: 'nodeInput'
},
value_bigger_than_max: {
catalogId: 'value_bigger_than_max',
itemLabel: 'nodeInput'
},
value_not_in_list: {
catalogId: 'value_not_in_list',
itemLabel: 'nodeInput'
},
custom_validation_failed: {
catalogId: 'custom_validation_failed',
itemLabel: 'nodeInput'
},
exception_during_inner_validation: {
catalogId: 'exception_during_inner_validation',
itemLabel: 'nodeInput'
},
exception_during_validation: {
catalogId: 'exception_during_validation',
itemLabel: 'node'
},
dependency_cycle: {
catalogId: 'dependency_cycle',
itemLabel: 'node'
}
...NODE_LEVEL_VALIDATION_ERROR_RULES
}
// Image-not-loaded shares the custom_validation_failed type, so type-keyed
@@ -131,26 +117,6 @@ function getInputName(error: NodeValidationError): string {
)
}
function getErrorText(error: NodeValidationError) {
return [
'message' in error ? error.message : undefined,
'details' in error ? error.details : undefined
]
.filter(Boolean)
.join('\n')
}
function isImageNotLoadedText(text: string): boolean {
return /invalid image file|\[errno 21\].*is a directory/i.test(text)
}
function isImageNotLoadedValidationError(error: NodeValidationError): boolean {
return (
error.type === 'custom_validation_failed' &&
isImageNotLoadedText(getErrorText(error))
)
}
function nodeInputItemLabel(nodeName: string, inputName: string): string {
return `${nodeName} - ${inputName}`
}
@@ -179,13 +145,7 @@ function getInputConfigValue(
error: NodeValidationError,
key: 'min' | 'max'
): string | undefined {
const inputConfig = error.extra_info?.input_config
if (!Array.isArray(inputConfig)) return undefined
const config = inputConfig[1]
if (!config || typeof config !== 'object') return undefined
return formatCatalogValue((config as Record<string, unknown>)[key])
return formatCatalogValue(getInputConfigBounds(error)[key])
}
function getInputConfigType(error: NodeValidationError): string | undefined {

View File

@@ -35,13 +35,13 @@ const inputNodeIds = computed(() => {
})
const accessibleNodeErrors = computed(() =>
Object.keys(executionErrorStore.lastNodeErrors ?? {}).filter((k) =>
Object.keys(executionErrorStore.surfacedNodeErrors ?? {}).filter((k) =>
inputNodeIds.value.has(k)
)
)
const accessibleErrors = computed(() =>
accessibleNodeErrors.value.flatMap((k) => {
const nodeError = executionErrorStore.lastNodeErrors?.[k]
const nodeError = executionErrorStore.surfacedNodeErrors?.[k]
if (!nodeError) return []
return nodeError.errors.flatMap((error) => {

View File

@@ -252,6 +252,41 @@ describe('hasWidgetError', () => {
).toBe(true)
expect(spy).toHaveBeenCalledWith('1', 'display_slot')
})
it('matches raw interior errors by the source widget name for promoted widgets', () => {
const sourceExecutionId = createNodeExecutionId([
toNodeId(65),
toNodeId(18)
])
const widget = createMockWidget({
name: 'display_slot',
sourceExecutionId,
sourceWidgetName: 'ckpt_name'
})
executionErrorStore.lastNodeErrors = {
[sourceExecutionId]: {
errors: [
{
type: 'value_not_in_list',
message: 'Invalid model',
details: '',
extra_info: { input_name: 'ckpt_name' }
}
],
class_type: 'CheckpointLoaderSimple',
dependent_outputs: []
}
}
expect(
hasWidgetError(
widget,
createNodeExecutionId([toNodeId(1)]),
undefined,
executionErrorStore,
missingModelStore
)
).toBe(true)
})
})
const noopUi = {
@@ -667,6 +702,36 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
)
})
it('clears raw interior errors through widget.sourceExecutionId, which boundary lift relies on', () => {
const sourceExecutionId = createNodeExecutionId([65, 18])
const widget = createMockWidget({
name: 'display_slot',
nodeId: NODE_ID,
sourceExecutionId,
sourceWidgetName: 'ckpt_name'
})
const executionErrorStore = useExecutionErrorStore()
executionErrorStore.lastNodeErrors = {
[sourceExecutionId]: {
errors: [
{
type: 'value_not_in_list',
message: 'Invalid model',
details: '',
extra_info: { input_name: 'ckpt_name' }
}
],
class_type: 'CheckpointLoaderSimple',
dependent_outputs: []
}
}
const [processed] = processWidgets([widget])
processed.updateHandler('real_model.safetensors')
expect(executionErrorStore.lastNodeErrors).toBeNull()
})
it('clears execution errors on update', () => {
const widget = createMockWidget({
name: 'seed',

View File

@@ -130,8 +130,12 @@ export function hasWidgetError(
const errors = widget.sourceExecutionId
? executionErrorStore.lastNodeErrors?.[widget.sourceExecutionId]?.errors
: nodeErrors?.errors
// Raw interior errors name the source widget, not the boundary name
const errorInputName = widget.sourceExecutionId
? (widget.sourceWidgetName ?? widget.name)
: widget.name
return (
!!errors?.some((e) => e.extra_info?.input_name === widget.name) ||
!!errors?.some((e) => e.extra_info?.input_name === errorInputName) ||
missingModelStore.isWidgetMissingModel(nodeExecId, widget.name)
)
}

View File

@@ -1,9 +1,21 @@
import { fromAny } from '@total-typescript/shoehorn'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers'
import type { MissingNodeType } from '@/types/comfy'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import {
createBoundaryLinkedSubgraph,
createTestRootGraph,
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import {
createNodeExecutionId,
createNodeLocatorId
} from '@/types/nodeIdentification'
// Mock dependencies
vi.mock('@/i18n', () => ({
@@ -39,11 +51,20 @@ import { useExecutionErrorStore } from './executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { toNodeId } from '@/types/nodeId'
function mockGraphReady(rootGraph: typeof app.rootGraph) {
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(true)
}
describe('executionErrorStore — node error operations', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('clearSimpleNodeErrors', () => {
it('does nothing if lastNodeErrors is null', () => {
const store = useExecutionErrorStore()
@@ -296,6 +317,97 @@ describe('executionErrorStore — node error operations', () => {
// Error should remain
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
})
it('clears a lifted host slot error from the raw interior record', () => {
const { rootGraph } = createBoundaryLinkedSubgraph()
mockGraphReady(rootGraph)
const store = useExecutionErrorStore()
store.lastNodeErrors = {
'12:5': nodeError([
validationError('required_input_missing', 'seed_input')
])
}
expect(store.surfacedNodeErrors).toHaveProperty('12')
store.clearSimpleNodeErrors(createNodeExecutionId([toNodeId(12)]), 'seed')
expect(store.lastNodeErrors).toBeNull()
expect(store.surfacedNodeErrors).toBeNull()
})
it('does not clear lifted host slot errors when the raw error is not simple', () => {
const { rootGraph } = createBoundaryLinkedSubgraph()
mockGraphReady(rootGraph)
const store = useExecutionErrorStore()
store.lastNodeErrors = {
'12:5': nodeError([
validationError(
'custom_validation_failed',
'seed_input',
{},
'Custom validation failed'
)
])
}
expect(store.surfacedNodeErrors).toHaveProperty('12')
store.clearSimpleNodeErrors(createNodeExecutionId([toNodeId(12)]), 'seed')
expect(store.lastNodeErrors).toHaveProperty('12:5')
expect(store.lastNodeErrors?.['12:5'].errors).toHaveLength(1)
})
it('clears a nested lifted error fixed at an intermediate host level', () => {
const rootGraph = createTestRootGraph()
const outerSubgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'seed', type: '*' }]
})
const outerHost = createTestSubgraphNode(outerSubgraph, { id: 1 })
rootGraph.add(outerHost)
const middleSubgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'seed', type: '*' }]
})
const middleHost = createTestSubgraphNode(middleSubgraph, {
id: 2,
parentGraph: outerSubgraph
})
outerSubgraph.add(middleHost)
outerSubgraph.inputNode.slots[0].connect(middleHost.inputs[0], middleHost)
const leaf = new LGraphNode('LeafNode')
leaf.id = toNodeId(3)
const leafInput = leaf.addInput('seed_input', '*')
middleSubgraph.add(leaf)
middleSubgraph.inputNode.slots[0].connect(leafInput, leaf)
mockGraphReady(rootGraph)
const store = useExecutionErrorStore()
store.lastNodeErrors = {
'1:2:3': nodeError([
validationError('required_input_missing', 'seed_input')
])
}
expect(store.surfacedNodeErrors).toHaveProperty('1')
store.clearSimpleNodeErrors(
createNodeExecutionId([toNodeId(1), toNodeId(2)]),
'seed'
)
expect(
store.lastNodeErrors,
'a fix at the intermediate host clears the raw interior error'
).toBeNull()
expect(store.surfacedNodeErrors).toBeNull()
})
})
describe('clearWidgetRelatedErrors', () => {
@@ -388,6 +500,137 @@ describe('executionErrorStore — node error operations', () => {
expect(store.lastNodeErrors).not.toBeNull()
expect(store.lastNodeErrors?.['123'].errors).toHaveLength(1)
})
it('validates the base target against live widget bounds, not recorded ones', () => {
const store = useExecutionErrorStore()
store.lastNodeErrors = {
'123': nodeError([
validationError('value_bigger_than_max', 'testWidget', {
input_config: ['INT', { max: 100 }]
})
])
}
store.clearWidgetRelatedErrors(
createNodeExecutionId([toNodeId(123)]),
'testWidget',
'testWidget',
150,
{ max: 200 }
)
expect(
store.lastNodeErrors,
'a value within the refreshed widget bounds clears despite stale recorded bounds'
).toBeNull()
})
it('does not clear lifted range errors until the host value is in range', () => {
const { rootGraph } = createBoundaryLinkedSubgraph()
mockGraphReady(rootGraph)
const store = useExecutionErrorStore()
store.lastNodeErrors = {
'12:5': nodeError([
validationError('value_bigger_than_max', 'seed_input', {}, 'Too high')
])
}
expect(store.surfacedNodeErrors).toHaveProperty('12')
store.clearWidgetRelatedErrors(
createNodeExecutionId([toNodeId(12)]),
'seed',
'seed',
200,
{ max: 100 }
)
expect(store.lastNodeErrors).toHaveProperty('12:5')
expect(store.lastNodeErrors?.['12:5'].errors).toHaveLength(1)
store.clearWidgetRelatedErrors(
createNodeExecutionId([toNodeId(12)]),
'seed',
'seed',
50,
{ max: 100 }
)
expect(store.lastNodeErrors).toBeNull()
})
it('clears fan-out lifted targets per their own recorded bounds', () => {
const { rootGraph, subgraph } = createBoundaryLinkedSubgraph()
const second = new LGraphNode('SecondInterior')
second.id = toNodeId(7)
const secondInput = second.addInput('other_input', '*')
subgraph.add(second)
subgraph.inputNode.slots[0].connect(secondInput, second)
mockGraphReady(rootGraph)
const store = useExecutionErrorStore()
store.lastNodeErrors = {
'12:5': nodeError([
validationError('value_bigger_than_max', 'seed_input', {
input_config: ['INT', { max: 100 }]
})
]),
'12:7': nodeError([
validationError('value_bigger_than_max', 'other_input', {
input_config: ['INT', { max: 50 }]
})
])
}
expect(store.surfacedNodeErrors?.['12'].errors).toHaveLength(2)
store.clearWidgetRelatedErrors(
createNodeExecutionId([toNodeId(12)]),
'seed',
'seed',
75,
{ max: 100 }
)
expect(
store.lastNodeErrors?.['12:5'],
'the target whose max=100 is satisfied by 75 clears'
).toBeUndefined()
expect(
store.lastNodeErrors?.['12:7'].errors,
'the target whose max=50 is still violated by 75 stays'
).toHaveLength(1)
})
})
describe('surfacedNodeErrors', () => {
it('derives boundary-lifted errors while preserving the raw record', () => {
const { rootGraph, host } = createBoundaryLinkedSubgraph()
mockGraphReady(rootGraph)
const store = useExecutionErrorStore()
store.lastNodeErrors = {
'12:5': nodeError([
validationError('required_input_missing', 'seed_input')
])
}
const hostLocatorId = createNodeLocatorId(null, toNodeId(12))
expect(store.lastNodeErrors).toHaveProperty('12:5')
expect(store.surfacedNodeErrors).toHaveProperty('12')
expect(
store.surfacedNodeErrors?.['12'].errors[0].extra_info
).toMatchObject({
input_name: 'seed',
source_execution_id: '12:5',
source_input_name: 'seed_input'
})
expect(store.getNodeErrors(hostLocatorId)?.class_type).toBe(host.title)
expect(store.allErrorExecutionIds).toEqual(['12'])
expect(store.activeGraphErrorNodeIds).toEqual(new Set(['12']))
})
})
})

View File

@@ -2,6 +2,11 @@ import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { useNodeErrorFlagSync } from '@/composables/graph/useNodeErrorFlagSync'
import {
getLiftedErrorSource,
liftNodeErrorsToBoundary,
resolveLiftChain
} from '@/core/graph/subgraph/liftNodeErrorsToBoundary'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
@@ -16,7 +21,10 @@ import type {
NodeError,
PromptError
} from '@/schemas/apiSchema'
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
import {
getAncestorExecutionIds,
tryNormalizeNodeExecutionId
} from '@/types/nodeIdentification'
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
import {
executionIdToNodeLocatorId,
@@ -25,10 +33,18 @@ import {
} from '@/utils/graphTraversalUtil'
import {
SIMPLE_ERROR_TYPES,
getInputConfigBounds,
isValueStillOutOfRange
} from '@/utils/executionErrorUtil'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
interface SlotNodeErrorClearTarget {
executionId: NodeExecutionId
slotName: string
/** Interior targets validate against the bounds recorded on their errors. */
useRecordedBounds?: boolean
}
/** Execution error state: node errors, runtime errors, prompt errors, and missing assets. */
export const useExecutionErrorStore = defineStore('executionError', () => {
const workflowStore = useWorkflowStore()
@@ -79,31 +95,27 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
lastPromptError.value = null
}
/**
* Removes a node's errors if they consist entirely of simple, auto-resolvable
* types. When `slotName` is provided, only errors for that slot are checked.
*/
function clearSimpleNodeErrors(
function clearSimpleNodeErrorsFromRecord(
nodeErrors: Record<string, NodeError>,
executionId: NodeExecutionId,
slotName?: string
): void {
if (!lastNodeErrors.value) return
const nodeError = lastNodeErrors.value[executionId]
if (!nodeError) return
): Record<string, NodeError> | null {
const nodeError = nodeErrors[executionId]
if (!nodeError) return null
const isSlotScoped = slotName !== undefined
const relevantErrors = isSlotScoped
? nodeError.errors.filter((e) => e.extra_info?.input_name === slotName)
: nodeError.errors
if (relevantErrors.length === 0) return
if (!relevantErrors.every((e) => SIMPLE_ERROR_TYPES.has(e.type))) return
if (relevantErrors.length === 0) return null
if (!relevantErrors.every((e) => SIMPLE_ERROR_TYPES.has(e.type))) {
return null
}
const updated = { ...lastNodeErrors.value }
const updated = { ...nodeErrors }
if (isSlotScoped) {
// Remove only the target slot's errors if they were all simple
const remainingErrors = nodeError.errors.filter(
(e) => e.extra_info?.input_name !== slotName
)
@@ -116,16 +128,150 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
}
}
} else {
// If no slot specified and all errors were simple, clear the whole node
delete updated[executionId]
}
return updated
}
/**
* Raw interior sources of lifted errors whose boundary chain passes through
* `(executionId, slotName)`, so a fix at any host level — final surface or
* intermediate — clears the error at its raw key.
*/
function getLiftedErrorSourceTargets(
executionId: NodeExecutionId,
slotName: string
): SlotNodeErrorClearTarget[] {
const surfaced = surfacedNodeErrors.value
if (!surfaced || !app.isGraphReady) return []
return Object.values(surfaced).flatMap((surface) =>
surface.errors.flatMap((error): SlotNodeErrorClearTarget[] => {
const source = getLiftedErrorSource(error)
if (!source) return []
const sourceExecutionId = tryNormalizeNodeExecutionId(
source.source_execution_id
)
if (!sourceExecutionId) return []
const clearsThisError = resolveLiftChain(
app.rootGraph,
sourceExecutionId,
source.source_input_name
).some(
(level) =>
level.hostExecId === executionId && level.hostInputName === slotName
)
return clearsThisError
? [
{
executionId: sourceExecutionId,
slotName: source.source_input_name,
useRecordedBounds: true
}
]
: []
})
)
}
/** Raw targets are keys into lastNodeErrors, not surfacedNodeErrors. */
function getRawClearTargets(
executionId: NodeExecutionId,
slotName: string
): SlotNodeErrorClearTarget[] {
return [
{ executionId, slotName },
...getLiftedErrorSourceTargets(executionId, slotName)
]
}
/**
* Bounds recorded on the error win only for interior lifted targets, where
* the caller's options describe the host widget rather than the interior
* input. The base target keeps the caller's live widget bounds, which stay
* authoritative when node definitions change after validation.
*/
function getTargetRangeOptions(
errors: NodeError['errors'],
fallback: { min?: number; max?: number }
): { min?: number; max?: number } {
for (const error of errors) {
const { min, max } = getInputConfigBounds(error)
if (min === undefined && max === undefined) continue
return {
min: typeof min === 'number' ? min : fallback.min,
max: typeof max === 'number' ? max : fallback.max
}
}
return fallback
}
function isTargetStillOutOfRange(
nodeErrors: Record<string, NodeError>,
target: SlotNodeErrorClearTarget,
value: number,
callerOptions: { min?: number; max?: number }
): boolean {
const nodeError = nodeErrors[target.executionId]
if (!nodeError) return false
const errors = nodeError.errors.filter(
(error) => error.extra_info?.input_name === target.slotName
)
const options = target.useRecordedBounds
? getTargetRangeOptions(errors, callerOptions)
: callerOptions
return isValueStillOutOfRange(value, errors, options)
}
function clearTargets(
targets: { executionId: NodeExecutionId; slotName?: string }[]
): void {
if (!lastNodeErrors.value) return
let updated = lastNodeErrors.value
for (const target of targets) {
updated =
clearSimpleNodeErrorsFromRecord(
updated,
target.executionId,
target.slotName
) ?? updated
}
if (updated === lastNodeErrors.value) return
lastNodeErrors.value = Object.keys(updated).length > 0 ? updated : null
}
/**
* Removes a node's errors if they consist entirely of simple, auto-resolvable
* types. When `slotName` is provided, only errors for that slot are checked
* and boundary-lifted errors are also cleared through their raw interior
* source. Node-scoped calls (no `slotName`) operate on the raw record key
* only.
*/
function clearSimpleNodeErrors(
executionId: NodeExecutionId,
slotName?: string
): void {
clearTargets(
slotName === undefined
? [{ executionId }]
: getRawClearTargets(executionId, slotName)
)
}
/**
* Attempts to clear an error for a given widget, but avoids clearing it if
* the error is a range violation and the new value is still out of bounds.
* The base target validates against the caller's live widget bounds; each
* interior lifted target validates against its own recorded bounds, so a
* boundary input fanning out to inputs with different constraints clears
* only the targets the new value satisfies.
*
* Note: `value_not_in_list` errors are optimistically cleared without
* list-membership validation because combo widgets constrain choices to
@@ -138,16 +284,24 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
newValue: unknown,
options?: { min?: number; max?: number }
): void {
if (typeof newValue === 'number' && lastNodeErrors.value) {
const nodeErrors = lastNodeErrors.value[executionId]
if (nodeErrors) {
const errs = nodeErrors.errors.filter(
(e) => e.extra_info?.input_name === widgetName
)
if (isValueStillOutOfRange(newValue, errs, options || {})) return
}
}
clearSimpleNodeErrors(executionId, widgetName)
const nodeErrors = lastNodeErrors.value
if (!nodeErrors) return
const targets = getRawClearTargets(executionId, widgetName)
const clearableTargets =
typeof newValue === 'number'
? targets.filter(
(target) =>
!isTargetStillOutOfRange(
nodeErrors,
target,
newValue,
options ?? {}
)
)
: targets
clearTargets(clearableTargets)
}
/**
@@ -224,6 +378,13 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
() => !!lastNodeErrors.value && Object.keys(lastNodeErrors.value).length > 0
)
// Re-lifts only when the record changes; topology is assumed stable while errors are displayed.
const surfacedNodeErrors = computed(() =>
lastNodeErrors.value && app.isGraphReady
? liftNodeErrorsToBoundary(app.rootGraph, lastNodeErrors.value)
: lastNodeErrors.value
)
const hasAnyError = computed(
() =>
hasExecutionError.value ||
@@ -236,8 +397,8 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
const allErrorExecutionIds = computed<string[]>(() => {
const ids: string[] = []
if (lastNodeErrors.value) {
ids.push(...Object.keys(lastNodeErrors.value))
if (surfacedNodeErrors.value) {
ids.push(...Object.keys(surfacedNodeErrors.value))
}
if (lastExecutionError.value) {
const nodeId = lastExecutionError.value.node_id
@@ -279,8 +440,8 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
// Fall back to rootGraph when currentGraph hasn't been initialized yet
const activeGraph = canvasStore.currentGraph ?? app.rootGraph
if (lastNodeErrors.value) {
for (const executionId of Object.keys(lastNodeErrors.value)) {
if (surfacedNodeErrors.value) {
for (const executionId of Object.keys(surfacedNodeErrors.value)) {
const graphNode = getNodeByExecutionId(app.rootGraph, executionId)
if (graphNode?.graph === activeGraph) {
ids.add(String(graphNode.id))
@@ -302,12 +463,12 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
/** Map of node errors indexed by locator ID. */
const nodeErrorsByLocatorId = computed<Record<NodeLocatorId, NodeError>>(
() => {
if (!lastNodeErrors.value) return {}
if (!surfacedNodeErrors.value) return {}
const map: Record<NodeLocatorId, NodeError> = {}
for (const [executionId, nodeError] of Object.entries(
lastNodeErrors.value
surfacedNodeErrors.value
)) {
const locatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
if (locatorId) {
@@ -361,7 +522,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
return errorAncestorExecutionIds.value.has(execId)
}
useNodeErrorFlagSync(lastNodeErrors, missingModelStore, missingMediaStore)
useNodeErrorFlagSync(surfacedNodeErrors, missingModelStore, missingMediaStore)
return {
// Raw state
@@ -380,6 +541,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
dismissErrorOverlay,
// Derived state
surfacedNodeErrors,
hasExecutionError,
hasPromptError,
hasNodeError,

View File

@@ -1,30 +1,18 @@
import type { NodeError } from '@/schemas/apiSchema'
import type { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers'
type ExecutionErrorStore = ReturnType<typeof useExecutionErrorStore>
function createRequiredInputMissingNodeError(inputName: string): NodeError {
return {
errors: [
{
type: 'required_input_missing',
message: 'Missing',
details: '',
extra_info: { input_name: inputName }
}
],
dependent_outputs: [],
class_type: 'TestNode'
}
}
export function seedRequiredInputMissingNodeError(
store: ExecutionErrorStore,
executionId: NodeExecutionId,
inputName: string
): void {
store.lastNodeErrors = {
[executionId]: createRequiredInputMissingNodeError(inputName)
[executionId]: nodeError(
[validationError('required_input_missing', inputName, {}, 'Missing', '')],
'TestNode'
)
}
}

View File

@@ -0,0 +1,30 @@
import type { NodeError } from '@/schemas/apiSchema'
import type { NodeValidationError } from '@/utils/executionErrorUtil'
export function validationError(
type: string,
inputName?: string,
extraInfo: Record<string, unknown> = {},
message = `${type} message`,
details = `${type} details`
): NodeValidationError {
return {
type,
message,
details,
...(inputName
? { extra_info: { ...extraInfo, input_name: inputName } }
: {})
}
}
export function nodeError(
errors: NodeValidationError[],
classType = 'InteriorNode'
): NodeError {
return {
class_type: classType,
dependent_outputs: [],
errors
}
}

View File

@@ -105,6 +105,61 @@ export const SIMPLE_ERROR_TYPES = new Set([
'required_input_missing'
])
export type NodeValidationError = NodeError['errors'][number]
export const INPUT_LEVEL_VALIDATION_ERROR_TYPES = new Set([
'required_input_missing',
'bad_linked_input',
'return_type_mismatch',
'invalid_input_type',
'value_smaller_than_min',
'value_bigger_than_max',
'value_not_in_list',
'custom_validation_failed',
'exception_during_inner_validation'
])
export const NODE_LEVEL_VALIDATION_ERROR_TYPES = new Set([
'exception_during_validation',
'dependency_cycle'
])
/** Decodes the `[type, { min, max, ... }]` tuple the backend attaches to range errors. */
export function getInputConfigBounds(error: NodeValidationError): {
min?: unknown
max?: unknown
} {
const config = error.extra_info?.input_config
if (!Array.isArray(config)) return {}
const bounds = config[1]
if (!bounds || typeof bounds !== 'object') return {}
const { min, max } = bounds as { min?: unknown; max?: unknown }
return { min, max }
}
export function isImageNotLoadedValidationError(
error: NodeValidationError
): boolean {
return (
error.type === 'custom_validation_failed' &&
/invalid image file|\[errno 21\].*is a directory/i.test(
[error.message, error.details].filter(Boolean).join('\n')
)
)
}
// Anything not input-level (including unknown types) is node-level.
export function isNodeLevelValidationError(
error: NodeValidationError
): boolean {
return (
!INPUT_LEVEL_VALIDATION_ERROR_TYPES.has(error.type) ||
isImageNotLoadedValidationError(error)
)
}
/**
* Returns true if `value` still violates a recorded range constraint.
* Pass errors already filtered to the target widget (by `input_name`).