mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 09:18:26 +00:00
Compare commits
2 Commits
glary/fix-
...
coderabbit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d38450d8bd | ||
|
|
74147d7ee2 |
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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="
|
||||
() => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
299
src/core/graph/subgraph/liftNodeErrorsToBoundary.test.ts
Normal file
299
src/core/graph/subgraph/liftNodeErrorsToBoundary.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
193
src/core/graph/subgraph/liftNodeErrorsToBoundary.ts
Normal file
193
src/core/graph/subgraph/liftNodeErrorsToBoundary.ts
Normal 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
|
||||
}
|
||||
94
src/i18n.safeTranslation.test.ts
Normal file
94
src/i18n.safeTranslation.test.ts
Normal 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
42
src/locales/escapeNodeDefI18n.test.ts
Normal file
42
src/locales/escapeNodeDefI18n.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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']))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
30
src/utils/__tests__/nodeErrorHelpers.ts
Normal file
30
src/utils/__tests__/nodeErrorHelpers.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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`).
|
||||
|
||||
Reference in New Issue
Block a user