Compare commits

..

11 Commits

Author SHA1 Message Date
bymyself
a4ddb3f0bf fix(e2e): move openSwitcherPanel to WorkspaceAuthHelper
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/12940#discussion_r3445172630
2026-07-13 19:17:55 -07:00
bymyself
b15e12778e fix(e2e): extract workspace mock data to fixtures
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/12940#discussion_r3445170462
2026-07-13 19:17:55 -07:00
GitHub Action
2e0de78bc2 [automated] Apply ESLint and Oxfmt fixes 2026-07-13 19:17:55 -07:00
bymyself
6f55a45e71 fix(e2e): drop two-switch UI test — teamWorkspaceStore.switchWorkspace reloads
teamWorkspaceStore.switchWorkspace() calls window.location.reload() after
clearing context. The second switch in the back-and-forth test never lands
because the page reloads and re-runs init with the fixture-default token mock.
Single-switch token persistence is already covered by the first test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 19:17:55 -07:00
bymyself
ddb3e82f80 fix(e2e): scope workspace clicks to switcher panel to prevent ambiguous matches
getByText() without scoping could match the trigger row label or other
text on the page. Scope all workspace name clicks to
getByTestId('workspace-switcher-panel') to ensure only the list item
button is targeted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 19:17:55 -07:00
bymyself
e672f81497 fix(e2e): fix panel sequencing and ACCESS_DENIED timing race
- Add Escape before re-opening profile popover to avoid toggle-close on
  double-click, and wait for workspace-switcher-panel to be visible before
  clicking an item (prevents stale-panel click timeouts)
- Remove intermediate 'original-token' assertion in ACCESS_DENIED test:
  the refresh timer fires at delay≈0 so the token is cleared before the
  poll can observe it — wait for callCount=2 instead then assert null

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 19:17:55 -07:00
bymyself
dcf8461d43 fix(e2e): account for init auto-switch and use Team Workspace for explicit switches
teamWorkspaceStore.initialize() auto-selects Personal Workspace on boot and
calls switchWorkspace → /api/auth/token. useWorkspaceSwitch skips switchWorkspace
when the target is already active, so clicking Personal Workspace in the panel
after init is a no-op.

Fix:
- Add default /api/auth/token stub in the fixture to absorb the init call
- Switch to Team Workspace in tests (not the auto-selected Personal one) so
  the explicit click is always a genuine workspace change
- Per-test route mocks are matched LIFO and override the fixture default

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 19:17:55 -07:00
GitHub Action
7561b53f79 [automated] Apply ESLint and Oxfmt fixes 2026-07-13 19:17:55 -07:00
bymyself
259eeee9b7 fix(e2e): correct workspace switch click sequence and use polling assertions
- Add two-step open: profile popover → workspace-switcher-trigger → workspace item
  (clicking the trigger div opens the panel; only clicking an item calls switchWorkspace)
- Replace bare expect() after click with expect.poll() to wait for async switchWorkspace
- Import Page type from @playwright/test instead of inline import()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 19:17:55 -07:00
GitHub Action
00d2d3bd25 [automated] Apply ESLint and Oxfmt fixes 2026-07-13 19:17:55 -07:00
bymyself
f80f2c3b77 test(e2e): add cloud E2E tests for workspace auth refresh races
Covers token persistence, workspace-switch replacement, transient 500
preservation, and 403 ACCESS_DENIED session clearing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 19:17:55 -07:00
24 changed files with 443 additions and 1524 deletions

View File

@@ -0,0 +1,51 @@
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
import type { WorkspaceTokenResponse } from '@/platform/workspace/stores/workspaceAuthStore'
export const mockWorkspacesRemoteConfig: RemoteConfig = {
team_workspaces_enabled: true
}
export const mockPersonalWorkspace: WorkspaceWithRole = {
id: 'ws-personal',
name: 'Personal Workspace',
type: 'personal',
created_at: '2026-01-01T00:00:00Z',
joined_at: '2026-01-01T00:00:00Z',
role: 'owner'
}
export const mockTeamWorkspace: WorkspaceWithRole = {
id: 'ws-team',
name: 'Team Workspace',
type: 'team',
created_at: '2026-01-02T00:00:00Z',
joined_at: '2026-01-02T00:00:00Z',
role: 'member'
}
export function makeWorkspaceTokenResponse(
workspace: WorkspaceWithRole,
token: string,
expiresInMs = 60 * 60 * 1000
): WorkspaceTokenResponse {
return {
token,
expires_at: new Date(Date.now() + expiresInMs).toISOString(),
workspace: {
id: workspace.id,
name: workspace.name,
type: workspace.type
},
role: workspace.role,
permissions: []
}
}
// On app boot, teamWorkspaceStore.initialize() auto-selects Personal Workspace
// and calls switchWorkspace → /api/auth/token. This default response absorbs
// that init call so per-test mocks only see explicit switches.
export const defaultWorkspaceTokenResponse = makeWorkspaceTokenResponse(
mockPersonalWorkspace,
'init-token'
)

