mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 01:07:56 +00:00
Compare commits
5 Commits
e2e/worksp
...
codex/e2e-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c874e699d8 | ||
|
|
1a98362984 | ||
|
|
ab33746b3e | ||
|
|
55c4e807a1 | ||
|
|
74147d7ee2 |
2
.github/workflows/ci-tests-e2e.yaml
vendored
2
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -93,7 +93,7 @@ jobs:
|
||||
# Run sharded tests (browsers pre-installed in container)
|
||||
- name: Run Playwright tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
id: playwright
|
||||
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
|
||||
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --workers=4 --reporter=blob
|
||||
env:
|
||||
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
|
||||
COLLECT_COVERAGE: 'true'
|
||||
|
||||
@@ -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,49 @@ 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('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -178,6 +178,40 @@ export function normalizeI18nKey(key: string) {
|
||||
return typeof key === 'string' ? key.replace(/\./g, '_') : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Characters that vue-i18n's message compiler treats as syntax in message text,
|
||||
* so plain text has to escape them to render verbatim through `t()`/`st()`:
|
||||
*
|
||||
* - `@` starts a linked-message reference (`@:key`); malformed usage throws
|
||||
* `Invalid linked format`.
|
||||
* - `{` / `}` delimit interpolation (`{name}`, `{'literal'}`); an unbalanced
|
||||
* brace throws `Unterminated/Unbalanced closing brace`.
|
||||
* - `|` separates plural branches, so `a | b` silently renders as one branch.
|
||||
* - `%` forms modulo interpolation when immediately followed by `{` (`%{name}`);
|
||||
* it must be escaped too, otherwise escaping a following `{` re-forms `%{`.
|
||||
*
|
||||
* The set is a build-inlined `const enum` (`TokenChars`) in
|
||||
* `@intlify/message-compiler` and is not exported, so it is hardcoded here.
|
||||
*
|
||||
* @see https://vue-i18n.intlify.dev/guide/essentials/syntax (Special Characters, Literal interpolation)
|
||||
* @see https://vue-i18n.intlify.dev/guide/essentials/pluralization
|
||||
*/
|
||||
const VUE_I18N_SYNTAX_CHARS = /[@{}|%]/g
|
||||
|
||||
/**
|
||||
* Escapes vue-i18n message-syntax characters as literal interpolations (`{'x'}`)
|
||||
* so arbitrary text renders verbatim instead of being parsed as syntax. This is
|
||||
* the only escape vue-i18n supports; see {@link VUE_I18N_SYNTAX_CHARS}.
|
||||
*
|
||||
* Only apply to values read through the compiler (`t()`/`st()`). Values read raw
|
||||
* via `tm()`/`stRaw()` (e.g. node tooltips) must be left untouched, or the
|
||||
* literal `{'x'}` would surface to users. Apply exactly once to raw text: the
|
||||
* escape output itself contains `{`/`}`, so it is not idempotent.
|
||||
*/
|
||||
export function escapeVueI18nMessageSyntax(text: string): string {
|
||||
return text.replace(VUE_I18N_SYNTAX_CHARS, (char) => `{'${char}'}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a dynamic prompt in the format {opt1|opt2|{optA|optB}|} and randomly replaces groups. Supports C style comments.
|
||||
* @param input The dynamic prompt to process
|
||||
|
||||
@@ -3,7 +3,10 @@ import * as fs from 'fs'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
|
||||
import { normalizeI18nKey } from '../packages/shared-frontend-utils/src/formatUtil'
|
||||
import {
|
||||
escapeVueI18nMessageSyntax,
|
||||
normalizeI18nKey
|
||||
} from '@/utils/formatUtil'
|
||||
import type { ComfyNodeDefImpl } from '../src/stores/nodeDefStore'
|
||||
|
||||
const localePath = './src/locales/en/main.json'
|
||||
@@ -44,8 +47,6 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
}
|
||||
)
|
||||
|
||||
console.log(`Collected ${nodeDefs.length} node definitions`)
|
||||
|
||||
const allDataTypesLocale = Object.fromEntries(
|
||||
nodeDefs
|
||||
.flatMap((nodeDef) => {
|
||||
@@ -60,7 +61,7 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
)
|
||||
return allDataTypes.map((dataType) => [
|
||||
normalizeI18nKey(dataType),
|
||||
dataType
|
||||
escapeVueI18nMessageSyntax(dataType)
|
||||
])
|
||||
})
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
@@ -98,7 +99,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
const runtimeWidgets = Object.fromEntries(
|
||||
Object.entries(widgetsMappings)
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([key, value]) => [normalizeI18nKey(key), { name: value }])
|
||||
.map(([key, value]) => [
|
||||
normalizeI18nKey(key),
|
||||
{ name: value ? escapeVueI18nMessageSyntax(value) : value }
|
||||
])
|
||||
)
|
||||
|
||||
if (Object.keys(runtimeWidgets).length > 0) {
|
||||
@@ -121,7 +125,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
function extractInputs(nodeDef: ComfyNodeDefImpl) {
|
||||
const inputs = Object.fromEntries(
|
||||
Object.values(nodeDef.inputs).flatMap((input) => {
|
||||
const name = input.name
|
||||
const name =
|
||||
input.name === undefined
|
||||
? undefined
|
||||
: escapeVueI18nMessageSyntax(input.name)
|
||||
const tooltip = input.tooltip
|
||||
|
||||
if (name === undefined && tooltip === undefined) {
|
||||
@@ -146,7 +153,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
const outputs = Object.fromEntries(
|
||||
nodeDef.outputs.flatMap((output, i) => {
|
||||
// Ignore data types if they are already translated in allDataTypesLocale.
|
||||
const name = output.name in allDataTypesLocale ? undefined : output.name
|
||||
const name =
|
||||
output.name === undefined || output.name in allDataTypesLocale
|
||||
? undefined
|
||||
: escapeVueI18nMessageSyntax(output.name)
|
||||
const tooltip = output.tooltip
|
||||
|
||||
if (name === undefined && tooltip === undefined) {
|
||||
@@ -179,8 +189,12 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
return [
|
||||
normalizeI18nKey(nodeDef.name),
|
||||
{
|
||||
display_name: nodeDef.display_name ?? nodeDef.name,
|
||||
description: nodeDef.description || undefined,
|
||||
display_name: escapeVueI18nMessageSyntax(
|
||||
nodeDef.display_name ?? nodeDef.name
|
||||
),
|
||||
description: nodeDef.description
|
||||
? escapeVueI18nMessageSyntax(nodeDef.description)
|
||||
: undefined,
|
||||
inputs: Object.keys(inputs).length > 0 ? inputs : undefined,
|
||||
outputs: extractOutputs(nodeDef)
|
||||
}
|
||||
@@ -192,7 +206,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
nodeDefs.flatMap((nodeDef) =>
|
||||
nodeDef.category
|
||||
.split('/')
|
||||
.map((category) => [normalizeI18nKey(category), category])
|
||||
.map((category) => [
|
||||
normalizeI18nKey(category),
|
||||
escapeVueI18nMessageSyntax(category)
|
||||
])
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -3,23 +3,42 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { GizmoManager } from './GizmoManager'
|
||||
|
||||
const { mockSetMode, mockAttach, mockDetach, mockGetHelper, mockDispose } =
|
||||
vi.hoisted(() => ({
|
||||
mockSetMode: vi.fn(),
|
||||
mockAttach: vi.fn(),
|
||||
mockDetach: vi.fn(),
|
||||
mockGetHelper: vi.fn(),
|
||||
mockDispose: vi.fn()
|
||||
}))
|
||||
const {
|
||||
mockSetMode,
|
||||
mockAttach,
|
||||
mockDetach,
|
||||
mockGetHelper,
|
||||
mockDispose,
|
||||
transformControlsInstances,
|
||||
omitGetPointer
|
||||
} = vi.hoisted(() => ({
|
||||
mockSetMode: vi.fn(),
|
||||
mockAttach: vi.fn(),
|
||||
mockDetach: vi.fn(),
|
||||
mockGetHelper: vi.fn(),
|
||||
mockDispose: vi.fn(),
|
||||
transformControlsInstances: [] as unknown[],
|
||||
omitGetPointer: { value: false }
|
||||
}))
|
||||
|
||||
vi.mock('three/examples/jsm/controls/TransformControls', () => {
|
||||
class TransformControls {
|
||||
enabled = true
|
||||
dragging = false
|
||||
camera: THREE.Camera
|
||||
_getPointer?: (event: PointerEvent) => {
|
||||
x: number
|
||||
y: number
|
||||
button: number
|
||||
}
|
||||
private listeners = new Map<string, ((e: unknown) => void)[]>()
|
||||
|
||||
constructor(camera: THREE.Camera) {
|
||||
this.camera = camera
|
||||
if (!omitGetPointer.value) {
|
||||
this._getPointer = (event) => ({ x: 0, y: 0, button: event.button })
|
||||
}
|
||||
transformControlsInstances.push(this)
|
||||
}
|
||||
|
||||
addEventListener(event: string, cb: (e: unknown) => void) {
|
||||
@@ -64,6 +83,8 @@ describe('GizmoManager', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
transformControlsInstances.length = 0
|
||||
omitGetPointer.value = false
|
||||
|
||||
scene = new THREE.Scene()
|
||||
interactionElement = document.createElement('div')
|
||||
@@ -89,6 +110,120 @@ describe('GizmoManager', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('setPointerNdcSource', () => {
|
||||
type PointerNdc = { x: number; y: number; button: number }
|
||||
function lastControls() {
|
||||
return transformControlsInstances.at(-1) as {
|
||||
dragging: boolean
|
||||
_getPointer?: (event: PointerEvent) => PointerNdc
|
||||
}
|
||||
}
|
||||
function getPointerOverride() {
|
||||
return lastControls()._getPointer
|
||||
}
|
||||
|
||||
it('routes TransformControls pointer NDC through the injected source', () => {
|
||||
manager.init()
|
||||
manager.setPointerNdcSource((clientX, clientY) => ({
|
||||
x: clientX / 100,
|
||||
y: clientY / 100,
|
||||
inside: true
|
||||
}))
|
||||
|
||||
const pointer = getPointerOverride()!({
|
||||
clientX: 50,
|
||||
clientY: -25,
|
||||
button: 2
|
||||
} as PointerEvent)
|
||||
|
||||
expect(pointer).toEqual({ x: 0.5, y: -0.25, button: 2 })
|
||||
})
|
||||
|
||||
it('maps unmappable points to an off-screen pointer', () => {
|
||||
manager.init()
|
||||
manager.setPointerNdcSource(() => null)
|
||||
|
||||
const pointer = getPointerOverride()!({
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
button: 0
|
||||
} as PointerEvent)
|
||||
|
||||
expect(pointer).toEqual({ x: 10, y: 10, button: 0 })
|
||||
})
|
||||
|
||||
it('maps points outside the viewport to an off-screen pointer while not dragging', () => {
|
||||
manager.init()
|
||||
manager.setPointerNdcSource(() => ({ x: -1.2, y: 0.4, inside: false }))
|
||||
|
||||
const pointer = getPointerOverride()!({
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
button: 0
|
||||
} as PointerEvent)
|
||||
|
||||
expect(pointer).toEqual({ x: 10, y: 10, button: 0 })
|
||||
})
|
||||
|
||||
it('keeps the unclamped NDC for points outside the viewport mid-drag', () => {
|
||||
manager.init()
|
||||
manager.setPointerNdcSource(() => ({ x: -1.2, y: 0.4, inside: false }))
|
||||
lastControls().dragging = true
|
||||
|
||||
const pointer = getPointerOverride()!({
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
button: -1
|
||||
} as PointerEvent)
|
||||
|
||||
expect(pointer).toEqual({ x: -1.2, y: 0.4, button: -1 })
|
||||
})
|
||||
|
||||
it('applies a source registered before init once init runs', () => {
|
||||
manager.setPointerNdcSource(() => ({ x: 0.5, y: 0.5, inside: true }))
|
||||
manager.init()
|
||||
|
||||
const pointer = getPointerOverride()!({
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
button: 1
|
||||
} as PointerEvent)
|
||||
|
||||
expect(pointer).toEqual({ x: 0.5, y: 0.5, button: 1 })
|
||||
})
|
||||
|
||||
it('delegates to the stock mapping until a source is registered', () => {
|
||||
manager.init()
|
||||
|
||||
const stock = getPointerOverride()!({
|
||||
clientX: 40,
|
||||
clientY: 60,
|
||||
button: 2
|
||||
} as PointerEvent)
|
||||
expect(stock).toEqual({ x: 0, y: 0, button: 2 })
|
||||
|
||||
manager.setPointerNdcSource(() => ({ x: 0.5, y: -0.25, inside: true }))
|
||||
|
||||
const mapped = getPointerOverride()!({
|
||||
clientX: 40,
|
||||
clientY: 60,
|
||||
button: 2
|
||||
} as PointerEvent)
|
||||
expect(mapped).toEqual({ x: 0.5, y: -0.25, button: 2 })
|
||||
})
|
||||
|
||||
it('warns and skips the override when _getPointer is missing at init', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
omitGetPointer.value = true
|
||||
manager.setPointerNdcSource(() => ({ x: 0.5, y: 0.5, inside: true }))
|
||||
|
||||
manager.init()
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('_getPointer'))
|
||||
expect(lastControls()._getPointer).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('init', () => {
|
||||
it('adds helper to scene with correct name and render order', () => {
|
||||
manager.init()
|
||||
|
||||
@@ -4,6 +4,9 @@ import { TransformControls } from 'three/examples/jsm/controls/TransformControls
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||
|
||||
import type { GizmoMode, Model3DTransform } from './interfaces'
|
||||
import type { PointerNdcSource } from './load3dViewport'
|
||||
|
||||
const OFF_SCREEN_POINTER_NDC = { x: 10, y: 10 }
|
||||
|
||||
export class GizmoManager {
|
||||
private transformControls: TransformControls | null = null
|
||||
@@ -18,6 +21,7 @@ export class GizmoManager {
|
||||
private interactionElement: HTMLElement
|
||||
private orbitControls: OrbitControls
|
||||
private onTransformChange?: () => void
|
||||
private getPointerNdc?: PointerNdcSource
|
||||
|
||||
constructor(
|
||||
scene: THREE.Scene,
|
||||
@@ -46,12 +50,45 @@ export class GizmoManager {
|
||||
}
|
||||
})
|
||||
|
||||
this.installPointerNdcOverride()
|
||||
|
||||
const helper = this.transformControls.getHelper()
|
||||
helper.name = 'GizmoTransformControls'
|
||||
helper.renderOrder = 999
|
||||
this.scene.add(helper)
|
||||
}
|
||||
|
||||
setPointerNdcSource(getPointerNdc: PointerNdcSource): void {
|
||||
this.getPointerNdc = getPointerNdc
|
||||
}
|
||||
|
||||
private installPointerNdcOverride(): void {
|
||||
if (!this.transformControls) return
|
||||
const transformControls = this.transformControls
|
||||
const controls = transformControls as unknown as {
|
||||
_getPointer?: (event: PointerEvent) => {
|
||||
x: number
|
||||
y: number
|
||||
button: number
|
||||
}
|
||||
}
|
||||
const original = controls._getPointer
|
||||
if (typeof original !== 'function') {
|
||||
console.warn(
|
||||
'TransformControls no longer exposes _getPointer; letterbox-aware gizmo pointer mapping is disabled.'
|
||||
)
|
||||
return
|
||||
}
|
||||
controls._getPointer = (event: PointerEvent) => {
|
||||
if (!this.getPointerNdc) return original.call(transformControls, event)
|
||||
const ndc = this.getPointerNdc(event.clientX, event.clientY)
|
||||
if (!ndc || (!ndc.inside && !transformControls.dragging)) {
|
||||
return { ...OFF_SCREEN_POINTER_NDC, button: event.button }
|
||||
}
|
||||
return { x: ndc.x, y: ndc.y, button: event.button }
|
||||
}
|
||||
}
|
||||
|
||||
setupForModel(model: THREE.Object3D): void {
|
||||
if (!this.transformControls) return
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import * as THREE from 'three'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { Load3dDeps } from '@/extensions/core/load3d/Load3d'
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import type {
|
||||
CameraState,
|
||||
GizmoMode
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import type { PointerNdcSource } from '@/extensions/core/load3d/load3dViewport'
|
||||
|
||||
const {
|
||||
cloneSkinnedMock,
|
||||
@@ -1260,4 +1262,102 @@ describe('Load3d', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('constructor wiring', () => {
|
||||
function makeConstructorDeps() {
|
||||
const container = document.createElement('div')
|
||||
const canvas = document.createElement('canvas')
|
||||
container.appendChild(canvas)
|
||||
|
||||
const view = {
|
||||
canvas,
|
||||
renderer: {
|
||||
setViewport: vi.fn(),
|
||||
setScissor: vi.fn(),
|
||||
setScissorTest: vi.fn(),
|
||||
setClearColor: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
render: vi.fn()
|
||||
},
|
||||
width: 800,
|
||||
height: 600,
|
||||
state: { clearColor: new THREE.Color(0x000000), clearAlpha: 0 },
|
||||
observeResize: vi.fn(),
|
||||
beginRender: vi.fn(),
|
||||
blit: vi.fn(),
|
||||
setSize: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}
|
||||
const gizmoManager = {
|
||||
setPointerNdcSource: vi.fn(),
|
||||
init: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}
|
||||
const deps = {
|
||||
view,
|
||||
eventManager: {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
emitEvent: vi.fn()
|
||||
},
|
||||
sceneManager: {
|
||||
init: vi.fn(),
|
||||
scene: new THREE.Scene(),
|
||||
renderBackground: vi.fn(),
|
||||
handleResize: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
},
|
||||
cameraManager: {
|
||||
init: vi.fn(),
|
||||
activeCamera: new THREE.PerspectiveCamera(),
|
||||
handleResize: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
},
|
||||
controlsManager: { init: vi.fn(), update: vi.fn(), dispose: vi.fn() },
|
||||
lightingManager: { init: vi.fn(), dispose: vi.fn() },
|
||||
viewHelperManager: {
|
||||
createViewHelper: vi.fn(),
|
||||
init: vi.fn(),
|
||||
update: vi.fn(),
|
||||
render: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
},
|
||||
hdriManager: { dispose: vi.fn() },
|
||||
loaderManager: { init: vi.fn(), dispose: vi.fn() },
|
||||
modelManager: { dispose: vi.fn() },
|
||||
recordingManager: {
|
||||
getIsRecording: vi.fn(() => false),
|
||||
dispose: vi.fn()
|
||||
},
|
||||
animationManager: {
|
||||
init: vi.fn(),
|
||||
update: vi.fn(),
|
||||
isAnimationPlaying: false,
|
||||
dispose: vi.fn()
|
||||
},
|
||||
gizmoManager,
|
||||
adapterRef: { current: null, capabilities: null }
|
||||
}
|
||||
return { container, deps: deps as unknown as Load3dDeps, gizmoManager }
|
||||
}
|
||||
|
||||
it('wires the gizmo pointer NDC source to clientPointToNdc on every construction path', () => {
|
||||
const { container, deps, gizmoManager } = makeConstructorDeps()
|
||||
const load3d = new Load3d(container, deps)
|
||||
|
||||
expect(gizmoManager.setPointerNdcSource).toHaveBeenCalledOnce()
|
||||
|
||||
const ndc = { x: 0.25, y: -0.5, inside: true }
|
||||
const clientPointToNdc = vi
|
||||
.spyOn(load3d, 'clientPointToNdc')
|
||||
.mockReturnValue(ndc)
|
||||
const source = gizmoManager.setPointerNdcSource.mock
|
||||
.calls[0][0] as PointerNdcSource
|
||||
|
||||
expect(source(12, 34)).toBe(ndc)
|
||||
expect(clientPointToNdc).toHaveBeenCalledWith(12, 34)
|
||||
|
||||
load3d.remove()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -83,6 +83,9 @@ class Load3d extends Viewport3d {
|
||||
|
||||
this.loaderManager.init()
|
||||
this.animationManager.init()
|
||||
this.gizmoManager.setPointerNdcSource((clientX, clientY) =>
|
||||
this.clientPointToNdc(clientX, clientY)
|
||||
)
|
||||
this.gizmoManager.init()
|
||||
|
||||
this.eventManager.addEventListener('modelReady', () => {
|
||||
|
||||
@@ -386,6 +386,67 @@ describe('Viewport3d', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('clientPointToNdc', () => {
|
||||
function installCanvas(rect: {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
}) {
|
||||
const canvas = document.createElement('canvas')
|
||||
vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({
|
||||
...rect,
|
||||
right: rect.left + rect.width,
|
||||
bottom: rect.top + rect.height,
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
toJSON: () => ({})
|
||||
} as DOMRect)
|
||||
Object.assign(ctx.viewport, { view: { canvas } })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(ctx.viewport, {
|
||||
targetWidth: 100,
|
||||
targetHeight: 100,
|
||||
targetAspectRatio: 1,
|
||||
isViewerMode: false
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes client coordinates against the canvas rect before letterbox mapping', () => {
|
||||
installCanvas({ left: 100, top: 50, width: 400, height: 200 })
|
||||
|
||||
expect(ctx.viewport.clientPointToNdc(300, 150)).toEqual({
|
||||
x: expect.closeTo(0),
|
||||
y: expect.closeTo(0),
|
||||
inside: true
|
||||
})
|
||||
expect(ctx.viewport.clientPointToNdc(150, 150)).toEqual({
|
||||
x: expect.closeTo(-1.5),
|
||||
y: expect.closeTo(0),
|
||||
inside: false
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when the canvas has no layout size', () => {
|
||||
installCanvas({ left: 0, top: 0, width: 0, height: 0 })
|
||||
|
||||
expect(ctx.viewport.clientPointToNdc(10, 10)).toBeNull()
|
||||
})
|
||||
|
||||
it('maps the full canvas when no aspect ratio is maintained', () => {
|
||||
installCanvas({ left: 100, top: 50, width: 400, height: 200 })
|
||||
Object.assign(ctx.viewport, { targetWidth: 0, targetHeight: 0 })
|
||||
|
||||
expect(ctx.viewport.clientPointToNdc(100, 50)).toEqual({
|
||||
x: expect.closeTo(-1),
|
||||
y: expect.closeTo(1),
|
||||
inside: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('start / remove lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
import type { RendererView } from '@/renderer/three/RendererView'
|
||||
import { normalize } from '@/utils/mathUtil'
|
||||
|
||||
import type { CameraManager } from './CameraManager'
|
||||
import type { ControlsManager } from './ControlsManager'
|
||||
@@ -17,7 +18,12 @@ import type {
|
||||
import { attachContextMenuGuard } from './load3dContextMenuGuard'
|
||||
import type { RenderLoopHandle } from './load3dRenderLoop'
|
||||
import { startRenderLoop } from './load3dRenderLoop'
|
||||
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
|
||||
import type { LetterboxNdc } from './load3dViewport'
|
||||
import {
|
||||
clientPointToLetterboxNdc,
|
||||
computeLetterboxedViewport,
|
||||
isLoad3dActive
|
||||
} from './load3dViewport'
|
||||
|
||||
const VIEW_HELPER_SIZE = 128
|
||||
|
||||
@@ -276,6 +282,17 @@ export class Viewport3d {
|
||||
this.renderer.render(this.sceneManager.scene, this.getRenderCamera())
|
||||
}
|
||||
|
||||
clientPointToNdc(clientX: number, clientY: number): LetterboxNdc | null {
|
||||
const rect = this.domElement.getBoundingClientRect()
|
||||
if (rect.width <= 0 || rect.height <= 0) return null
|
||||
return clientPointToLetterboxNdc(
|
||||
normalize(clientX, rect.left, rect.right),
|
||||
normalize(clientY, rect.top, rect.bottom),
|
||||
{ width: rect.width, height: rect.height },
|
||||
this.shouldMaintainAspectRatio() ? this.targetAspectRatio : null
|
||||
)
|
||||
}
|
||||
|
||||
protected startAnimation(): void {
|
||||
this.renderLoop = startRenderLoop({
|
||||
tick: () => {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
|
||||
import {
|
||||
clientPointToLetterboxNdc,
|
||||
computeLetterboxedViewport,
|
||||
isLoad3dActive
|
||||
} from './load3dViewport'
|
||||
import type { Load3dActivityFlags } from './load3dViewport'
|
||||
|
||||
describe('computeLetterboxedViewport', () => {
|
||||
@@ -106,3 +110,59 @@ describe('isLoad3dActive', () => {
|
||||
expect(isLoad3dActive({ ...idle, [flag]: true })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clientPointToLetterboxNdc', () => {
|
||||
function ndc(x: number, y: number, inside = true) {
|
||||
return { x: expect.closeTo(x), y: expect.closeTo(y), inside }
|
||||
}
|
||||
|
||||
it('maps the full canvas when no target aspect is set', () => {
|
||||
expect(
|
||||
clientPointToLetterboxNdc(0.5, 0.5, { width: 400, height: 300 }, null)
|
||||
).toEqual(ndc(0, 0))
|
||||
expect(
|
||||
clientPointToLetterboxNdc(0, 1, { width: 400, height: 300 }, null)
|
||||
).toEqual(ndc(-1, -1))
|
||||
})
|
||||
|
||||
it('maps pillarboxed content edges to -1/1', () => {
|
||||
const container = { width: 400, height: 200 }
|
||||
expect(clientPointToLetterboxNdc(0.25, 0.5, container, 1)).toEqual(
|
||||
ndc(-1, 0)
|
||||
)
|
||||
expect(clientPointToLetterboxNdc(0.75, 0, container, 1)).toEqual(ndc(1, 1))
|
||||
expect(clientPointToLetterboxNdc(0.5, 0.5, container, 1)).toEqual(ndc(0, 0))
|
||||
})
|
||||
|
||||
it('extrapolates unclamped NDC marked outside on the letterbox bars', () => {
|
||||
const container = { width: 400, height: 200 }
|
||||
expect(clientPointToLetterboxNdc(0.1, 0.5, container, 1)).toEqual(
|
||||
ndc(-1.6, 0, false)
|
||||
)
|
||||
expect(clientPointToLetterboxNdc(0.9, 0.5, container, 1)).toEqual(
|
||||
ndc(1.6, 0, false)
|
||||
)
|
||||
})
|
||||
|
||||
it('handles letterbox bars above/below wide content', () => {
|
||||
const container = { width: 200, height: 400 }
|
||||
expect(clientPointToLetterboxNdc(0.5, 0.375, container, 2)).toEqual(
|
||||
ndc(0, 1)
|
||||
)
|
||||
expect(clientPointToLetterboxNdc(0.5, 0.1, container, 2)).toEqual(
|
||||
ndc(0, 3.2, false)
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null instead of NaN for zero-size containers', () => {
|
||||
expect(
|
||||
clientPointToLetterboxNdc(0.5, 0.5, { width: 0, height: 0 }, 1)
|
||||
).toBeNull()
|
||||
expect(
|
||||
clientPointToLetterboxNdc(0.5, 0.5, { width: 0, height: 200 }, 1)
|
||||
).toBeNull()
|
||||
expect(
|
||||
clientPointToLetterboxNdc(0.5, 0.5, { width: 400, height: 0 }, 1)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { denormalize, normalize } from '@/utils/mathUtil'
|
||||
|
||||
type Size = { width: number; height: number }
|
||||
|
||||
type LetterboxedViewport = {
|
||||
@@ -34,6 +36,39 @@ export function computeLetterboxedViewport(
|
||||
}
|
||||
}
|
||||
|
||||
export type LetterboxNdc = { x: number; y: number; inside: boolean }
|
||||
|
||||
export type PointerNdcSource = (
|
||||
clientX: number,
|
||||
clientY: number
|
||||
) => LetterboxNdc | null
|
||||
|
||||
export function clientPointToLetterboxNdc(
|
||||
normalizedX: number,
|
||||
normalizedY: number,
|
||||
container: Size,
|
||||
targetAspectRatio: number | null
|
||||
): LetterboxNdc | null {
|
||||
const toNdc = (localX: number, localY: number): LetterboxNdc => ({
|
||||
x: denormalize(localX, -1, 1),
|
||||
y: -denormalize(localY, -1, 1),
|
||||
inside: localX >= 0 && localX <= 1 && localY >= 0 && localY <= 1
|
||||
})
|
||||
|
||||
if (targetAspectRatio === null) {
|
||||
return toNdc(normalizedX, normalizedY)
|
||||
}
|
||||
const { offsetX, offsetY, width, height } = computeLetterboxedViewport(
|
||||
container,
|
||||
targetAspectRatio
|
||||
)
|
||||
if (width <= 0 || height <= 0) return null
|
||||
return toNdc(
|
||||
normalize(normalizedX * container.width, offsetX, offsetX + width),
|
||||
normalize(normalizedY * container.height, offsetY, offsetY + height)
|
||||
)
|
||||
}
|
||||
|
||||
export type Load3dActivityFlags = {
|
||||
mouseOnNode: boolean
|
||||
mouseOnScene: boolean
|
||||
|
||||
80
src/i18n.safeTranslation.test.ts
Normal file
80
src/i18n.safeTranslation.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
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'
|
||||
)
|
||||
})
|
||||
})
|
||||
20
src/i18n.ts
20
src/i18n.ts
@@ -159,15 +159,28 @@ export const te: (typeof i18n.global)['te'] = i18n.global.te
|
||||
export const d: (typeof i18n.global)['d'] = i18n.global.d
|
||||
const tm = i18n.global.tm
|
||||
|
||||
function rawTranslationOrFallback(key: string, fallbackMessage: string) {
|
||||
const message = tm(key)
|
||||
return typeof message === 'string' ? message : fallbackMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe translation function that returns the fallback message if the key is not found.
|
||||
* Invalid message syntax falls back to the raw locale message instead of crashing.
|
||||
*
|
||||
* @param key - The key to translate.
|
||||
* @param fallbackMessage - The fallback message to use if the key is not found.
|
||||
*/
|
||||
export function st(key: string, fallbackMessage: string) {
|
||||
// The normal defaultMsg overload fails in some cases for custom nodes
|
||||
return te(key) ? t(key) : fallbackMessage
|
||||
if (!te(key)) return fallbackMessage
|
||||
|
||||
try {
|
||||
// The normal defaultMsg overload fails in some cases for custom nodes
|
||||
return t(key)
|
||||
} catch (error) {
|
||||
if (!(error instanceof SyntaxError)) throw error
|
||||
return rawTranslationOrFallback(key, fallbackMessage)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,6 +193,5 @@ export function st(key: string, fallbackMessage: string) {
|
||||
export function stRaw(key: string, fallbackMessage: string) {
|
||||
if (!te(key)) return fallbackMessage
|
||||
|
||||
const message = tm(key)
|
||||
return typeof message === 'string' ? message : fallbackMessage
|
||||
return rawTranslationOrFallback(key, fallbackMessage)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
35
src/locales/escapeNodeDefI18n.test.ts
Normal file
35
src/locales/escapeNodeDefI18n.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
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'
|
||||
])('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
|
||||
}
|
||||
}
|
||||
@@ -210,10 +210,15 @@ describe('formatShortMonthDay', () => {
|
||||
})
|
||||
|
||||
describe('formatClockTime', () => {
|
||||
it('formats time with hours, minutes, and seconds', () => {
|
||||
it('uses app locale with explicit 12-hour preference', () => {
|
||||
const ts = new Date(2024, 5, 15, 14, 5, 6).getTime()
|
||||
const result = formatClockTime(ts, 'en-GB')
|
||||
// en-GB uses 24-hour format
|
||||
expect(result).toBe('14:05:06')
|
||||
|
||||
expect(formatClockTime(ts, 'en-US', 'en-u-hc-h12')).toBe('2:05:06 PM')
|
||||
})
|
||||
|
||||
it('uses app locale with explicit 24-hour preference', () => {
|
||||
const ts = new Date(2024, 5, 15, 14, 5, 6).getTime()
|
||||
|
||||
expect(formatClockTime(ts, 'en-US', 'en-u-hc-h23')).toBe('14:05:06')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -84,17 +84,27 @@ export const formatShortMonthDay = (ts: number, locale: string): string => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Localized clock time, e.g. "10:05:06" with locale defaults for 12/24 hour.
|
||||
* Localized clock time, e.g. "10:05:06" with the app locale for language and
|
||||
* the browser/system locale preference for 12/24-hour formatting.
|
||||
*
|
||||
* @param ts Unix timestamp in milliseconds
|
||||
* @param locale BCP-47 locale string
|
||||
* @param clockPreferenceLocale Optional locale source for hour-cycle preference
|
||||
* @returns Localized time string
|
||||
*/
|
||||
export const formatClockTime = (ts: number, locale: string): string => {
|
||||
export const formatClockTime = (
|
||||
ts: number,
|
||||
locale: string,
|
||||
clockPreferenceLocale?: string
|
||||
): string => {
|
||||
const d = new Date(ts)
|
||||
const { hourCycle } = new Intl.DateTimeFormat(clockPreferenceLocale, {
|
||||
hour: 'numeric'
|
||||
}).resolvedOptions()
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
second: '2-digit',
|
||||
hourCycle
|
||||
}).format(d)
|
||||
}
|
||||
|
||||
@@ -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