Compare commits

...

8 Commits

Author SHA1 Message Date
jaeone94
dff90de25d test: clarify execution error locate controls 2026-06-07 04:56:42 +09:00
jaeone94
d94fb3c1b7 fix: refine execution error presentation 2026-06-07 04:43:45 +09:00
jaeone94
e46be82b88 fix: simplify validation error rows 2026-06-07 02:10:09 +09:00
jaeone94
e7019146d1 fix: group validation errors by catalog id 2026-06-07 02:10:09 +09:00
jaeone94
0f27431c37 test: cover validation error catalog grouping 2026-06-07 02:10:09 +09:00
Dante
f8187cec4c test: e2e for on-node grid thumbnail previews (FE-741) (#12667)
## Summary

Adds an end-to-end (Playwright) test for the on-node image-grid
thumbnail behavior introduced in #12561 (FE-741), kept in its own PR so
#12561 stays scoped to the fix + unit tests.

#12561 makes small on-node grid cells request a lightweight thumbnail
URL (`preview=webp;75`, server-resized via `res` on cloud) instead of
downloading the full-resolution `/api/view` image. That PR covers the
helper and component with unit tests. This PR adds the missing
**integration** coverage: it drives a real `Preview Image` node in the
browser, injects a multi-image grid over the websocket, and asserts the
rendered grid `<img>` elements request thumbnails.

It exercises the full path the unit tests can't:

`executed` WS output → `nodeOutputStore.buildImageUrls` →
`getGridThumbnailUrl` → rendered grid `<img>` `src`

## What it checks

- A 4-image `Preview Image` grid renders 4 cells (`viewMode ===
'grid'`).
- Every grid cell `<img>` `src` carries the compact thumbnail format
(`preview=webp;75`; the `;` may be percent-encoded as `%3B`).
- Each `src` still points at the real `/api/view` URL for that output
(`filename=grid-<n>.png`), confirming it's the thumbnailed view URL, not
a placeholder/blob.

Lives next to the existing batch-preview test in
`browser_tests/tests/vueNodes/interactions/node/imagePreview.spec.ts`,
reusing the `ExecutionHelper` + `webSocketFixture` injection pattern.

## Notes

- **Stacked on #12561** (`jaewon/fe-741-onnode-grid-thumbnail-preview`).
Without that fix the grid cells use the full-res URL and this test is
red — i.e. it's a true regression guard. Retarget to `main` once #12561
merges.
- Gallery/full-view URLs staying at full resolution is already covered
by the unit tests in #12561 (`currentImageUrl` is left untransformed);
this test deliberately scopes to the grid path to avoid depending on
injected previews actually resolving on the backend.

## Test Plan

- [x] `pnpm typecheck:browser` clean
- [x] ESLint + oxlint clean
- [ ] e2e CI (`ci-tests-e2e`) green on this PR
2026-06-06 13:10:44 +00:00
Terry Jia
7e61358724 FE-905 fix(load3d): cache scene capture so unchanged runs hit backend cache (#12627)
## Summary
The scene widget's serializeValue uploaded a fresh temp image on every
queue, so the `image / mask / normal` filenames in the prompt JSON were
new each run. The backend cache key (which hashes those input strings)
never matched, forcing Load3D and every downstream node to re-execute
even when the user changed nothing.

Track a session-scoped dirty flag and last-output cache in module-level
WeakMaps keyed by LGraphNode. serializeValue returns the cached output
when nothing has changed; user actions that mutate the visible scene
(scene/model/camera/light config, animation, recording, gizmo, camera
orbit) mark dirty through useLoad3d watchers and event handlers. The
model_file / width / height widget callbacks invalidate via a new
optional onSceneInvalidated hook plumbed through Load3DConfiguration, so
the captured screenshot stays consistent with the inputs the backend
sees.

## Screenshots (if applicable)
Before

https://github.com/user-attachments/assets/5ee5f79f-dd38-401e-babe-4d6ea156e56d

After

https://github.com/user-attachments/assets/5e00beb4-937c-4c66-abb2-e455f5301de6
2026-06-06 11:58:52 +00:00
Alexander Brown
ff9e6415b5 fix(nodes-2): apply Textarea widget font-size setting in Vue Nodes 2.0 (#12386)
*PR Created by the Glary-Bot Agent*

---

## Summary

`Settings → Appearance → Node Widget → Textarea widget font size`
(`Comfy.TextareaWidget.FontSize`) was wired through the legacy LiteGraph
textarea only. The Vue Nodes 2.0 `WidgetTextarea.vue` hardcoded Tailwind
`text-xs`, so once Vue nodes were enabled the slider had no effect.

`GraphView.vue` already writes the setting value to
`--comfy-textarea-font-size` on `:root` for the legacy
`.comfy-multiline-input` rule. This PR makes `WidgetTextarea` consume
the same variable via Tailwind v4's parenthesized CSS-variable
shorthand, keeping `GraphView` as the single source of truth.

- `text-xs` → `text-(length:--comfy-textarea-font-size) leading-normal`.
The `length:` type hint is required because `text-` is ambiguous between
`font-size` and `color`. `leading-normal` keeps line-height proportional
to font-size across the 8–24 px range so multi-line text doesn't clip at
the high end.
- Initialize `--comfy-textarea-font-size: 10px` on `:root` in the
design-system stylesheet so isolated renders (Storybook, tests) that do
not mount `GraphView` still pick up the documented default.

- Fixes
[FE-799](https://linear.app/comfyorg/issue/FE-799/bug-textarea-widget-font-size-setting-not-working-in-nodes-20)

## Verification

- `pnpm typecheck`, `pnpm lint`, `pnpm exec stylelint`, `pnpm exec oxfmt
--check`, `pnpm knip`, and `WidgetTextarea.test.ts` (20 tests) all pass.
- Manual browser verification with Vue Nodes 2.0 enabled and a
`CLIPTextEncode` node:
  - setting `8` → computed `font-size: 8px`
  - setting `22` → computed `font-size: 22px`
- setting `24` → computed `font-size: 24px`, computed `line-height:
36px` (ratio 1.5, no clipping)
- Confirmed the legacy LiteGraph path still resolves to
`comfy-multiline-input` with `font-size: 22px` when Vue Nodes is
disabled (no regression).
- Confirmed the `:root` default resolves to `10px` when `GraphView`'s
inline override is absent (Storybook-like environments).

## Out of scope (follow-up)

`WidgetMarkdown.vue` (the Vue Nodes 2.0 markdown/tiptap widget) also
hardcodes `text-sm`. The legacy `.comfy-markdown .tiptap` rule reads the
same `--comfy-textarea-font-size` variable, so the setting historically
governed markdown widgets in Nodes 1.0. Bringing that into line with
this PR's approach is a follow-up the design team should weigh in on
before changing.

## Screenshots


![textarea-fontsize-8px](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6f2a0d3f4193afb5583ebf6c01c558f759abf5059f5f0535c4168e081a68ea47/pr-images/1779307449630-3046bbe9-cb29-41f7-8994-9d251bd0ab5d.png)


![textarea-fontsize-22px](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6f2a0d3f4193afb5583ebf6c01c558f759abf5059f5f0535c4168e081a68ea47/pr-images/1779307449987-46aed4a1-b09c-4b2e-88cf-e6302944c319.png)


![textarea-fontsize-24px-multiline](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6f2a0d3f4193afb5583ebf6c01c558f759abf5059f5f0535c4168e081a68ea47/pr-images/1779307450337-164136c9-b1e2-4dac-8390-4d935d416675.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12386-fix-nodes-2-apply-Textarea-widget-font-size-setting-in-Vue-Nodes-2-0-3666d73d365081fd8084e84a41ee357b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-06-06 00:26:51 +00:00
33 changed files with 1434 additions and 371 deletions

View File

@@ -0,0 +1,63 @@
import { expect } from '@playwright/test'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
type Load3dImageInput = {
image: string
mask: string
normal: string
recording: string
}
type PromptBody = {
prompt?: Record<
string,
{ class_type?: string; inputs?: Record<string, unknown> }
>
}
function getLoad3dImageInput(body: unknown, nodeId: string): Load3dImageInput {
const prompt = (body as PromptBody).prompt ?? {}
const node = prompt[nodeId]
expect(node?.class_type, `node ${nodeId} should be Load3D`).toBe('Load3D')
const input = node!.inputs!.image as Load3dImageInput
expect(typeof input.image).toBe('string')
expect(typeof input.recording).toBe('string')
return input
}
test.describe('Load3D serialize cache', () => {
test('starting a recording forces the next queue to re-capture (FE-905)', async ({
comfyPage,
load3d
}) => {
const exec = new ExecutionHelper(comfyPage)
let firstBody: unknown
await exec.run({
onPromptRequest: (body) => {
firstBody = body
}
})
const firstInput = getLoad3dImageInput(firstBody, '1')
expect(firstInput.recording).toBe('')
await load3d.recordingButton.click()
await expect(load3d.stopRecordingButton).toBeVisible()
let secondBody: unknown
await exec.run({
onPromptRequest: (body) => {
secondBody = body
}
})
const secondInput = getLoad3dImageInput(secondBody, '1')
expect(
secondInput.image,
'after starting a recording, the next queue must re-capture ' +
'(image filename must change) so the recording is not dropped'
).not.toBe(firstInput.image)
})
})

View File

@@ -41,7 +41,7 @@ test.describe('Errors tab - common', { tag: '@ui' }, () => {
await comfyPage.setup()
})
test('Should filter execution errors by search query', async ({
test('Should keep execution errors matching the search query', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
@@ -62,9 +62,9 @@ test.describe('Errors tab - common', { tag: '@ui' }, () => {
await expect(runtimePanel).toBeVisible()
const searchInput = comfyPage.page.getByPlaceholder(/^Search/)
await searchInput.fill('nonexistent_query_xyz_12345')
await searchInput.fill('Execution failed')
await expect(runtimePanel).toHaveCount(0)
await expect(runtimePanel).toBeVisible()
})
})
})

View File

@@ -41,7 +41,7 @@ test.describe('Errors tab - Execution errors', { tag: '@ui' }, () => {
).toBeVisible()
})
test('Should show error message in runtime error panel', async ({
test('Should show runtime error log in the execution error group', async ({
comfyPage
}) => {
await openExecutionErrorTab(comfyPage)
@@ -50,6 +50,6 @@ test.describe('Errors tab - Execution errors', { tag: '@ui' }, () => {
TestIds.dialogs.runtimeErrorPanel
)
await expect(runtimePanel).toBeVisible()
await expect(runtimePanel).toContainText(/\S/)
await expect(runtimePanel).toContainText('Error log')
})
})

View File

@@ -0,0 +1,30 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe(
'Textarea widget font size',
{ tag: ['@widget', '@vue-nodes'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('default')
})
test('applies Comfy.TextareaWidget.FontSize to Vue Nodes 2.0 textarea widget', async ({
comfyPage
}) => {
const textarea = comfyPage.vueNodes.nodes.locator('textarea').first()
await expect(textarea).toBeVisible()
await comfyPage.settings.setSetting('Comfy.TextareaWidget.FontSize', 14)
await expect
.poll(() => textarea.evaluate((el) => getComputedStyle(el).fontSize))
.toBe('14px')
await comfyPage.settings.setSetting('Comfy.TextareaWidget.FontSize', 22)
await expect
.poll(() => textarea.evaluate((el) => getComputedStyle(el).fontSize))
.toBe('22px')
})
}
)

View File

@@ -180,4 +180,44 @@ test.describe('Vue Nodes Batch Image Preview', { tag: '@vue-nodes' }, () => {
await expect.poll(() => countColumns(node.imageGrid)).toBeLessThan(10)
}
)
wstest(
'requests lightweight thumbnail URLs for grid cells',
async ({ comfyPage, getWebSocket }) => {
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
await test.step('Add node', async () => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode('Preview Image')
const previewImage = comfyPage.vueNodes.getNodeByTitle('Preview Image')
await expect(previewImage).toBeVisible()
})
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
const gridImages = node.imageGrid.locator('img')
await test.step('Inject a multi-image grid', async () => {
const images = Array.from({ length: 4 }, (_, index) => ({
filename: `grid-${index}.png`,
subfolder: '',
type: 'output'
}))
execution.executed('', '1', { images })
await expect(gridImages).toHaveCount(4)
})
// FE-741: small on-node grid cells must request a server re-encoded
// thumbnail (`preview=webp;75`, `;` may be percent-encoded) instead of
// downloading the full-resolution image, while still pointing at the
// real `/api/view` URL for that output. Verifies the full path: WS
// output -> nodeOutputStore.buildImageUrls -> getGridThumbnailUrl ->
// rendered grid `<img>`.
for (const cell of await gridImages.all()) {
await expect(cell).toHaveAttribute('src', /[?&]preview=webp(%3B|;)75/)
await expect(cell).toHaveAttribute('src', /[?&]filename=grid-\d+\.png/)
}
}
)
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 104 KiB

View File

@@ -121,6 +121,7 @@
--comfy-topbar-height: 2.5rem;
--workflow-tabs-height: 2.375rem;
--comfy-input-bg: #222;
--comfy-textarea-font-size: 10px;
--input-text: #ddd;
--descrip-text: #999;
--drag-text: #ccc;

View File

@@ -2,20 +2,12 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ErrorNodeCard from './ErrorNodeCard.vue'
import type { ErrorCardData } from './types'
/**
* ErrorNodeCard displays a single error card inside the error tab.
* It shows the node header (ID badge, title, action buttons)
* and the list of error items (message, traceback, copy button).
*/
const meta: Meta<typeof ErrorNodeCard> = {
title: 'RightSidePanel/Errors/ErrorNodeCard',
component: ErrorNodeCard,
parameters: {
layout: 'centered'
},
argTypes: {
showNodeIdBadge: { control: 'boolean' }
},
decorators: [
(story) => ({
components: { story },
@@ -105,58 +97,36 @@ const promptOnlyCard: ErrorCardData = {
]
}
/** Single validation error with node ID badge visible */
export const WithNodeIdBadge: Story = {
export const SingleValidationError: Story = {
args: {
card: singleErrorCard,
showNodeIdBadge: true
}
}
/** Single validation error without node ID badge */
export const WithoutNodeIdBadge: Story = {
args: {
card: singleErrorCard,
showNodeIdBadge: false
card: singleErrorCard
}
}
/** Subgraph node error — shows "Enter subgraph" button */
export const WithEnterSubgraphButton: Story = {
args: {
card: subgraphErrorCard,
showNodeIdBadge: true
}
}
/** Regular node error — no "Enter subgraph" button */
export const WithoutEnterSubgraphButton: Story = {
args: {
card: singleErrorCard,
showNodeIdBadge: true
card: subgraphErrorCard
}
}
/** Multiple validation errors on one node */
export const MultipleErrors: Story = {
args: {
card: multipleErrorsCard,
showNodeIdBadge: true
card: multipleErrorsCard
}
}
/** Runtime execution error with full traceback */
export const RuntimeError: Story = {
args: {
card: runtimeErrorCard,
showNodeIdBadge: true
card: runtimeErrorCard
}
}
/** Prompt-level error (no node header) */
export const PromptError: Story = {
args: {
card: promptOnlyCard,
showNodeIdBadge: false
card: promptOnlyCard
}
}

View File

@@ -71,6 +71,7 @@ describe('ErrorNodeCard.vue', () => {
en: {
g: {
copy: 'Copy',
details: 'Details',
findIssues: 'Find Issues',
findOnGithub: 'Find on GitHub',
getHelpAction: 'Get Help'
@@ -78,6 +79,7 @@ describe('ErrorNodeCard.vue', () => {
rightSidePanel: {
locateNode: 'Locate Node',
enterSubgraph: 'Enter Subgraph',
errorLog: 'Error log',
findOnGithubTooltip: 'Search GitHub issues for related problems',
getHelpTooltip:
'Report this error and we\u0027ll help you resolve it'
@@ -96,8 +98,9 @@ describe('ErrorNodeCard.vue', () => {
) {
const user = userEvent.setup()
const onCopyToClipboard = vi.fn()
const onLocateNode = vi.fn()
render(ErrorNodeCard, {
props: { card, onCopyToClipboard },
props: { card, onCopyToClipboard, onLocateNode },
global: {
plugins: [
PrimeVue,
@@ -131,14 +134,20 @@ describe('ErrorNodeCard.vue', () => {
})
],
stubs: {
TransitionCollapse: { template: '<div><slot /></div>' },
Button: {
template:
'<button :aria-label="$attrs[\'aria-label\']"><slot /></button>'
template: '<button v-bind="$attrs"><slot /></button>'
}
}
}
})
return { user, onCopyToClipboard }
return { user, onCopyToClipboard, onLocateNode }
}
async function toggleRuntimeDetails(
user: ReturnType<typeof userEvent.setup>
) {
await user.click(screen.getByRole('button', { name: /Details/ }))
}
let cardIdCounter = 0
@@ -160,40 +169,67 @@ describe('ErrorNodeCard.vue', () => {
}
}
function makeValidationErrorCard(): ErrorCardData {
function makePromptErrorCard(): ErrorCardData {
return {
id: `node-${++cardIdCounter}`,
title: 'CLIPTextEncode',
nodeId: '6',
nodeTitle: 'CLIP Text Encode',
id: '__prompt__',
title: 'Prompt has no outputs',
errors: [
{
message: 'Required input is missing',
details: 'Input: text'
message: 'Server Error: No outputs',
details: 'Error details',
displayMessage:
'The workflow does not contain any output nodes to produce a result.'
}
]
}
}
it('displays enriched report for runtime errors on mount', async () => {
it('shows runtime details by default and can collapse them', async () => {
const reportText =
'# ComfyUI Error Report\n## System Information\n- OS: Linux'
mockGenerateErrorReport.mockReturnValue(reportText)
renderCard(makeRuntimeErrorCard())
const { user } = renderCard(makeRuntimeErrorCard())
await waitFor(() => {
expect(screen.getByText(/ComfyUI Error Report/)).toBeInTheDocument()
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
})
expect(screen.queryByRole('listitem')).not.toBeInTheDocument()
expect(screen.getByText('Error log')).toBeInTheDocument()
const detailsButton = screen.getByRole('button', { name: /Details/ })
const detailsRegion = screen.getByRole('region', { name: 'Error log' })
expect(detailsButton).toHaveAttribute(
'aria-controls',
detailsRegion.getAttribute('id')
)
expect(screen.getByText(/ComfyUI Error Report/)).toBeInTheDocument()
expect(screen.getByText(/System Information/)).toBeInTheDocument()
expect(screen.getByText(/OS: Linux/)).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /Find on GitHub/ })
).toBeInTheDocument()
await toggleRuntimeDetails(user)
expect(screen.queryByText(/ComfyUI Error Report/)).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /Find on GitHub/ })
).not.toBeInTheDocument()
})
it('locates the node when the runtime node title is clicked', async () => {
const { user, onLocateNode } = renderCard(makeRuntimeErrorCard())
await user.click(screen.getByRole('button', { name: 'KSampler' }))
expect(onLocateNode).toHaveBeenCalledWith('10')
})
it('does not generate report for non-runtime errors', async () => {
renderCard(makeValidationErrorCard())
renderCard(makePromptErrorCard())
await waitFor(() => {
expect(screen.getByText('Input: text')).toBeInTheDocument()
expect(screen.getByText('Error details')).toBeInTheDocument()
})
expect(mockGetLogs).not.toHaveBeenCalled()
@@ -201,15 +237,15 @@ describe('ErrorNodeCard.vue', () => {
})
it('displays original details for non-runtime errors', async () => {
renderCard(makeValidationErrorCard())
renderCard(makePromptErrorCard())
await waitFor(() => {
expect(screen.getByText('Input: text')).toBeInTheDocument()
expect(screen.getByText('Error details')).toBeInTheDocument()
})
expect(screen.queryByText(/ComfyUI Error Report/)).not.toBeInTheDocument()
})
it('displays catalog-resolved copy when available', async () => {
it('hides grouped catalog copy and shows the item label as a list item', async () => {
renderCard({
id: `node-${++cardIdCounter}`,
title: 'KSampler',
@@ -229,17 +265,17 @@ describe('ErrorNodeCard.vue', () => {
})
await waitFor(() => {
expect(screen.getByText('Missing connection')).toBeInTheDocument()
expect(screen.getByText('KSampler - model')).toBeInTheDocument()
})
expect(screen.getByRole('listitem')).toHaveTextContent('KSampler - model')
expect(screen.queryByText('Missing connection')).not.toBeInTheDocument()
expect(
screen.getByText('Required input slots have no connection feeding them.')
).toBeInTheDocument()
screen.queryByText(
'Required input slots have no connection feeding them.'
)
).not.toBeInTheDocument()
expect(
screen.getByText('KSampler is missing a required input: model')
).toBeInTheDocument()
expect(screen.queryByText('KSampler - model')).not.toBeInTheDocument()
expect(
screen.queryByText('Required input is missing')
screen.queryByText('KSampler is missing a required input: model')
).not.toBeInTheDocument()
})
@@ -250,8 +286,9 @@ describe('ErrorNodeCard.vue', () => {
const { user, onCopyToClipboard } = renderCard(makeRuntimeErrorCard())
await waitFor(() => {
expect(screen.getByText(/Full Report Content/)).toBeInTheDocument()
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
})
expect(screen.getByText(/Full Report Content/)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /Copy/ }))
@@ -261,21 +298,6 @@ describe('ErrorNodeCard.vue', () => {
)
})
it('copies original details when copy button is clicked for validation error', async () => {
const { user, onCopyToClipboard } = renderCard(makeValidationErrorCard())
await waitFor(() => {
expect(screen.getByText('Input: text')).toBeInTheDocument()
})
await user.click(screen.getByRole('button', { name: /Copy/ }))
expect(onCopyToClipboard).toHaveBeenCalledTimes(1)
expect(onCopyToClipboard.mock.calls[0][0]).toBe(
'Required input is missing\n\nInput: text'
)
})
it('generates report with fallback logs when getLogs fails', async () => {
mockGetLogs.mockRejectedValue(new Error('Network error'))
@@ -300,8 +322,9 @@ describe('ErrorNodeCard.vue', () => {
renderCard(makeRuntimeErrorCard())
await waitFor(() => {
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
})
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
})
it('opens GitHub issues search when Find Issue button is clicked', async () => {
@@ -310,9 +333,7 @@ describe('ErrorNodeCard.vue', () => {
const { user } = renderCard(makeRuntimeErrorCard())
await waitFor(() => {
expect(
screen.getByRole('button', { name: /Find on GitHub/ })
).toBeInTheDocument()
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
})
await user.click(screen.getByRole('button', { name: /Find on GitHub/ }))
@@ -335,9 +356,7 @@ describe('ErrorNodeCard.vue', () => {
const { user } = renderCard(makeRuntimeErrorCard())
await waitFor(() => {
expect(
screen.getByRole('button', { name: /Get Help/ })
).toBeInTheDocument()
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
})
await user.click(screen.getByRole('button', { name: /Get Help/ }))
@@ -398,9 +417,7 @@ describe('ErrorNodeCard.vue', () => {
}
})
await waitFor(() => {
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
})
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
expect(mockGenerateErrorReport).not.toHaveBeenCalled()
})

View File

@@ -1,18 +1,19 @@
<template>
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
<!-- Card Header -->
<div
v-if="card.nodeId && !compact"
class="flex flex-wrap items-center gap-2 py-2"
>
<span
v-if="showNodeIdBadge"
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
<button
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
type="button"
class="m-0 min-w-0 flex-1 cursor-pointer appearance-none truncate border-0 bg-transparent p-0 text-left text-sm font-medium text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
@click="handleLocateNode"
>
#{{ card.nodeId }}
</span>
{{ card.nodeTitle || card.title }}
</button>
<span
v-if="card.nodeTitle || card.title"
v-else-if="card.nodeTitle || card.title"
class="flex-1 truncate text-sm font-medium text-muted-foreground"
>
{{ card.nodeTitle || card.title }}
@@ -27,6 +28,24 @@
>
{{ t('rightSidePanel.enterSubgraph') }}
</Button>
<Button
v-if="hasRuntimeError"
variant="textonly"
size="icon-sm"
:class="
cn(
'size-8 shrink-0 text-muted-foreground hover:text-base-foreground',
runtimeDetailsExpanded &&
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
)
"
:aria-label="t('g.details')"
:aria-controls="runtimeDetailsControlIds || undefined"
:aria-expanded="runtimeDetailsExpanded"
@click.stop="toggleRuntimeDetails"
>
<i class="icon-[lucide--monitor-x] size-4" />
</Button>
<Button
variant="textonly"
size="icon-sm"
@@ -39,120 +58,143 @@
</div>
</div>
<!-- Multiple Errors within one Card -->
<div
class="flex min-h-0 flex-1 flex-col space-y-4 divide-y divide-interface-stroke/20"
>
<!-- Card Content -->
<div
v-for="(error, idx) in card.errors"
:key="idx"
:class="
cn(
'flex min-h-0 flex-col gap-3',
fullHeight && error.isRuntimeError && 'flex-1'
)
"
class="flex min-h-0 flex-col gap-3"
>
<!-- Human-friendly category/title when resolved by the error catalog. -->
<p
v-if="error.displayTitle"
class="m-0 px-0.5 text-sm font-semibold text-destructive-background-hover"
>
{{ error.displayTitle }}
</p>
<!-- Error Message -->
<p
v-if="getDisplayMessage(error)"
v-if="getInlineMessage(error)"
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-sm/relaxed wrap-break-word whitespace-pre-wrap"
>
{{ getDisplayMessage(error) }}
{{ getInlineMessage(error) }}
</p>
<!-- Traceback / Details (enriched with full report for runtime errors) -->
<ul
v-if="getInlineItemLabel(error)"
class="m-0 list-disc space-y-1 pl-5 text-sm/relaxed text-muted-foreground marker:text-muted-foreground"
>
<li class="min-w-0 wrap-break-word">
<button
v-if="card.nodeId"
type="button"
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
@click="handleLocateNode"
>
{{ getInlineItemLabel(error) }}
</button>
<span v-else>
{{ getInlineItemLabel(error) }}
</span>
</li>
</ul>
<div
v-if="displayedDetailsMap[idx]"
v-if="!error.isRuntimeError && getInlineDetails(error, idx)"
:class="
cn(
'overflow-y-auto rounded-lg border border-interface-stroke/30 bg-secondary-background-hover p-2.5',
error.isRuntimeError
? fullHeight
? 'min-h-0 flex-1'
: 'max-h-[15lh]'
: 'max-h-[6lh]'
'overflow-y-auto rounded-lg border border-interface-stroke/30 bg-secondary-background p-2.5',
'max-h-[6lh]'
)
"
>
<p
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ displayedDetailsMap[idx] }}
{{ getInlineDetails(error, idx) }}
</p>
</div>
<div class="flex flex-col gap-2">
<div class="flex gap-2">
<Button
v-tooltip.top="t('rightSidePanel.findOnGithubTooltip')"
variant="secondary"
size="sm"
class="h-8 w-2/3 justify-center gap-1 rounded-lg text-xs"
data-testid="error-card-find-on-github"
@click="handleCheckGithub(error)"
>
{{ t('g.findOnGithub') }}
<i class="icon-[lucide--github] size-3.5" />
</Button>
<Button
variant="secondary"
size="sm"
class="h-8 w-1/3 justify-center gap-1 rounded-lg text-xs"
data-testid="error-card-copy"
@click="handleCopyError(idx)"
>
{{ t('g.copy') }}
<i class="icon-[lucide--copy] size-3.5" />
</Button>
</div>
<Button
v-tooltip.top="t('rightSidePanel.getHelpTooltip')"
variant="secondary"
size="sm"
class="h-8 w-full justify-center gap-1 rounded-lg text-xs"
@click="handleGetHelp"
<TransitionCollapse>
<div
v-if="error.isRuntimeError && isRuntimeDisclosureExpanded"
:id="getRuntimeDetailsId(idx)"
role="region"
data-testid="runtime-error-panel"
:aria-label="t('rightSidePanel.errorLog')"
class="flex min-h-0 flex-col gap-3"
>
{{ t('g.getHelpAction') }}
<i class="icon-[lucide--external-link] size-3.5" />
</Button>
</div>
<div
v-if="getInlineDetails(error, idx)"
class="overflow-hidden rounded-lg border border-interface-stroke/30 bg-secondary-background"
>
<div
class="flex items-center justify-between gap-2 px-3 pt-3 pb-2"
>
<span
class="text-xs font-semibold tracking-wide text-base-foreground uppercase"
>
{{ t('rightSidePanel.errorLog') }}
</span>
<Button
variant="textonly"
size="icon-sm"
class="size-7 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('g.copy')"
data-testid="error-card-copy"
@click="handleCopyError(idx)"
>
<i class="icon-[lucide--copy] size-4" />
</Button>
</div>
<div class="max-h-[15lh] overflow-y-auto px-3 pb-3">
<p
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ getInlineDetails(error, idx) }}
</p>
</div>
<div class="mx-3 mt-1 h-px bg-base-foreground/20" />
<div class="mx-3 flex items-center justify-between gap-2 py-2">
<Button
v-tooltip.top="t('rightSidePanel.getHelpTooltip')"
variant="textonly"
size="sm"
class="h-8 justify-start gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
@click="handleGetHelp"
>
<i class="icon-[lucide--external-link] size-3.5" />
{{ t('g.getHelpAction') }}
</Button>
<Button
v-tooltip.top="t('rightSidePanel.findOnGithubTooltip')"
variant="textonly"
size="sm"
class="h-8 justify-end gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
data-testid="error-card-find-on-github"
@click="handleCheckGithub(error)"
>
<i class="icon-[lucide--github] size-3.5" />
{{ t('g.findOnGithub') }}
</Button>
</div>
</div>
</div>
</TransitionCollapse>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@comfyorg/tailwind-utils'
import TransitionCollapse from '../layout/TransitionCollapse.vue'
import type { ErrorCardData, ErrorItem } from './types'
import { useErrorActions } from './useErrorActions'
import { useErrorReport } from './useErrorReport'
const {
card,
showNodeIdBadge = false,
compact = false,
fullHeight = false
} = defineProps<{
const { card, compact = false } = defineProps<{
card: ErrorCardData
showNodeIdBadge?: boolean
/** Hide card header and error message (used in single-node selection mode) */
compact?: boolean
/** Allow runtime error details to fill available height (used in dedicated panel) */
fullHeight?: boolean
}>()
const emit = defineEmits<{
@@ -164,6 +206,23 @@ const emit = defineEmits<{
const { t } = useI18n()
const { displayedDetailsMap } = useErrorReport(() => card)
const { findOnGitHub, contactSupport: handleGetHelp } = useErrorActions()
const runtimeDetailsExpanded = ref(true)
const hasRuntimeError = computed(() =>
card.errors.some((error) => error.isRuntimeError)
)
const isRuntimeDisclosureExpanded = computed(
() => compact || runtimeDetailsExpanded.value
)
const runtimeDetailsControlIds = computed(() =>
card.errors
.map((error, idx) => (error.isRuntimeError ? getRuntimeDetailsId(idx) : ''))
.filter(Boolean)
.join(' ')
)
function toggleRuntimeDetails() {
runtimeDetailsExpanded.value = !runtimeDetailsExpanded.value
}
function handleLocateNode() {
if (card.nodeId) {
@@ -179,7 +238,7 @@ function handleEnterSubgraph() {
function handleCopyError(idx: number) {
const details = displayedDetailsMap.value[idx]
const message = getDisplayMessage(card.errors[idx])
const message = getCopyMessage(card.errors[idx])
emit('copyToClipboard', [message, details].filter(Boolean).join('\n\n'))
}
@@ -187,7 +246,26 @@ function handleCheckGithub(error: ErrorItem) {
findOnGitHub(error.message)
}
function getDisplayMessage(error: ErrorItem | undefined) {
function getCopyMessage(error: ErrorItem | undefined) {
return error?.displayMessage ?? error?.message
}
function getInlineMessage(error: ErrorItem | undefined) {
if (!error || error.displayMessage) return undefined
return error.message
}
function getInlineItemLabel(error: ErrorItem | undefined) {
if (!error || error.isRuntimeError) return undefined
return error.displayItemLabel
}
function getInlineDetails(error: ErrorItem | undefined, idx: number) {
if (getInlineItemLabel(error)) return undefined
return displayedDetailsMap.value[idx]
}
function getRuntimeDetailsId(idx: number) {
return `${card.id}-runtime-details-${idx}`
}
</script>

View File

@@ -1,15 +1,18 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { render, screen, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import TabErrors from './TabErrors.vue'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { MissingNodeType } from '@/types/comfy'
const mockFocusNode = vi.hoisted(() => vi.fn())
const mockEnterSubgraph = vi.hoisted(() => vi.fn())
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
@@ -38,6 +41,13 @@ vi.mock('@/services/litegraphService', () => ({
}))
}))
vi.mock('@/composables/canvas/useFocusNode', () => ({
useFocusNode: vi.fn(() => ({
focusNode: mockFocusNode,
enterSubgraph: mockEnterSubgraph
}))
}))
vi.mock('@/platform/missingModel/missingModelDownload', () => ({
downloadModel: vi.fn(),
fetchModelMetadata: vi.fn().mockResolvedValue({
@@ -52,6 +62,7 @@ describe('TabErrors.vue', () => {
let i18n: ReturnType<typeof createI18n>
beforeEach(() => {
vi.clearAllMocks()
i18n = createI18n({
legacy: false,
locale: 'en',
@@ -59,11 +70,22 @@ describe('TabErrors.vue', () => {
en: {
g: {
workflow: 'Workflow',
copy: 'Copy'
copy: 'Copy',
details: 'Details',
findOnGithub: 'Find on GitHub',
getHelpAction: 'Get Help'
},
rightSidePanel: {
noErrors: 'No errors',
noneSearchDesc: 'No results found',
errorHelp: 'Error help',
errorLog: 'Error log',
findOnGithubTooltip: 'Search GitHub issues',
getHelpTooltip: 'Get help',
info: 'Info',
infoFor: 'Info for {item}',
locateNode: 'Locate node',
locateNodeFor: 'Locate {item}',
missingModels: {
missingModelsTitle: 'Missing Models',
downloadAll: 'Download all',
@@ -144,29 +166,111 @@ describe('TabErrors.vue', () => {
expect(screen.queryByText('Error details')).not.toBeInTheDocument()
})
it('renders node validation errors grouped by class_type', async () => {
it('renders node validation errors grouped by catalog copy', async () => {
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
vi.mocked(getNodeByExecutionId).mockReturnValue({
title: 'CLIP Text Encode'
} as ReturnType<typeof getNodeByExecutionId>)
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) => {
const titles: Record<string, string> = {
'1': 'KSampler',
'2': 'CLIP Text Encode'
}
return {
title: titles[String(nodeId)] ?? ''
} as ReturnType<typeof getNodeByExecutionId>
})
renderComponent({
const { user } = renderComponent({
executionError: {
lastNodeErrors: {
'6': {
'2': {
class_type: 'CLIPTextEncode',
errors: [
{ message: 'Required input is missing', details: 'Input: text' }
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'Input: clip',
extra_info: {
input_name: 'clip'
}
}
]
},
'1': {
class_type: 'KSampler',
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'Input: positive',
extra_info: {
input_name: 'positive'
}
},
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'Input: model',
extra_info: {
input_name: 'model'
}
}
]
}
}
}
})
expect(screen.getByText('CLIPTextEncode')).toBeInTheDocument()
expect(screen.getByText('#6')).toBeInTheDocument()
expect(screen.getByText('CLIP Text Encode')).toBeInTheDocument()
expect(screen.getByText('Required input is missing')).toBeInTheDocument()
expect(screen.getByText('Missing connection')).toBeInTheDocument()
expect(screen.getByText('(3)')).toBeInTheDocument()
expect(
screen.getAllByText(
'Required input slots have no connection feeding them.'
)
).toHaveLength(1)
expect(screen.queryByText('#1')).not.toBeInTheDocument()
expect(screen.queryByText('#2')).not.toBeInTheDocument()
expect(screen.queryByText('KSampler')).not.toBeInTheDocument()
expect(screen.queryByText('CLIP Text Encode')).not.toBeInTheDocument()
const itemRows = screen.getAllByRole('listitem')
expect(itemRows).toHaveLength(3)
expect(itemRows[0]).toHaveTextContent('KSampler - model')
expect(itemRows[1]).toHaveTextContent('KSampler - positive')
expect(itemRows[2]).toHaveTextContent('CLIP Text Encode - clip')
const infoButton = within(itemRows[1]).getByRole('button', {
name: 'Info for KSampler - positive'
})
await user.click(infoButton)
const itemDetail = screen.getByText(
'KSampler is missing a required input: positive'
)
expect(infoButton).toHaveAttribute(
'aria-controls',
itemDetail.getAttribute('id')
)
const labelLocateButton = within(itemRows[1]).getByRole('button', {
name: 'KSampler - positive'
})
await user.click(labelLocateButton)
expect(mockFocusNode.mock.calls.at(-1)?.[0]).toBe('1')
const iconLocateButton = within(itemRows[2]).getByRole('button', {
name: 'Locate CLIP Text Encode - clip'
})
await user.click(iconLocateButton)
expect(mockFocusNode.mock.calls.at(-1)?.[0]).toBe('2')
expect(
screen.queryByText('Required input is missing')
).not.toBeInTheDocument()
expect(screen.queryByText('Input: model')).not.toBeInTheDocument()
expect(screen.queryByText('Input: positive')).not.toBeInTheDocument()
expect(screen.queryByText('Input: clip')).not.toBeInTheDocument()
})
it('renders runtime execution errors from WebSocket', async () => {
@@ -175,7 +279,7 @@ describe('TabErrors.vue', () => {
title: 'KSampler'
} as ReturnType<typeof getNodeByExecutionId>)
renderComponent({
const { user } = renderComponent({
executionError: {
lastExecutionError: {
prompt_id: 'abc',
@@ -190,12 +294,16 @@ describe('TabErrors.vue', () => {
})
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('#10')).toBeInTheDocument()
expect(screen.getByText('Execution failed')).toBeInTheDocument()
expect(
screen.getByText('Node threw an error during execution.')
).toBeInTheDocument()
expect(screen.getByText('Error log')).toBeInTheDocument()
expect(screen.getByText(/Line 1/)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Details' }))
expect(screen.queryByText(/Line 1/)).not.toBeInTheDocument()
})
it('filters errors based on search query', async () => {
@@ -230,7 +338,7 @@ describe('TabErrors.vue', () => {
expect(screen.queryByText('KSampler')).not.toBeInTheDocument()
})
it('calls copyToClipboard when copy button is clicked', async () => {
it('calls copyToClipboard when a runtime error copy button is clicked', async () => {
const { useCopyToClipboard } =
await import('@/composables/useCopyToClipboard')
const mockCopy = vi.fn()
@@ -238,21 +346,26 @@ describe('TabErrors.vue', () => {
const { user } = renderComponent({
executionError: {
lastNodeErrors: {
'1': {
class_type: 'TestNode',
errors: [{ message: 'Test message', details: 'Test details' }]
}
lastExecutionError: {
prompt_id: 'abc',
node_id: '1',
node_type: 'TestNode',
exception_message: 'Test message',
exception_type: 'RuntimeError',
traceback: ['Test details'],
timestamp: Date.now()
}
}
})
await user.click(screen.getByTestId('error-card-copy'))
expect(mockCopy).toHaveBeenCalledWith('Test message\n\nTest details')
expect(mockCopy).toHaveBeenCalledWith(
'Node threw an error during execution.\n\nTest details'
)
})
it('renders single runtime error outside accordion in full-height panel', async () => {
it('renders a single runtime error in the normal execution group', async () => {
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
vi.mocked(getNodeByExecutionId).mockReturnValue({
title: 'KSampler'
@@ -274,7 +387,11 @@ describe('TabErrors.vue', () => {
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('Execution failed')).toBeInTheDocument()
expect(screen.getByTestId('runtime-error-panel')).toBeInTheDocument()
expect(
within(screen.getByTestId('error-group-execution')).getByTestId(
'runtime-error-panel'
)
).toBeInTheDocument()
expect(screen.getAllByText('Execution failed')).toHaveLength(1)
})

View File

@@ -11,32 +11,7 @@
/>
</div>
<!-- Runtime error: full-height panel outside accordion -->
<div
v-if="singleRuntimeErrorCard"
data-testid="runtime-error-panel"
aria-live="polite"
class="flex min-h-0 flex-1 flex-col overflow-hidden px-4 py-3"
>
<div
class="shrink-0 pb-2 text-sm font-semibold text-destructive-background-hover"
>
{{ singleRuntimeErrorGroup?.displayTitle }}
</div>
<ErrorNodeCard
:key="singleRuntimeErrorCard.id"
:card="singleRuntimeErrorCard"
:show-node-id-badge="showNodeIdBadge"
full-height
class="min-h-0 flex-1"
@locate-node="handleLocateNode"
@enter-subgraph="handleEnterSubgraph"
@copy-to-clipboard="copyToClipboard"
/>
</div>
<!-- Scrollable content (non-runtime or mixed errors) -->
<div v-else class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
<div class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
<TransitionGroup tag="div" name="list-scale" class="relative">
<div
v-if="filteredGroups.length === 0"
@@ -70,10 +45,13 @@
{{ group.displayTitle }}
</span>
<span
v-if="group.type === 'execution' && group.cards.length > 1"
v-if="
group.type === 'execution' &&
getExecutionGroupCount(group) > 1
"
class="text-destructive-background-hover"
>
({{ group.cards.length }})
({{ getExecutionGroupCount(group) }})
</span>
</span>
<Button
@@ -155,7 +133,7 @@
</template>
<div
v-if="group.type !== 'execution' && group.displayMessage"
v-if="group.displayMessage"
data-testid="error-group-display-message"
class="px-4 pt-1 pb-3"
>
@@ -186,12 +164,79 @@
/>
<!-- Execution Errors -->
<div v-if="group.type === 'execution'" class="space-y-3 px-4">
<div v-if="isExecutionItemListGroup(group)" class="px-4">
<ul class="m-0 list-none space-y-1 p-0">
<li
v-for="item in getExecutionItemList(group)"
:key="item.key"
class="min-w-0"
>
<div class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 flex-1 items-center gap-1">
<button
v-tooltip.top="{
value: item.displayDetails || undefined,
showDelay: 300
}"
type="button"
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
@click="handleLocateNode(item.nodeId)"
>
{{ item.label }}
</button>
<Button
v-if="item.displayDetails"
variant="textonly"
size="icon-sm"
:class="
cn(
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground',
isExecutionItemDetailExpanded(item.key) &&
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
)
"
:aria-label="
t('rightSidePanel.infoFor', { item: item.label })
"
:aria-controls="getExecutionItemDetailId(item.key)"
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
@click.stop="toggleExecutionItemDetail(item.key)"
>
<i class="icon-[lucide--info] size-3.5" />
</Button>
</span>
<Button
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="
t('rightSidePanel.locateNodeFor', { item: item.label })
"
@click.stop="handleLocateNode(item.nodeId)"
>
<i class="icon-[lucide--locate] size-4" />
</Button>
</div>
<TransitionCollapse>
<p
v-if="
item.displayDetails &&
isExecutionItemDetailExpanded(item.key)
"
:id="getExecutionItemDetailId(item.key)"
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ item.displayDetails }}
</p>
</TransitionCollapse>
</li>
</ul>
</div>
<div v-else-if="group.type === 'execution'" class="space-y-3 px-4">
<ErrorNodeCard
v-for="card in group.cards"
:key="card.id"
:card="card"
:show-node-id-badge="showNodeIdBadge"
:compact="isSingleNodeSelected"
@locate-node="handleLocateNode"
@enter-subgraph="handleEnterSubgraph"
@@ -255,6 +300,7 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useFocusNode } from '@/composables/canvas/useFocusNode'
@@ -266,6 +312,7 @@ import { NodeBadgeMode } from '@/types/nodeSource'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
import TransitionCollapse from '../layout/TransitionCollapse.vue'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import ErrorNodeCard from './ErrorNodeCard.vue'
import MissingNodeCard from './MissingNodeCard.vue'
@@ -285,6 +332,13 @@ import type { SwapNodeGroup } from './useErrorGroups'
import type { ErrorGroup } from './types'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
interface ExecutionItemListEntry {
key: string
nodeId: string
label: string
displayDetails?: string
}
const ErrorPanelSurveyCta =
isNightly && !isCloud && !isDesktop
? defineAsyncComponent(
@@ -307,6 +361,7 @@ const { isInstalling: isInstallingAll, installAllPacks: installAll } =
const { replaceGroup, replaceAllGroups } = useNodeReplacement()
const searchQuery = ref('')
const expandedExecutionItemDetailKeys = ref(new Set<string>())
const isSearching = computed(() => searchQuery.value.trim() !== '')
const fullSizeGroupTypes = new Set([
@@ -325,6 +380,78 @@ const showNodeIdBadge = computed(
NodeBadgeMode.None
)
function isExecutionItemListGroup(group: ErrorGroup) {
return (
group.type === 'execution' &&
group.cards.length > 0 &&
group.cards.every(
(card) =>
card.nodeId &&
card.errors.length > 0 &&
card.errors.every(
(error) => !error.isRuntimeError && Boolean(error.displayItemLabel)
)
)
)
}
function getExecutionItemList(group: ErrorGroup): ExecutionItemListEntry[] {
if (group.type !== 'execution') return []
const items: ExecutionItemListEntry[] = []
for (const card of group.cards) {
if (!card.nodeId) continue
for (let idx = 0; idx < card.errors.length; idx++) {
const error = card.errors[idx]
const label = error.displayItemLabel
if (!label) continue
items.push({
key: `${card.id}:${idx}`,
nodeId: card.nodeId,
label,
displayDetails: error.displayDetails
})
}
}
return items.sort(compareExecutionItemListEntry)
}
function compareExecutionItemListEntry(
a: ExecutionItemListEntry,
b: ExecutionItemListEntry
) {
return (
a.nodeId.localeCompare(b.nodeId, undefined, { numeric: true }) ||
a.label.localeCompare(b.label)
)
}
function getExecutionGroupCount(group: ErrorGroup) {
if (group.type !== 'execution') return 0
if (isExecutionItemListGroup(group)) {
return group.cards.reduce((count, card) => count + card.errors.length, 0)
}
return group.cards.length
}
function isExecutionItemDetailExpanded(key: string) {
return expandedExecutionItemDetailKeys.value.has(key)
}
function toggleExecutionItemDetail(key: string) {
const nextKeys = new Set(expandedExecutionItemDetailKeys.value)
if (nextKeys.has(key)) {
nextKeys.delete(key)
} else {
nextKeys.add(key)
}
expandedExecutionItemDetailKeys.value = nextKeys
}
function getExecutionItemDetailId(key: string) {
return `execution-item-detail-${key}`
}
const {
allErrorGroups,
tabErrorGroups,
@@ -356,20 +483,6 @@ function handleMissingModelRefresh() {
void missingModelStore.refreshMissingModels()
}
const singleRuntimeErrorGroup = computed(() => {
if (filteredGroups.value.length !== 1) return null
const group = filteredGroups.value[0]
const isSoleRuntimeError =
group.type === 'execution' &&
group.cards.length === 1 &&
group.cards[0].errors.every((e) => e.isRuntimeError)
return isSoleRuntimeError ? group : null
})
const singleRuntimeErrorCard = computed(
() => singleRuntimeErrorGroup.value?.cards[0] ?? null
)
const isAllCollapsed = computed({
get() {
return filteredGroups.value.every((g) => isSectionCollapsed(g.groupKey))

View File

@@ -23,6 +23,9 @@ vi.mock('@/utils/graphTraversalUtil', () => ({
}))
const mockIsCloud = vi.hoisted(() => ({ value: false }))
const unknownValidationMessage = vi.hoisted(
() => 'A node returned a validation error ComfyUI does not recognize.'
)
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
@@ -43,6 +46,18 @@ vi.mock('@/i18n', () => {
'Required input missing',
'errorCatalog.validationErrors.required_input_missing.toastMessage':
'{nodeName} is missing a required input: {inputName}',
'errorCatalog.validationErrors.unknown_validation_error.title':
'Validation failed',
'errorCatalog.validationErrors.unknown_validation_error.message':
unknownValidationMessage,
'errorCatalog.validationErrors.unknown_validation_error.detailsWithRawDetails':
'{nodeName} returned an unrecognized validation error ({errorType}): {rawDetails}',
'errorCatalog.validationErrors.unknown_validation_error.itemLabel':
'{nodeName}',
'errorCatalog.validationErrors.unknown_validation_error.toastTitle':
'Validation failed',
'errorCatalog.validationErrors.unknown_validation_error.toastMessage':
'{nodeName} returned an unrecognized validation error.',
'errorCatalog.promptErrors.prompt_no_outputs.title':
'Prompt has no outputs',
'errorCatalog.promptErrors.prompt_no_outputs.desc':
@@ -384,7 +399,7 @@ describe('useErrorGroups', () => {
expect(swapIdx).toBeLessThan(missingIdx)
})
it('includes execution error groups from node errors', async () => {
it('uses fallback catalog grouping for unknown node validation errors', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
@@ -405,8 +420,8 @@ describe('useErrorGroups', () => {
(g) => g.type === 'execution'
)
expect(execGroups.length).toBeGreaterThan(0)
expect(execGroups[0].groupKey).toBe('execution:KSampler')
expect(execGroups[0].displayTitle).toBe('KSampler')
expect(execGroups[0].groupKey).toBe('execution:unknown_validation_error')
expect(execGroups[0].displayTitle).toBe('Validation failed')
})
it('resolves required_input_missing item display copy', async () => {
@@ -455,6 +470,55 @@ describe('useErrorGroups', () => {
)
})
it('groups node validation errors by catalog id across node types', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'model',
extra_info: {
input_name: 'model'
}
}
]
},
'2': {
class_type: 'CLIPLoader',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'clip',
extra_info: {
input_name: 'clip'
}
}
]
}
}
await nextTick()
const execGroups = groups.allErrorGroups.value.filter(
(g) => g.type === 'execution'
)
expect(execGroups).toHaveLength(1)
const [group] = execGroups
expect(group.groupKey).toBe('execution:missing_connection')
expect(group.displayTitle).toBe('Missing connection')
expect(group.cards.map((card) => card.title)).toEqual([
'KSampler',
'CLIPLoader'
])
expect(group.cards.flatMap((card) => card.errors)).toHaveLength(2)
})
it('uses general execution_failed display fields for unrecognized runtime execution errors', async () => {
mockIsCloud.value = true
const { store, groups } = createErrorGroups()
@@ -716,7 +780,7 @@ describe('useErrorGroups', () => {
expect(groups.groupedErrorMessages.value).toEqual([])
})
it('collects unique error messages from node errors', async () => {
it('collects unique display messages from node errors', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
@@ -736,10 +800,7 @@ describe('useErrorGroups', () => {
await nextTick()
const messages = groups.groupedErrorMessages.value
expect(messages).toContain('Error A')
expect(messages).toContain('Error B')
// Deduplication: Error A appears twice but should only be listed once
expect(messages.filter((m) => m === 'Error A')).toHaveLength(1)
expect(messages).toEqual([unknownValidationMessage])
})
it('includes missing node group display message', async () => {

View File

@@ -30,6 +30,7 @@ import type {
MissingModelCandidate,
MissingModelGroup
} from '@/platform/missingModel/types'
import type { ResolvedCatalogErrorMessage } from '@/platform/errorCatalog/types'
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
import { groupCandidatesByName } from '@/platform/missingModel/missingModelScan'
import { groupCandidatesByMediaType } from '@/platform/missingMedia/missingMediaScan'
@@ -43,7 +44,6 @@ import {
} from '@/types/nodeIdentification'
const PROMPT_CARD_ID = '__prompt__'
const SINGLE_GROUP_KEY = '__single__'
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
const RESOLVING = '__RESOLVING__'
@@ -66,6 +66,7 @@ export interface SwapNodeGroup {
interface GroupEntry {
type: 'execution'
displayTitle: string
displayMessage?: string
priority: number
cards: Map<string, ErrorCardData>
}
@@ -75,10 +76,14 @@ interface ErrorSearchItem {
cardIndex: number
searchableNodeId: string
searchableNodeTitle: string
searchableRawMessage: string
searchableRawDetails: string
searchableMessage: string
searchableDetails: string
}
type CataloguedErrorItem = ErrorItem & ResolvedCatalogErrorMessage
/**
* Resolve display info for a node by its execution ID.
* For group node internals, resolves the parent group node's title instead.
@@ -106,17 +111,21 @@ function getOrCreateGroup(
groupsMap: Map<string, GroupEntry>,
groupKey: string,
displayTitle = groupKey,
priority = 1
priority = 1,
displayMessage?: string
): Map<string, ErrorCardData> {
let entry = groupsMap.get(groupKey)
if (!entry) {
entry = {
type: 'execution',
displayTitle,
displayMessage,
priority,
cards: new Map()
}
groupsMap.set(groupKey, entry)
} else if (!entry.displayMessage && displayMessage) {
entry.displayMessage = displayMessage
}
return entry.cards
}
@@ -138,44 +147,6 @@ function createErrorCard(
}
}
/**
* In single-node mode, regroup cards by error message instead of class_type.
* This lets the user see "what kinds of errors this node has" at a glance.
*/
function regroupByErrorMessage(
groupsMap: Map<string, GroupEntry>
): Map<string, GroupEntry> {
const allCards = Array.from(groupsMap.values()).flatMap((g) =>
Array.from(g.cards.values())
)
const cardErrorPairs = allCards.flatMap((card) =>
card.errors.map((error) => ({ card, error }))
)
const messageMap = new Map<string, GroupEntry>()
for (const { card, error } of cardErrorPairs) {
addCardErrorToGroup(messageMap, card, error)
}
return messageMap
}
function addCardErrorToGroup(
messageMap: Map<string, GroupEntry>,
card: ErrorCardData,
error: ErrorItem
) {
const displayTitle =
error.displayTitle ?? error.displayMessage ?? error.message
const groupKey = error.catalogId ?? displayTitle
const group = getOrCreateGroup(messageMap, groupKey, displayTitle, 1)
if (!group.has(card.id)) {
group.set(card.id, { ...card, errors: [] })
}
group.get(card.id)?.errors.push(error)
}
function compareNodeId(a: ErrorCardData, b: ErrorCardData): number {
return compareExecutionId(a.nodeId, b.nodeId)
}
@@ -186,6 +157,7 @@ function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
type: 'execution' as const,
groupKey: `execution:${rawGroupKey}`,
displayTitle: groupData.displayTitle,
displayMessage: groupData.displayMessage,
cards: Array.from(groupData.cards.values()).sort(compareNodeId),
priority: groupData.priority
}))
@@ -209,6 +181,8 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
cardIndex: ci,
searchableNodeId: card.nodeId ?? '',
searchableNodeTitle: card.nodeTitle ?? '',
searchableRawMessage: card.errors.map((e) => e.message).join(' '),
searchableRawDetails: card.errors.map((e) => e.details).join(' '),
searchableMessage: card.errors
.map((e) =>
[e.displayTitle, e.displayMessage, e.message]
@@ -225,9 +199,11 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
const fuseOptions: IFuseOptions<ErrorSearchItem> = {
keys: [
{ name: 'searchableNodeId', weight: 0.3 },
{ name: 'searchableNodeTitle', weight: 0.3 },
{ name: 'searchableMessage', weight: 0.3 },
{ name: 'searchableRawMessage', weight: 0.3 },
{ name: 'searchableNodeId', weight: 0.2 },
{ name: 'searchableNodeTitle', weight: 0.2 },
{ name: 'searchableMessage', weight: 0.2 },
{ name: 'searchableRawDetails', weight: 0.1 },
{ name: 'searchableDetails', weight: 0.1 }
],
threshold: 0.3
@@ -333,18 +309,23 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
nodeId: string,
classType: string,
idPrefix: string,
errors: ErrorItem[],
error: CataloguedErrorItem,
filterBySelection = false
) {
if (filterBySelection && !isErrorInSelection(nodeId)) return
const groupKey = isSingleNodeSelected.value ? SINGLE_GROUP_KEY : classType
const cards = getOrCreateGroup(groupsMap, groupKey, classType, 1)
const cards = getOrCreateGroup(
groupsMap,
error.catalogId,
error.displayTitle ?? classType,
1,
error.displayMessage
)
if (!cards.has(nodeId)) {
cards.set(nodeId, createErrorCard(nodeId, classType, idPrefix))
}
const card = cards.get(nodeId)
if (!card) return
card.errors.push(...errors)
card.errors.push(error)
}
function processPromptError(
@@ -368,7 +349,8 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
groupsMap,
`prompt:${error.type}`,
groupDisplayTitle,
0
0,
resolvedDisplay.displayMessage
)
// Prompt errors are not tied to a node, so they bypass addNodeErrorToGroup.
@@ -395,13 +377,13 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
)) {
const nodeDisplayName =
resolveNodeInfo(nodeId).title || nodeError.class_type
addNodeErrorToGroup(
groupsMap,
nodeId,
nodeError.class_type,
'node',
nodeError.errors.map((e) => {
return {
for (const e of nodeError.errors) {
addNodeErrorToGroup(
groupsMap,
nodeId,
nodeError.class_type,
'node',
{
message: e.message,
details: e.details ?? undefined,
...resolveRunErrorMessage({
@@ -409,10 +391,10 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
error: e,
nodeDisplayName
})
}
}),
filterBySelection
)
},
filterBySelection
)
}
}
}
@@ -428,20 +410,18 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
String(e.node_id),
e.node_type,
'exec',
[
{
message: `${e.exception_type}: ${e.exception_message}`,
details: e.traceback.join('\n'),
isRuntimeError: true,
exceptionType: e.exception_type,
...resolveRunErrorMessage({
kind: 'execution',
error: e,
nodeDisplayName:
resolveNodeInfo(String(e.node_id)).title || e.node_type
})
}
],
{
message: `${e.exception_type}: ${e.exception_message}`,
details: e.traceback.join('\n'),
isRuntimeError: true,
exceptionType: e.exception_type,
...resolveRunErrorMessage({
kind: 'execution',
error: e,
nodeDisplayName:
resolveNodeInfo(String(e.node_id)).title || e.node_type
})
},
filterBySelection
)
}
@@ -867,10 +847,6 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
processNodeErrors(groupsMap, true)
processExecutionError(groupsMap, true)
const executionGroups = isSingleNodeSelected.value
? toSortedGroups(regroupByErrorMessage(groupsMap))
: toSortedGroups(groupsMap)
const filterByNode = selectedNodeInfo.value.nodeIds !== null
// Missing nodes are intentionally unfiltered — they represent
@@ -883,7 +859,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
...(filterByNode
? buildMissingMediaGroupsFiltered()
: buildMissingMediaGroups()),
...executionGroups
...toSortedGroups(groupsMap)
]
})

View File

@@ -3,7 +3,14 @@ import { nextTick, reactive, ref, shallowRef } from 'vue'
import type { Pinia } from 'pinia'
import { getActivePinia } from 'pinia'
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import {
getLoad3dOutputCache,
isLoad3dSceneDirty,
markLoad3dSceneDirty,
nodeToLoad3dMap,
setLoad3dOutputCache,
useLoad3d
} from '@/composables/useLoad3d'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
@@ -186,6 +193,7 @@ describe('useLoad3d', () => {
resetGizmoTransform: vi.fn(),
applyGizmoTransform: vi.fn(),
fitToViewer: vi.fn(),
centerCameraOnModel: vi.fn(),
getGizmoTransform: vi.fn().mockReturnValue({
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
@@ -1742,4 +1750,184 @@ describe('useLoad3d', () => {
expect(originalOnRemoved).toHaveBeenCalledTimes(1)
})
})
describe('scene dirty tracking', () => {
const fakeCache = {
image: 'threed/scene-1.png [temp]',
mask: 'threed/scene_mask-1.png [temp]',
normal: 'threed/scene_normal-1.png [temp]',
camera_info: null,
recording: '',
model_3d_info: []
}
it('treats an unseen node as dirty by default', () => {
const fresh = createMockLGraphNode({ properties: {} })
expect(isLoad3dSceneDirty(fresh)).toBe(true)
})
it('markLoad3dSceneDirty sets the node dirty', () => {
const fresh = createMockLGraphNode({ properties: {} })
setLoad3dOutputCache(fresh, fakeCache)
expect(isLoad3dSceneDirty(fresh)).toBe(false)
markLoad3dSceneDirty(fresh)
expect(isLoad3dSceneDirty(fresh)).toBe(true)
})
it('setLoad3dOutputCache stores the output and clears dirty', () => {
const fresh = createMockLGraphNode({ properties: {} })
setLoad3dOutputCache(fresh, fakeCache)
expect(getLoad3dOutputCache(fresh)).toBe(fakeCache)
expect(isLoad3dSceneDirty(fresh)).toBe(false)
})
it('two nodes keep independent dirty state', () => {
const a = createMockLGraphNode({ properties: {} })
const b = createMockLGraphNode({ properties: {} })
setLoad3dOutputCache(a, fakeCache)
expect(isLoad3dSceneDirty(a)).toBe(false)
expect(isLoad3dSceneDirty(b)).toBe(true)
markLoad3dSceneDirty(a)
expect(isLoad3dSceneDirty(a)).toBe(true)
expect(isLoad3dSceneDirty(b)).toBe(true)
})
it('markLoad3dSceneDirty on null is a no-op', () => {
expect(() => markLoad3dSceneDirty(null)).not.toThrow()
})
it('sceneConfig changes flip the node dirty', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
setLoad3dOutputCache(mockNode, fakeCache)
expect(isLoad3dSceneDirty(mockNode)).toBe(false)
composable.sceneConfig.value.backgroundColor = '#ffffff'
await nextTick()
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
})
it('cameraChanged event marks the node dirty', async () => {
let cameraChangedHandler: ((state: unknown) => void) | undefined
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
(event: string, handler: unknown) => {
if (event === 'cameraChanged') {
cameraChangedHandler = handler as (state: unknown) => void
}
}
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
setLoad3dOutputCache(mockNode, fakeCache)
expect(isLoad3dSceneDirty(mockNode)).toBe(false)
cameraChangedHandler!({ position: { x: 1, y: 2, z: 3 } })
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
})
it('handleStopRecording marks dirty when a recording was produced', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
setLoad3dOutputCache(mockNode, fakeCache)
vi.mocked(mockLoad3d.getRecordingDuration!).mockReturnValue(5)
composable.handleStopRecording()
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
})
it('handleStopRecording leaves dirty alone when no recording was produced', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
setLoad3dOutputCache(mockNode, fakeCache)
vi.mocked(mockLoad3d.getRecordingDuration!).mockReturnValue(0)
composable.handleStopRecording()
expect(isLoad3dSceneDirty(mockNode)).toBe(false)
})
it('handleClearRecording marks dirty', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
setLoad3dOutputCache(mockNode, fakeCache)
composable.handleClearRecording()
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
})
it('handleStartRecording marks dirty so an in-progress recording forces a re-capture', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
setLoad3dOutputCache(mockNode, fakeCache)
expect(isLoad3dSceneDirty(mockNode)).toBe(false)
await composable.handleStartRecording()
expect(mockLoad3d.startRecording).toHaveBeenCalledTimes(1)
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
})
it('handleCenterCameraOnModel marks dirty', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
setLoad3dOutputCache(mockNode, fakeCache)
expect(isLoad3dSceneDirty(mockNode)).toBe(false)
composable.handleCenterCameraOnModel()
expect(mockLoad3d.centerCameraOnModel).toHaveBeenCalledTimes(1)
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
})
it('handleSeek marks dirty when the animation has a duration', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
const calls = vi.mocked(mockLoad3d.addEventListener!).mock.calls
const match = calls.find(([event]) => event === 'animationProgressChange')
const animationProgressHandler = match![1] as (d: {
progress: number
currentTime: number
duration: number
}) => void
animationProgressHandler({ progress: 0, currentTime: 0, duration: 10 })
setLoad3dOutputCache(mockNode, fakeCache)
composable.handleSeek(50)
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
})
})
})

View File

@@ -22,6 +22,7 @@ import type {
GizmoMode,
LightConfig,
MaterialMode,
Model3DInfo,
ModelConfig,
SceneConfig,
UpDirection
@@ -38,6 +39,38 @@ import { useLoad3dService } from '@/services/load3dService'
type Load3dReadyCallback = (load3d: Load3d) => void
export const nodeToLoad3dMap = new Map<LGraphNode, Load3d>()
export type Load3dCachedOutput = {
image: string
mask: string
normal: string
camera_info: CameraState | null
recording: string
model_3d_info: Model3DInfo
}
const load3dSceneDirty = new WeakMap<LGraphNode, boolean>()
const load3dOutputCache = new WeakMap<LGraphNode, Load3dCachedOutput>()
export const markLoad3dSceneDirty = (node: LGraphNode | null): void => {
if (!node) return
load3dSceneDirty.set(node, true)
}
export const isLoad3dSceneDirty = (node: LGraphNode): boolean =>
load3dSceneDirty.get(node) !== false
export const getLoad3dOutputCache = (
node: LGraphNode
): Load3dCachedOutput | undefined => load3dOutputCache.get(node)
export const setLoad3dOutputCache = (
node: LGraphNode,
output: Load3dCachedOutput
): void => {
load3dOutputCache.set(node, output)
load3dSceneDirty.set(node, false)
}
const pendingCallbacks = new Map<LGraphNode, Load3dReadyCallback[]>()
const persistentReadyCallbacks = new Map<LGraphNode, Load3dReadyCallback[]>()
@@ -69,6 +102,11 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
let load3d: Load3d | null = null
let isFirstModelLoad = true
const markDirty = () => {
const rawNode = toRaw(nodeRef.value)
if (rawNode) markLoad3dSceneDirty(rawNode as LGraphNode)
}
const debouncedHandleResize = useDebounceFn(() => {
load3d?.handleResize()
}, 150)
@@ -371,6 +409,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
if (n) {
n.properties['Light Config'] = lightConfig.value
}
markDirty()
}
const waitForLoad3d = (callback: Load3dReadyCallback) => {
@@ -415,6 +454,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
if (nodeRef.value) {
nodeRef.value.properties['Scene Config'] = newValue
}
markDirty()
},
{ deep: true }
)
@@ -455,6 +495,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
if (nodeRef.value) {
nodeRef.value.properties['Model Config'] = newValue
}
markDirty()
},
{ deep: true }
)
@@ -488,6 +529,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
load3d.toggleCamera(newValue.cameraType)
load3d.setFOV(newValue.fov)
}
markDirty()
},
{ deep: true }
)
@@ -547,18 +589,21 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
if (load3d) {
load3d.toggleAnimation(newValue)
}
markDirty()
})
watch(selectedSpeed, (newValue) => {
if (load3d && newValue) {
load3d.setAnimationSpeed(newValue)
}
markDirty()
})
watch(selectedAnimation, (newValue) => {
if (load3d && newValue !== undefined) {
load3d.updateSelectedAnimation(newValue)
}
markDirty()
})
const handleMouseEnter = () => {
@@ -573,6 +618,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
if (load3d) {
await load3d.startRecording()
isRecording.value = true
markDirty()
}
}
@@ -582,6 +628,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
isRecording.value = false
recordingDuration.value = load3d.getRecordingDuration()
hasRecording.value = recordingDuration.value > 0
if (hasRecording.value) markDirty()
}
}
@@ -598,6 +645,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
load3d.clearRecording()
hasRecording.value = false
recordingDuration.value = 0
markDirty()
}
}
@@ -605,6 +653,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
if (load3d && animationDuration.value > 0) {
const time = (progress / 100) * animationDuration.value
load3d.setAnimationTime(time)
markDirty()
}
}
@@ -936,6 +985,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
state: cameraState
}
}
markLoad3dSceneDirty(node)
}
},
gizmoTransformChange: (data: GizmoConfig) => {
@@ -976,7 +1026,9 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
}
const handleCenterCameraOnModel = () => {
load3d?.centerCameraOnModel()
if (!load3d) return
load3d.centerCameraOnModel()
markDirty()
}
const handleResetGizmoTransform = () => {

View File

@@ -38,13 +38,27 @@ vi.mock('@/services/load3dService', () => ({
})
}))
vi.mock('@/composables/useLoad3d', () => ({
useLoad3d: () => ({
waitForLoad3d: waitForLoad3dMock,
onLoad3dReady: onLoad3dReadyMock
}),
nodeToLoad3dMap
}))
vi.mock('@/composables/useLoad3d', () => {
const sceneDirty = new WeakMap<LGraphNode, boolean>()
const outputCache = new WeakMap<LGraphNode, unknown>()
return {
useLoad3d: () => ({
waitForLoad3d: waitForLoad3dMock,
onLoad3dReady: onLoad3dReadyMock
}),
nodeToLoad3dMap,
markLoad3dSceneDirty: (node: LGraphNode | null) => {
if (!node) return
sceneDirty.set(node, true)
},
isLoad3dSceneDirty: (node: LGraphNode) => sceneDirty.get(node) !== false,
getLoad3dOutputCache: (node: LGraphNode) => outputCache.get(node),
setLoad3dOutputCache: (node: LGraphNode, value: unknown) => {
outputCache.set(node, value)
sceneDirty.set(node, false)
}
}
})
vi.mock('@/extensions/core/load3d/Load3DConfiguration', () => ({
default: class {
@@ -482,13 +496,16 @@ describe('Comfy.Load3D.nodeCreated', () => {
await load3DExt.nodeCreated(node)
expect(configureMock).toHaveBeenCalledWith({
loadFolder: 'input',
modelWidget: widgets[0],
cameraState: undefined,
width: widgets[1],
height: widgets[2]
})
expect(configureMock).toHaveBeenCalledWith(
expect.objectContaining({
loadFolder: 'input',
modelWidget: widgets[0],
cameraState: undefined,
width: widgets[1],
height: widgets[2],
onSceneInvalidated: expect.any(Function)
})
)
})
it('attaches a serializeValue function to the scene widget', async () => {
@@ -1014,3 +1031,95 @@ describe('Comfy.Preview3DAdvanced.getNodeMenuItems', () => {
])
})
})
describe('Comfy.Load3D scene widget serializeValue caching', () => {
beforeEach(setupBaseMocks)
function makeFullFakeLoad3d() {
return {
getCurrentCameraType: vi.fn(() => 'perspective'),
cameraManager: { perspectiveCamera: { fov: 35 } },
getCameraState: vi.fn(() => ({ position: { x: 0, y: 0, z: 0 } })),
stopRecording: vi.fn(),
captureScene: vi.fn(async () => ({
scene: 'scene-data',
mask: 'mask-data',
normal: 'normal-data'
})),
handleResize: vi.fn(),
getModelInfo: vi.fn(() => null),
getRecordingData: vi.fn(() => null)
}
}
async function setup() {
const { load3DExt } = await loadExtensionsFresh()
const useLoad3dModule = await import('@/composables/useLoad3d')
const utilsModule = await import('@/extensions/core/load3d/Load3dUtils')
const uploadTempImage = utilsModule.default.uploadTempImage as ReturnType<
typeof vi.fn
>
let counter = 0
uploadTempImage.mockImplementation(
async (_data: unknown, kind: string) => ({
name: `${kind}-${++counter}.png`
})
)
const widgets: FakeWidget[] = [
{ name: 'model_file', value: 'm.glb' },
{ name: 'width', value: 256 },
{ name: 'height', value: 256 },
{ name: 'image', value: '' }
]
const node = makeLoad3DNode({ widgets, properties: {} })
useLoad3dModule.nodeToLoad3dMap.set(node, makeFullFakeLoad3d() as never)
await load3DExt.nodeCreated(node)
const serialize = widgets[3].serializeValue! as () => Promise<{
image: string
} | null>
return { node, serialize, uploadTempImage, useLoad3dModule }
}
it('reuses the cached output when the scene has not been dirtied', async () => {
const { node, serialize, uploadTempImage, useLoad3dModule } = await setup()
const first = await serialize()
expect(uploadTempImage).toHaveBeenCalledTimes(3)
expect(first?.image).toBe('threed/scene-1.png [temp]')
expect(useLoad3dModule.isLoad3dSceneDirty(node)).toBe(false)
expect(useLoad3dModule.getLoad3dOutputCache(node)).toBe(first)
const second = await serialize()
expect(uploadTempImage).toHaveBeenCalledTimes(3)
expect(second).toBe(first)
})
it('re-captures after the scene is marked dirty', async () => {
const { node, serialize, uploadTempImage, useLoad3dModule } = await setup()
await serialize()
expect(uploadTempImage).toHaveBeenCalledTimes(3)
useLoad3dModule.markLoad3dSceneDirty(node)
const refreshed = await serialize()
expect(uploadTempImage).toHaveBeenCalledTimes(6)
expect(refreshed?.image).toBe('threed/scene-4.png [temp]')
})
it('returns null when no load3d instance is registered for the node', async () => {
const { load3DExt } = await loadExtensionsFresh()
const widgets: FakeWidget[] = [
{ name: 'model_file', value: 'm.glb' },
{ name: 'width', value: 256 },
{ name: 'height', value: 256 },
{ name: 'image', value: '' }
]
const node = makeLoad3DNode({ widgets })
await load3DExt.nodeCreated(node)
expect(await widgets[3].serializeValue!()).toBeNull()
})
})

View File

@@ -2,7 +2,15 @@ import { nextTick } from 'vue'
import Load3D from '@/components/load3d/Load3D.vue'
import Load3DViewerContent from '@/components/load3d/Load3dViewerContent.vue'
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import {
type Load3dCachedOutput,
getLoad3dOutputCache,
isLoad3dSceneDirty,
markLoad3dSceneDirty,
nodeToLoad3dMap,
setLoad3dOutputCache,
useLoad3d
} from '@/composables/useLoad3d'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import type {
CameraConfig,
@@ -96,6 +104,8 @@ async function handleModelUpload(files: FileList, node: LGraphNode) {
modelWidget.value = uploadPath
}
markLoad3dSceneDirty(node)
} catch (error) {
console.error('Model upload failed:', error)
useToastStore().addAlert(t('toastMessages.fileUploadFailed'))
@@ -113,6 +123,7 @@ async function handleResourcesUpload(files: FileList, node: LGraphNode) {
: '3d'
await Load3dUtils.uploadMultipleFiles(files, subfolder)
markLoad3dSceneDirty(node)
} catch (error) {
console.error('Extra resources upload failed:', error)
useToastStore().addAlert(t('toastMessages.extraResourcesUploadFailed'))
@@ -319,6 +330,7 @@ useExtensionService().registerExtension({
if (modelWidget) {
modelWidget.value = LOAD3D_NONE_MODEL
}
markLoad3dSceneDirty(node)
})
}
@@ -377,7 +389,8 @@ useExtensionService().registerExtension({
modelWidget,
cameraState,
width,
height
height,
onSceneInvalidated: () => markLoad3dSceneDirty(node)
})
})
@@ -395,6 +408,11 @@ useExtensionService().registerExtension({
return null
}
if (!isLoad3dSceneDirty(node)) {
const cached = getLoad3dOutputCache(node)
if (cached) return cached
}
const cameraConfig: CameraConfig = (node.properties[
'Camera Config'
] as CameraConfig | undefined) || {
@@ -426,7 +444,7 @@ useExtensionService().registerExtension({
const modelInfo = currentLoad3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
const returnVal = {
const returnVal: Load3dCachedOutput = {
image: `threed/${data.name} [temp]`,
mask: `threed/${dataMask.name} [temp]`,
normal: `threed/${dataNormal.name} [temp]`,
@@ -443,9 +461,11 @@ useExtensionService().registerExtension({
const [recording] = await Promise.all([
Load3dUtils.uploadTempImage(recordingData, 'recording', 'mp4')
])
returnVal['recording'] = `threed/${recording.name} [temp]`
returnVal.recording = `threed/${recording.name} [temp]`
}
setLoad3dOutputCache(node, returnVal)
return returnVal
}
}

View File

@@ -682,3 +682,138 @@ describe('Load3DConfiguration "none" model handling', () => {
})
})
})
describe('Load3DConfiguration.onSceneInvalidated', () => {
function makeLoad3dMock(): Load3d {
return {
loadModel: vi.fn().mockResolvedValue(undefined),
clearModel: vi.fn(),
setUpDirection: vi.fn(),
setMaterialMode: vi.fn(),
setTargetSize: vi.fn(),
setCameraState: vi.fn(),
toggleGrid: vi.fn(),
setBackgroundColor: vi.fn(),
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
setBackgroundRenderMode: vi.fn(),
toggleCamera: vi.fn(),
setFOV: vi.fn(),
setLightIntensity: vi.fn(),
setHDRIIntensity: vi.fn(),
setHDRIAsBackground: vi.fn(),
setHDRIEnabled: vi.fn(),
emitModelReady: vi.fn()
} as unknown as Load3d
}
async function flush() {
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
beforeEach(() => {
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'model.glb'])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/view')
})
it('width.callback invokes onSceneInvalidated', async () => {
const onSceneInvalidated = vi.fn()
const width = { value: 1024 } as unknown as IBaseWidget
const height = { value: 1024 } as unknown as IBaseWidget
const config = new Load3DConfiguration(makeLoad3dMock())
config.configure({
modelWidget: { value: 'none' } as unknown as IBaseWidget,
loadFolder: 'input',
width,
height,
onSceneInvalidated
})
await flush()
width.callback!(2048)
expect(onSceneInvalidated).toHaveBeenCalledTimes(1)
})
it('height.callback invokes onSceneInvalidated', async () => {
const onSceneInvalidated = vi.fn()
const width = { value: 1024 } as unknown as IBaseWidget
const height = { value: 1024 } as unknown as IBaseWidget
const config = new Load3DConfiguration(makeLoad3dMock())
config.configure({
modelWidget: { value: 'none' } as unknown as IBaseWidget,
loadFolder: 'input',
width,
height,
onSceneInvalidated
})
await flush()
height.callback!(2048)
expect(onSceneInvalidated).toHaveBeenCalledTimes(1)
})
it('model_file widget callback invokes onSceneInvalidated after the model loads', async () => {
const onSceneInvalidated = vi.fn()
const modelWidget = { value: 'none' } as unknown as IBaseWidget
const config = new Load3DConfiguration(makeLoad3dMock())
config.configure({
modelWidget,
loadFolder: 'input',
onSceneInvalidated
})
await flush()
modelWidget.value = 'model.glb'
await flush()
expect(onSceneInvalidated).toHaveBeenCalled()
})
it('preserves any pre-existing model widget callback alongside the invalidation hook', async () => {
const onSceneInvalidated = vi.fn()
const original = vi.fn()
const modelWidget = {
value: 'none',
callback: original
} as unknown as IBaseWidget
const config = new Load3DConfiguration(makeLoad3dMock())
config.configure({
modelWidget,
loadFolder: 'input',
onSceneInvalidated
})
await flush()
modelWidget.value = 'model.glb'
await flush()
expect(original).toHaveBeenCalledWith('model.glb')
expect(onSceneInvalidated).toHaveBeenCalled()
})
it('callbacks remain safe when onSceneInvalidated is omitted', async () => {
const width = { value: 1024 } as unknown as IBaseWidget
const height = { value: 1024 } as unknown as IBaseWidget
const modelWidget = { value: 'none' } as unknown as IBaseWidget
const config = new Load3DConfiguration(makeLoad3dMock())
config.configure({
modelWidget,
loadFolder: 'input',
width,
height
})
await flush()
expect(() => width.callback!(2048)).not.toThrow()
expect(() => height.callback!(2048)).not.toThrow()
expect(() => {
modelWidget.value = 'model.glb'
}).not.toThrow()
})
})

View File

@@ -23,6 +23,14 @@ type Load3DConfigurationSettings = {
height?: IBaseWidget
bgImagePath?: string
silentOnNotFound?: boolean
/**
* Called when a user-driven change to one of the wired widgets
* (model_file, width, height) makes the previously captured scene stale.
* Backend caching covers these inputs by themselves; this hook lets the
* caller invalidate any frontend-side capture cache so the next serialize
* re-renders at the new state.
*/
onSceneInvalidated?: () => void
}
const ANNOTATED_FILENAME_PATTERN = / \[(input|output|temp)\]$/
@@ -63,22 +71,33 @@ class Load3DConfiguration {
setting.modelWidget,
setting.loadFolder,
setting.cameraState,
setting.silentOnNotFound ?? false
setting.silentOnNotFound ?? false,
setting.onSceneInvalidated
)
this.setupTargetSize(
setting.width,
setting.height,
setting.onSceneInvalidated
)
this.setupTargetSize(setting.width, setting.height)
this.setupDefaultProperties(setting.bgImagePath)
}
private setupTargetSize(width?: IBaseWidget, height?: IBaseWidget) {
private setupTargetSize(
width?: IBaseWidget,
height?: IBaseWidget,
onSceneInvalidated?: () => void
) {
if (width && height) {
this.load3d.setTargetSize(width.value as number, height.value as number)
width.callback = (value: number) => {
this.load3d.setTargetSize(value, height.value as number)
onSceneInvalidated?.()
}
height.callback = (value: number) => {
this.load3d.setTargetSize(width.value as number, value)
onSceneInvalidated?.()
}
}
}
@@ -103,7 +122,8 @@ class Load3DConfiguration {
modelWidget: IBaseWidget,
loadFolder: string,
cameraState?: CameraState,
silentOnNotFound: boolean = false
silentOnNotFound: boolean = false,
onSceneInvalidated?: () => void
) {
const onModelWidgetUpdate = this.createModelUpdateHandler(
loadFolder,
@@ -137,6 +157,8 @@ class Load3DConfiguration {
if (originalCallback) {
originalCallback(value)
}
onSceneInvalidated?.()
}
}

View File

@@ -268,6 +268,7 @@
"title": "Title",
"edit": "Edit",
"copy": "Copy",
"details": "Details",
"copyJobId": "Copy Job ID",
"copied": "Copied",
"relativeTime": {
@@ -3554,6 +3555,7 @@
"parameters": "Parameters",
"nodes": "Nodes",
"info": "Info",
"infoFor": "Info for {item}",
"color": "Node color",
"pinned": "Pinned",
"bypass": "Bypass",
@@ -3573,6 +3575,7 @@
"hideInput": "Hide input",
"showInput": "Show input",
"locateNode": "Locate node on canvas",
"locateNodeFor": "Locate {item}",
"favorites": "FAVORITED INPUTS",
"favoritesNone": "NO FAVORITED INPUTS",
"favoritesNoneTooltip": "Star widgets to quickly access them without selecting nodes",
@@ -3606,6 +3609,7 @@
"errors": "Errors",
"noErrors": "No errors",
"executionErrorOccurred": "An error occurred during execution. Check the Errors tab for details.",
"errorLog": "Error log",
"findOnGithubTooltip": "Search GitHub issues for related problems",
"getHelpTooltip": "Report this error and we'll help you resolve it",
"enterSubgraph": "Enter subgraph",
@@ -3810,6 +3814,15 @@
"toastTitle": "Invalid input",
"toastMessage": "{nodeName} rejected the value for {inputName}."
},
"unknown_validation_error": {
"title": "Validation failed",
"message": "A node returned a validation error ComfyUI does not recognize.",
"details": "{nodeName} returned an unrecognized validation error: {errorType}",
"detailsWithRawDetails": "{nodeName} returned an unrecognized validation error ({errorType}): {rawDetails}",
"itemLabel": "{nodeName}",
"toastTitle": "Validation failed",
"toastMessage": "{nodeName} returned an unrecognized validation error."
},
"exception_during_inner_validation": {
"title": "Validation failed",
"message": "The workflow couldn't validate a connected node.",

View File

@@ -2,6 +2,7 @@
// 1:1 to an API error type. Simple validation mappings stay with the validation
// resolver.
export const MISSING_CONNECTION_CATALOG_ID = 'missing_connection'
export const UNKNOWN_VALIDATION_ERROR_CATALOG_ID = 'unknown_validation_error'
export const EXECUTION_FAILED_CATALOG_ID = 'execution_failed'
export const IMAGE_NOT_LOADED_CATALOG_ID = 'image_not_loaded'
export const OUT_OF_MEMORY_CATALOG_ID = 'out_of_memory'

View File

@@ -144,6 +144,26 @@ describe('errorMessageResolver', () => {
})
})
it('resolves unknown validation errors to fallback catalog copy', () => {
expect(
resolveRunErrorMessage({
kind: 'node_validation',
error: nodeValidationError('value_not_valid', undefined, 'some detail'),
nodeDisplayName: 'KSampler'
})
).toEqual({
catalogId: 'unknown_validation_error',
displayTitle: 'Validation failed',
displayMessage:
'A node returned a validation error ComfyUI does not recognize.',
displayDetails:
'KSampler returned an unrecognized validation error (value_not_valid): some detail',
displayItemLabel: 'KSampler',
toastTitle: 'Validation failed',
toastMessage: 'KSampler returned an unrecognized validation error.'
})
})
it('falls back to raw API copy when catalog keys are missing in the active locale', () => {
const originalLocale = i18n.global.locale.value
const originalKoMessages = i18n.global.getLocaleMessage('ko')

View File

@@ -1,4 +1,8 @@
import type { ResolvedErrorMessage, RunErrorMessageSource } from './types'
import type {
ResolvedCatalogErrorMessage,
ResolvedErrorMessage,
RunErrorMessageSource
} from './types'
import { resolveExecutionErrorMessage } from './executionErrorResolver'
import { resolveMissingErrorMessage } from './missingErrorResolver'
@@ -9,6 +13,15 @@ import { resolveNodeValidationErrorMessage } from './validationErrorResolver'
// own the actual matching/copy rules so this file stays as the routing boundary.
export { resolveMissingErrorMessage }
export function resolveRunErrorMessage(
source: Extract<RunErrorMessageSource, { kind: 'node_validation' }>
): ResolvedCatalogErrorMessage
export function resolveRunErrorMessage(
source: Extract<RunErrorMessageSource, { kind: 'execution' }>
): ResolvedCatalogErrorMessage
export function resolveRunErrorMessage(
source: RunErrorMessageSource
): ResolvedErrorMessage
export function resolveRunErrorMessage(
source: RunErrorMessageSource
): ResolvedErrorMessage {

View File

@@ -1,4 +1,7 @@
import type { ResolvedErrorMessage, RunErrorMessageSource } from './types'
import type {
ResolvedCatalogErrorMessage,
RunErrorMessageSource
} from './types'
import { EXECUTION_FAILED_CATALOG_ID } from './catalogIds'
import type { ErrorResolveContext } from './catalogI18n'
@@ -11,7 +14,7 @@ type ExecutionErrorResolveContext = Pick<ErrorResolveContext, 'nodeDisplayName'>
export function resolveExecutionErrorMessage(
error: Extract<RunErrorMessageSource, { kind: 'execution' }>['error'],
context: ExecutionErrorResolveContext
): ResolvedErrorMessage {
): ResolvedCatalogErrorMessage {
const exceptionMessage = error.exception_message.trim()
const match = resolveRuntimeCatalogMatch({
exceptionType: error.exception_type,

View File

@@ -1,4 +1,4 @@
import type { ResolvedErrorMessage } from './types'
import type { ResolvedCatalogErrorMessage } from './types'
import {
normalizeNodeName,
@@ -19,7 +19,7 @@ export function resolveRuntimeCatalogCopy(
params?: CatalogParams
detailsFallback?: string
} = {}
): ResolvedErrorMessage {
): ResolvedCatalogErrorMessage {
const keyPrefix = `errorCatalog.runtimeErrors.${catalogId}`
const nodeName = normalizeNodeName(context.nodeDisplayName)
const params = { nodeName, ...options.params }
@@ -27,7 +27,7 @@ export function resolveRuntimeCatalogCopy(
translateCatalogMessage(`${keyPrefix}.${suffix}`, fallback, params)
const displayMessage = resolveMessage('message')
const result: ResolvedErrorMessage = {
const result: ResolvedCatalogErrorMessage = {
catalogId,
displayTitle: resolveMessage('title'),
displayMessage

View File

@@ -25,6 +25,10 @@ export interface ResolvedErrorMessage {
toastMessage?: string
}
export type ResolvedCatalogErrorMessage = ResolvedErrorMessage & {
catalogId: string
}
export type ResolvedMissingErrorMessage = ResolvedErrorMessage & {
displayTitle: string
displayMessage: string

View File

@@ -1,8 +1,9 @@
import type { NodeValidationError, ResolvedErrorMessage } from './types'
import type { NodeValidationError, ResolvedCatalogErrorMessage } from './types'
import {
IMAGE_NOT_LOADED_CATALOG_ID,
MISSING_CONNECTION_CATALOG_ID
MISSING_CONNECTION_CATALOG_ID,
UNKNOWN_VALIDATION_ERROR_CATALOG_ID
} from './catalogIds'
import {
normalizeNodeName,
@@ -117,6 +118,11 @@ const IMAGE_NOT_LOADED_VALIDATION_RULE = {
copyKeys: DEFAULT_COPY_KEYS
} satisfies ValidationCatalogRule
const UNKNOWN_VALIDATION_ERROR_RULE = {
catalogId: UNKNOWN_VALIDATION_ERROR_CATALOG_ID,
itemLabel: 'node'
} satisfies ValidationCatalogRule
function getInputName(error: NodeValidationError): string {
const inputName = error.extra_info?.input_name
return (
@@ -228,7 +234,7 @@ function getValueSpecificCopyKeys(
}
function getRawDetailsCopyKeys(error: NodeValidationError): CopyKeys {
return error.details.trim()
return error.details?.trim()
? {
detailsKey: 'detailsWithRawDetails',
toastMessageKey: 'toastMessageWithRawDetails'
@@ -237,7 +243,7 @@ function getRawDetailsCopyKeys(error: NodeValidationError): CopyKeys {
}
function getRawDetailsOnlyCopyKeys(error: NodeValidationError): CopyKeys {
if (!error.details.trim()) return DEFAULT_COPY_KEYS
if (!error.details?.trim()) return DEFAULT_COPY_KEYS
return {
detailsKey: 'detailsWithRawDetails',
@@ -272,16 +278,17 @@ function resolveValidationCatalogCopy(
context: ErrorResolveContext,
localeKey: string,
rule: ValidationCatalogRule
): ResolvedErrorMessage {
): ResolvedCatalogErrorMessage {
const nodeName = normalizeNodeName(context.nodeDisplayName)
const inputName = getInputName(error)
const trimmedDetails = error.details.trim()
const trimmedDetails = error.details?.trim() ?? ''
const rawDetails =
error.type === 'dependency_cycle'
? formatDependencyCycleDetails(trimmedDetails)
: trimmedDetails
const params = {
...getValidationParams(error, nodeName, inputName),
errorType: error.type || 'unknown',
rawDetails
}
const keyPrefix = `errorCatalog.validationErrors.${localeKey}`
@@ -306,7 +313,7 @@ function resolveValidationCatalogCopy(
),
displayDetails: translateOptionalCatalogMessage(
`${keyPrefix}.${copyKeys.detailsKey}`,
error.details,
error.details ?? '',
params
),
displayItemLabel: translateCatalogMessage(
@@ -330,7 +337,7 @@ function resolveValidationCatalogCopy(
export function resolveNodeValidationErrorMessage(
error: NodeValidationError,
context: ErrorResolveContext
): ResolvedErrorMessage {
): ResolvedCatalogErrorMessage {
if (isImageNotLoadedValidationError(error)) {
return resolveValidationCatalogCopy(
error,
@@ -341,7 +348,17 @@ export function resolveNodeValidationErrorMessage(
}
const rule = VALIDATION_ERROR_RULES[error.type]
if (!rule) return {}
if (!rule) {
return resolveValidationCatalogCopy(
error,
context,
'unknown_validation_error',
{
...UNKNOWN_VALIDATION_ERROR_RULE,
copyKeys: getRawDetailsOnlyCopyKeys(error)
}
)
}
return resolveValidationCatalogCopy(error, context, error.type, rule)
}

View File

@@ -22,7 +22,7 @@
:class="
cn(
WidgetInputBaseClass,
'size-full resize-none text-xs',
'size-full resize-none text-(length:--comfy-textarea-font-size) leading-normal',
!hideLayoutField && 'pt-5',
// Avoid overflow-auto when idle to prevent per-textarea compositing layers.
'overflow-hidden hover:overflow-auto focus:overflow-auto'