mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-06 15:54:45 +00:00
Compare commits
5 Commits
main
...
jaeone/fe-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dff90de25d | ||
|
|
d94fb3c1b7 | ||
|
|
e46be82b88 | ||
|
|
e7019146d1 | ||
|
|
0f27431c37 |
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,6 +25,10 @@ export interface ResolvedErrorMessage {
|
||||
toastMessage?: string
|
||||
}
|
||||
|
||||
export type ResolvedCatalogErrorMessage = ResolvedErrorMessage & {
|
||||
catalogId: string
|
||||
}
|
||||
|
||||
export type ResolvedMissingErrorMessage = ResolvedErrorMessage & {
|
||||
displayTitle: string
|
||||
displayMessage: string
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user