View File

@@ -0,0 +1,65 @@
import { expect } from '@playwright/test'
import type { Page, Route } from '@playwright/test'
import {
defaultWorkspaceTokenResponse,
mockPersonalWorkspace,
mockTeamWorkspace,
mockWorkspacesRemoteConfig
} from '@e2e/fixtures/data/workspaceAuthFixtures'
export class WorkspaceAuthHelper {
constructor(private readonly page: Page) {}
async mockWorkspaceRoutes(): Promise<void> {
await this.page.route('**/api/features', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockWorkspacesRemoteConfig)
})
)
await this.page.route('**/api/workspaces', async (route) => {
if (route.request().method() !== 'GET') {
await route.fallback()
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
workspaces: [mockPersonalWorkspace, mockTeamWorkspace]
})
})
})
// Default stub for the init-time auto-switch. Per-test mocks added via
// mockTokenRoute() take priority (Playwright matches routes LIFO).
await this.page.route('**/api/auth/token', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(defaultWorkspaceTokenResponse)
})
)
}
async mockTokenRoute(
handler: (route: Route) => void | Promise<void>
): Promise<void> {
await this.page.route('**/api/auth/token', handler)
}
// Opens the profile popover then the workspace-switcher panel.
// useWorkspaceSwitch skips switchWorkspace when the target is already active,
// so always switch to a workspace other than the auto-selected Personal one.
async openSwitcherPanel(): Promise<void> {
await this.page.keyboard.press('Escape')
await this.page.getByRole('button', { name: 'Current user' }).click()
await this.page.getByTestId('workspace-switcher-trigger').click()
await expect(
this.page.getByTestId('workspace-switcher-panel')
).toBeVisible()
}
}

View File

@@ -23,7 +23,6 @@ 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'
@@ -70,25 +69,6 @@ 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 = (
@@ -159,10 +139,17 @@ 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 getInputSlotIndexByName(
comfyPage,
ksamplerId,
KSAMPLER_MODEL_INPUT_NAME
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 modelInputSlotRow = comfyPage.vueNodes.getInputSlotRow(
ksamplerId,
@@ -188,7 +175,7 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
await expect(modelInputSlotRow).toBeVisible()
await expect(modelInputSlotRow).toBeInViewport()
await expect(modelInputSlotHighlight).toHaveClass(SLOT_ERROR_CLASS)
await expect(modelInputSlotHighlight).toHaveClass(/before:ring-error/)
await expect(
comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId)
).toHaveClass(ERROR_CLASS)
@@ -420,76 +407,5 @@ 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

@@ -0,0 +1,181 @@
import { expect } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import {
makeWorkspaceTokenResponse,
mockTeamWorkspace
} from '@e2e/fixtures/data/workspaceAuthFixtures'
import { WorkspaceAuthHelper } from '@e2e/fixtures/helpers/WorkspaceAuthHelper'
const test = comfyPageFixture.extend<{ workspaceAuth: WorkspaceAuthHelper }>({
page: async ({ page }, use) => {
const helper = new WorkspaceAuthHelper(page)
await helper.mockWorkspaceRoutes()
await use(page)
},
workspaceAuth: async ({ page }, use) => {
await use(new WorkspaceAuthHelper(page))
}
})
test.describe('Workspace auth refresh', { tag: '@cloud' }, () => {
test('token is persisted to sessionStorage after switching workspace', async ({
comfyPage,
workspaceAuth
}) => {
const page = comfyPage.page
await workspaceAuth.mockTokenRoute((route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(
makeWorkspaceTokenResponse(mockTeamWorkspace, 'team-token')
)
})
)
await comfyPage.toast.closeToasts()
await workspaceAuth.openSwitcherPanel()
await page
.getByTestId('workspace-switcher-panel')
.getByText('Team Workspace')
.click()
await expect
.poll(
() =>
page.evaluate(() => sessionStorage.getItem('Comfy.Workspace.Token')),
{ timeout: 5000 }
)
.toBe('team-token')
const expiresAt = await page.evaluate(() =>
sessionStorage.getItem('Comfy.Workspace.ExpiresAt')
)
expect(Number(expiresAt)).toBeGreaterThan(Date.now())
})
test('transient token refresh failure preserves the active workspace session', async ({
comfyPage,
workspaceAuth
}) => {
const page = comfyPage.page
// TOKEN_REFRESH_BUFFER_MS is 5 minutes. Setting expiresInMs just below the
// buffer means scheduleTokenRefresh computes delay ≈ 0 and fires immediately.
const expiresInMs = 5 * 60 * 1000 - 500
let callCount = 0
await workspaceAuth.mockTokenRoute((route) => {
callCount++
if (callCount === 1) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(
makeWorkspaceTokenResponse(
mockTeamWorkspace,
'original-token',
expiresInMs
)
)
})
}
return route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ message: 'Internal Server Error' })
})
})
await comfyPage.toast.closeToasts()
await workspaceAuth.openSwitcherPanel()
await page
.getByTestId('workspace-switcher-panel')
.getByText('Team Workspace')
.click()
await expect
.poll(
() =>
page.evaluate(() => sessionStorage.getItem('Comfy.Workspace.Token')),
{ timeout: 5000 }
)
.toBe('original-token')
// The scheduled refresh fires immediately (token expires within buffer window).
// Wait for the second route call (the failing refresh) to complete, then verify
// the token is preserved — a transient 500 must not clear the session.
await expect
.poll(() => callCount, { timeout: 5000 })
.toBeGreaterThanOrEqual(2)
expect(
await page.evaluate(() => sessionStorage.getItem('Comfy.Workspace.Token'))
).toBe('original-token')
})
test('permanent auth failure (ACCESS_DENIED) clears the workspace session', async ({
comfyPage,
workspaceAuth
}) => {
const page = comfyPage.page
// TOKEN_REFRESH_BUFFER_MS is 5 minutes. Setting expiresInMs just below the
// buffer means scheduleTokenRefresh computes delay ≈ 0 and fires immediately.
const expiresInMs = 5 * 60 * 1000 - 500
let callCount = 0
await workspaceAuth.mockTokenRoute((route) => {
callCount++
if (callCount === 1) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(
makeWorkspaceTokenResponse(
mockTeamWorkspace,
'original-token',
expiresInMs
)
)
})
}
return route.fulfill({
status: 403,
contentType: 'application/json',
body: JSON.stringify({ message: 'Access denied' })
})
})
await comfyPage.toast.closeToasts()
await workspaceAuth.openSwitcherPanel()
await page
.getByTestId('workspace-switcher-panel')
.getByText('Team Workspace')
.click()
// Wait for both the switch (callCount=1) and the immediate refresh (callCount=2)
// to complete. The refresh fires at delay≈0 so token may already be cleared
// before we can assert the intermediate 'original-token' state.
await expect
.poll(() => callCount, { timeout: 5000 })
.toBeGreaterThanOrEqual(2)
// A 403 ACCESS_DENIED response must clear the workspace session entirely.
await expect
.poll(
() =>
page.evaluate(() => sessionStorage.getItem('Comfy.Workspace.Token')),
{ timeout: 5000 }
)
.toBeNull()
expect(
await page.evaluate(() =>
sessionStorage.getItem('Comfy.Workspace.Current')
)
).toBeNull()
})
})

