Compare commits

...

5 Commits

Author SHA1 Message Date
huang47
c874e699d8 test: run chromium E2E with four workers 2026-07-14 14:02:34 -07:00
Christian Byrne
1a98362984 fix(i18n): escape vue-i18n message syntax in generated node defs + preserve raw messages on compile errors (#13631)
## Summary

Defense-in-depth fix for the `Invalid linked format` crash: escape
vue-i18n message-syntax characters at node-def generation so the locale
bundle is always valid, **and** harden the runtime so any compile error
(custom nodes, already-shipped bundles) degrades to the raw message
instead of crashing the app.

This folds in the runtime guard from #13603 (@xmarre) — see credit below
— so the two layers land together.

## Changes

**Layer 1 — generation-time escaping (source data)**
- Add `escapeVueI18nMessageSyntax()` to
`packages/shared-frontend-utils/src/formatUtil.ts`. It escapes every
character vue-i18n's message compiler treats as syntax in text — `@ { }
| %` (verified against `@intlify/message-compiler`'s `readText`
tokenizer): `@` and unbalanced `{`/`}` throw, `|` mis-renders as plural
branches, `%` matters immediately before `{`. Each becomes a literal
interpolation `{'x'}` (the only escape vue-i18n supports); the set isn't
exported by the library so it's hardcoded with source/doc references.
Single-pass, applied once.
- Apply it in `scripts/collect-i18n-node-defs.ts` to the fields
**compiled** via `t()`/`st()`: `display_name`, `description`,
input/output `name`, widget labels, `dataTypes`, `nodeCategories`.
Tooltip fields go through `tm()`/`stRaw()` (no compile step) and are
intentionally left unescaped.
- Regression test (`src/locales/escapeNodeDefI18n.test.ts`) compiles the
escaped output for all five characters through real vue-i18n and asserts
it round-trips verbatim.

**Layer 2 — runtime fallback (consumer), folded in from #13603 by
@xmarre**
- `st()` now wraps `t(key)` in try/catch: a vue-i18n `SyntaxError` falls
back to the raw locale message (`tm()`) instead of crashing bootstrap.
Shared `rawTranslationOrFallback` helper reused by `stRaw()`. Tests in
`src/i18n.safeTranslation.test.ts` (isolated per-test, and covering the
non-`SyntaxError` rethrow branch).

**Tooling**
- Lint the collection scripts instead of ignoring them: import the
shared helper via the `@/utils/formatUtil` tsconfig alias (fixes
`import-x/no-relative-packages`; verified it resolves under Playwright)
and drop a stray progress `console.log`. No lint config changes and no
rules relaxed — the scripts are now genuinely linted (they were
previously excluded).

- **Breaking**: none.

## Review Focus

- **Root cause**: values read via `t()`/`st()` are compiled by vue-i18n,
which parses `@ { } | %` as message syntax; a malformed `@` throws
`Invalid linked format`. `app.ts` translates every node description at
startup, so one bad string aborts router load. Surfaced by the 1.47.7
locale sync (#13246), whose `ByteDanceSeedAudio` description contains
`@Audio1-3`.
- **The two layers compose**: escaped output compiles cleanly, so the
runtime catch never fires for generated strings; the catch is the safety
net for strings that bypass generation (custom/third-party nodes via
`mergeCustomNodesI18n`, and locale bundles already shipped in
1.47.x/1.48.x).
- **Compiled vs. raw boundary**: escaping is applied only to
compiler-consumed fields; leaving tooltips raw matches the
`stRaw()`/`tm()` work in #12469.
- **Translation propagation**: lobe-i18n's config prompt already keeps
`{...}` placeholders intact, so the escaped English propagates to all 13
locales.
- **Follow-up (out of scope)**: `scripts/collect-i18n-general.ts`
(commands/settings/menus) shares the same generation-time hazard and
could reuse the same helper later; Layer 2 already covers it at runtime.

## Credit

The runtime fallback (Layer 2) is @xmarre's work from #13603, folded in
here with authorship preserved on the original commits. Closing #13603
in favor of this combined PR.

Fixes #13545
Fixes #13575

---------

Co-authored-by: Connor Byrne <c.byrne@comfy.org>
Co-authored-by: xmarre <54859656+xmarre@users.noreply.github.com>
2026-07-14 18:14:47 +00:00
Terry Jia
ab33746b3e fix(load3d): letterbox-aware pointer NDC for the transform gizmo (#13550)
## Summary
The viewport letterboxes the scene to the width/height target aspect,
but TransformControls maps pointers over the full canvas rect, so its
handle raycasts skew toward the center, increasingly wrong toward the
edges whenever the canvas aspect differs from the target aspect.

Adds clientPointToLetterboxNdc() (pure, unit-tested) + a
Viewport3d.clientPointToNdc() wrapper, and routes TransformControls'
pointer math through it via GizmoManager.setPointerNdcSource(). 
Points on the letterbox bars resolve to nothing instead of phantom
handles.

## Screenshots (if applicable)
before
<img width="813" height="703" alt="image"
src="https://github.com/user-attachments/assets/cf5714c3-6b60-4ced-84ce-039f49c7e999"
/>
<img width="673" height="696" alt="image"
src="https://github.com/user-attachments/assets/31f898d2-a820-4748-b960-4f3cc5752095"
/>

after
<img width="1391" height="981" alt="image"
src="https://github.com/user-attachments/assets/18890d89-ff50-4298-bac5-472e00c79e24"
/>
<img width="955" height="712" alt="image"
src="https://github.com/user-attachments/assets/255f3c7d-dbb5-40a8-8865-c023caa1384e"
/>
2026-07-14 13:39:03 -04:00
Benjamin Lu
55c4e807a1 fix: use system hour cycle for queue times (#12297)
## Summary

Use the selected app locale for queue timestamp language and punctuation
while honoring the browser/system 12-hour or 24-hour clock preference.

## Changes

- **What**: `formatClockTime` resolves the hour cycle from the
browser/runtime default locale, then applies it while formatting with
the app locale.
- **What**: Added behavioral unit coverage for the production
two-argument path and explicit 12-hour and 24-hour preference locales.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

Queue timestamps already use the browser's local timezone. This PR fixes
only the remaining clock-preference mismatch: app language stays in
control of the rendered time string, while the browser/system locale
controls 12-hour versus 24-hour display.

No Playwright regression test was added because browser/system
hour-cycle preference is environment-level Intl state, not deterministic
UI state that the E2E suite can set reliably. Unit tests pin both hour
cycles through BCP-47 locale preferences and assert the rendered
behavior directly.

Fixes
[FE-252](https://linear.app/comfyorg/issue/FE-252/bug-queue-progress-times-ignore-browsersystem-1224-hour-preference)

## Screenshots (if applicable)

Not applicable.

---------

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-07-14 16:58:25 +00:00
jaeone94
74147d7ee2 feat: lift boundary-exposed validation errors to the subgraph host (#13542)
## Summary

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

## Why this is needed

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

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

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

## The rule (design)

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

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

### In scope — examples

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

### Out of scope — examples

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

## Changes

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

## Review Focus

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

## Screenshots

### Before 


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

### After



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

View File

@@ -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'

View File

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

View File

@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import {
appendWorkflowJsonExt,
ensureWorkflowSuffix,
escapeVueI18nMessageSyntax,
formatLocalizedMediumDate,
formatLocalizedNumber,
getFilePathSeparatorVariants,
@@ -477,4 +478,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('')
})
})
})

View File

@@ -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

View File

@@ -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)
])
)
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()

View File

@@ -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

View File

@@ -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()
})
})
})

View File

@@ -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', () => {

View File

@@ -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()

View File

@@ -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: () => {

View File

@@ -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()
})
})

View File

@@ -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

View 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'
)
})
})

View File

@@ -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)
}

View File

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

View File

@@ -0,0 +1,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)
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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')
})
})

View File

@@ -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)
}

View File

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