View File

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

View File

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

View File

@@ -5,11 +5,9 @@ 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()
@@ -129,8 +127,6 @@ 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
@@ -497,47 +493,6 @@ 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.surfacedNodeErrors) return
if (!executionErrorStore.lastNodeErrors) return
for (const [rawNodeId, nodeError] of Object.entries(
executionErrorStore.surfacedNodeErrors
executionErrorStore.lastNodeErrors
)) {
const nodeId = tryNormalizeNodeExecutionId(rawNodeId)
if (!nodeId) continue

View File

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

View File

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

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

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

View File

@@ -18,7 +18,6 @@ import {
SUBGRAPH_OUTPUT_ID
} from '@/lib/litegraph/src/constants'
import type { SerializedNodeId } from '@/types/nodeId'
import { toNodeId } from '@/types/nodeId'
import {
LGraph,
LGraphNode,
@@ -87,23 +86,6 @@ 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
@@ -287,32 +269,6 @@ 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

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

View File

@@ -1,10 +1,13 @@
import type { ExecutionErrorWsMessage, PromptError } from '@/schemas/apiSchema'
import type {
ExecutionErrorWsMessage,
NodeError,
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 }
export type NodeValidationError = NodeError['errors'][number]
export interface ResolvedErrorMessage {
catalogId?: string

View File

@@ -11,12 +11,6 @@ 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'
@@ -68,31 +62,51 @@ 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'
},
...NODE_LEVEL_VALIDATION_ERROR_RULES
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'
}
}
// Image-not-loaded shares the custom_validation_failed type, so type-keyed
@@ -117,6 +131,26 @@ 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}`
}
@@ -145,7 +179,13 @@ function getInputConfigValue(
error: NodeValidationError,
key: 'min' | 'max'
): string | undefined {
return formatCatalogValue(getInputConfigBounds(error)[key])
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])
}
function getInputConfigType(error: NodeValidationError): string | undefined {

View File

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

View File

@@ -252,41 +252,6 @@ 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 = {
@@ -702,36 +667,6 @@ 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,12 +130,8 @@ 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 === errorInputName) ||
!!errors?.some((e) => e.extra_info?.input_name === widget.name) ||
missingModelStore.isWidgetMissingModel(nodeExecId, widget.name)
)
}

View File

@@ -1,21 +1,9 @@
import { fromAny } from '@total-typescript/shoehorn'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers'
import type { MissingNodeType } from '@/types/comfy'
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'
import { createNodeExecutionId } from '@/types/nodeIdentification'
// Mock dependencies
vi.mock('@/i18n', () => ({
@@ -51,20 +39,11 @@ 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()
@@ -317,97 +296,6 @@ 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', () => {
@@ -500,137 +388,6 @@ 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,11 +2,6 @@ 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'
@@ -21,10 +16,7 @@ import type {
NodeError,
PromptError
} from '@/schemas/apiSchema'
import {
getAncestorExecutionIds,
tryNormalizeNodeExecutionId
} from '@/types/nodeIdentification'
import { getAncestorExecutionIds } from '@/types/nodeIdentification'
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
import {
executionIdToNodeLocatorId,
@@ -33,18 +25,10 @@ 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()
@@ -95,27 +79,31 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
lastPromptError.value = null
}
function clearSimpleNodeErrorsFromRecord(
nodeErrors: Record<string, NodeError>,
/**
* 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(
executionId: NodeExecutionId,
slotName?: string
): Record<string, NodeError> | null {
const nodeError = nodeErrors[executionId]
if (!nodeError) return null
): void {
if (!lastNodeErrors.value) return
const nodeError = lastNodeErrors.value[executionId]
if (!nodeError) return
const isSlotScoped = slotName !== undefined
const relevantErrors = isSlotScoped
? nodeError.errors.filter((e) => e.extra_info?.input_name === slotName)
: nodeError.errors
if (relevantErrors.length === 0) return null
if (!relevantErrors.every((e) => SIMPLE_ERROR_TYPES.has(e.type))) {
return null
}
if (relevantErrors.length === 0) return
if (!relevantErrors.every((e) => SIMPLE_ERROR_TYPES.has(e.type))) return
const updated = { ...nodeErrors }
const updated = { ...lastNodeErrors.value }
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
)
@@ -128,150 +116,16 @@ 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
@@ -284,24 +138,16 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
newValue: unknown,
options?: { min?: number; max?: number }
): void {
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)
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)
}
/**
@@ -378,13 +224,6 @@ 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 ||
@@ -397,8 +236,8 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
const allErrorExecutionIds = computed<string[]>(() => {
const ids: string[] = []
if (surfacedNodeErrors.value) {
ids.push(...Object.keys(surfacedNodeErrors.value))
if (lastNodeErrors.value) {
ids.push(...Object.keys(lastNodeErrors.value))
}
if (lastExecutionError.value) {
const nodeId = lastExecutionError.value.node_id
@@ -440,8 +279,8 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
// Fall back to rootGraph when currentGraph hasn't been initialized yet
const activeGraph = canvasStore.currentGraph ?? app.rootGraph
if (surfacedNodeErrors.value) {
for (const executionId of Object.keys(surfacedNodeErrors.value)) {
if (lastNodeErrors.value) {
for (const executionId of Object.keys(lastNodeErrors.value)) {
const graphNode = getNodeByExecutionId(app.rootGraph, executionId)
if (graphNode?.graph === activeGraph) {
ids.add(String(graphNode.id))
@@ -463,12 +302,12 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
/** Map of node errors indexed by locator ID. */
const nodeErrorsByLocatorId = computed<Record<NodeLocatorId, NodeError>>(
() => {
if (!surfacedNodeErrors.value) return {}
if (!lastNodeErrors.value) return {}
const map: Record<NodeLocatorId, NodeError> = {}
for (const [executionId, nodeError] of Object.entries(
surfacedNodeErrors.value
lastNodeErrors.value
)) {
const locatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
if (locatorId) {
@@ -522,7 +361,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
return errorAncestorExecutionIds.value.has(execId)
}
useNodeErrorFlagSync(surfacedNodeErrors, missingModelStore, missingMediaStore)
useNodeErrorFlagSync(lastNodeErrors, missingModelStore, missingMediaStore)
return {
// Raw state
@@ -541,7 +380,6 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
dismissErrorOverlay,
// Derived state
surfacedNodeErrors,
hasExecutionError,
hasPromptError,
hasNodeError,

View File

@@ -1,18 +1,30 @@
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]: nodeError(
[validationError('required_input_missing', inputName, {}, 'Missing', '')],
'TestNode'
)
[executionId]: createRequiredInputMissingNodeError(inputName)
}
}

View File

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

View File

@@ -105,61 +105,6 @@ 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`).