mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-08 15:29:52 +00:00
Compare commits
2 Commits
rizumu/fix
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74b9f16b62 | ||
|
|
dbeb9cc10d |
115
browser_tests/assets/missing/missing_model_promoted_widget.json
Normal file
115
browser_tests/assets/missing/missing_model_promoted_widget.json
Normal file
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"id": "test-missing-model-promoted-widget",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "subgraph-with-promoted-missing-model",
|
||||
"pos": [450, 250],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "subgraph-with-promoted-missing-model",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 1,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Subgraph with Promoted Missing Model",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [100, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [500, 200, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "ckpt-name-input-id",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"linkIds": [1],
|
||||
"pos": { "0": 150, "1": 220 }
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [250, 180],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "ckpt_name",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "ckpt_name"
|
||||
},
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": null },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "COMBO"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -369,6 +369,62 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test(
|
||||
'Resolving a promoted missing model widget through the legacy canvas path clears its error',
|
||||
{ tag: ['@canvas', '@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
const resolvedModelName = 'v1-5-pruned-emaonly-fp16.safetensors'
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_model_promoted_widget'
|
||||
)
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(1\)/
|
||||
)
|
||||
|
||||
await comfyPage.page.evaluate((value) => {
|
||||
const hostNode = window.app!.graph!.getNodeById(2)
|
||||
if (!hostNode?.isSubgraphNode()) {
|
||||
throw new Error('Expected subgraph host node')
|
||||
}
|
||||
|
||||
const interiorNode = hostNode.subgraph.getNodeById(1)
|
||||
const widget = interiorNode?.widgets?.find(
|
||||
(entry) => entry.name === 'ckpt_name'
|
||||
)
|
||||
type SettableWidget = typeof widget & {
|
||||
setValue?: (
|
||||
value: string,
|
||||
options: {
|
||||
e: PointerEvent
|
||||
node: unknown
|
||||
canvas: unknown
|
||||
}
|
||||
) => void
|
||||
}
|
||||
const settableWidget = widget as SettableWidget | undefined
|
||||
|
||||
if (!settableWidget?.setValue) {
|
||||
throw new Error('Expected concrete ckpt_name widget')
|
||||
}
|
||||
|
||||
settableWidget.setValue(value, {
|
||||
e: new PointerEvent('pointerup'),
|
||||
node: hostNode,
|
||||
canvas: window.app!.canvas
|
||||
})
|
||||
}, resolvedModelName)
|
||||
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
}
|
||||
)
|
||||
|
||||
test('Bypassing a subgraph hides interior errors, un-bypassing restores them', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
CanvasPointer,
|
||||
CanvasPointerEvent,
|
||||
LGraphCanvas
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
@@ -285,14 +290,7 @@ describe('Widget change error clearing via onWidgetChanged', () => {
|
||||
)
|
||||
expect(promotedWidget).toBeDefined()
|
||||
|
||||
// PromotedWidgetView.name returns displayName ("ckpt_input"), which is
|
||||
// passed as errorInputName to clearSimpleNodeErrors. Seed the error
|
||||
// with that name so the slot-name filter matches.
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
interiorExecId,
|
||||
promotedWidget!.name
|
||||
)
|
||||
seedRequiredInputMissingNodeError(store, interiorExecId, 'ckpt_name')
|
||||
|
||||
subgraphNode.onWidgetChanged!.call(
|
||||
subgraphNode,
|
||||
@@ -304,6 +302,227 @@ describe('Widget change error clearing via onWidgetChanged', () => {
|
||||
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
})
|
||||
|
||||
it('clears range errors for promoted widgets by interior widget name', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'steps_input', type: 'INT' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('KSampler')
|
||||
const interiorInput = interiorNode.addInput('steps_input', 'INT')
|
||||
interiorNode.addWidget('number', 'steps', 150, () => undefined, {
|
||||
min: 1,
|
||||
max: 100
|
||||
})
|
||||
interiorInput.widget = { name: 'steps' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
|
||||
store.lastNodeErrors = {
|
||||
[interiorExecId]: {
|
||||
errors: [
|
||||
{
|
||||
type: 'value_bigger_than_max',
|
||||
message: 'Too big',
|
||||
details: '',
|
||||
extra_info: { input_name: 'steps' }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'KSampler'
|
||||
}
|
||||
}
|
||||
|
||||
const promotedWidget = subgraphNode.widgets?.find(
|
||||
(w) => 'sourceWidgetName' in w && w.sourceWidgetName === 'steps'
|
||||
)
|
||||
expect(promotedWidget).toBeDefined()
|
||||
|
||||
subgraphNode.onWidgetChanged!.call(
|
||||
subgraphNode,
|
||||
'steps',
|
||||
50,
|
||||
150,
|
||||
promotedWidget!
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
})
|
||||
|
||||
it('clears missing model state when a promoted widget changes through the legacy canvas path', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'ckpt_input', type: '*' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
interiorNode.type = 'CheckpointLoaderSimple'
|
||||
const interiorInput = interiorNode.addInput('ckpt_input', '*')
|
||||
interiorNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'missing.safetensors',
|
||||
() => undefined,
|
||||
{ values: ['missing.safetensors', 'present.safetensors'] }
|
||||
)
|
||||
interiorInput.widget = { name: 'ckpt_name' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, {
|
||||
id: 65,
|
||||
pos: [0, 0],
|
||||
size: [200, 100]
|
||||
})
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
|
||||
missingModelStore.setMissingModels([
|
||||
{
|
||||
nodeId: interiorExecId,
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'missing.safetensors',
|
||||
isMissing: true
|
||||
} satisfies MissingModelCandidate
|
||||
])
|
||||
|
||||
const promotedWidget = subgraphNode.widgets?.find(
|
||||
(widget) =>
|
||||
'sourceWidgetName' in widget && widget.sourceWidgetName === 'ckpt_name'
|
||||
)
|
||||
expect(promotedWidget).toBeDefined()
|
||||
|
||||
const clickEvent = fromAny<CanvasPointerEvent, unknown>({
|
||||
canvasX: 190,
|
||||
canvasY: 20,
|
||||
deltaX: 0
|
||||
})
|
||||
const pointer = fromAny<CanvasPointer, unknown>({
|
||||
eDown: clickEvent
|
||||
})
|
||||
const canvas = fromAny<LGraphCanvas, unknown>({
|
||||
graph_mouse: [190, 20],
|
||||
last_mouseclick: 0
|
||||
})
|
||||
|
||||
const handled = promotedWidget!.onPointerDown?.(
|
||||
pointer,
|
||||
subgraphNode,
|
||||
canvas
|
||||
)
|
||||
expect(handled).toBe(true)
|
||||
expect(pointer.onClick).toBeDefined()
|
||||
|
||||
pointer.onClick?.(clickEvent)
|
||||
|
||||
expect(missingModelStore.missingModelCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps unchanged same-named promoted model targets on the canvas path', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'first_ckpt', type: '*' },
|
||||
{ name: 'second_ckpt', type: '*' }
|
||||
]
|
||||
})
|
||||
const firstNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
firstNode.type = 'CheckpointLoaderSimple'
|
||||
const firstInput = firstNode.addInput('first_ckpt', '*')
|
||||
const firstWidget = firstNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'missing.safetensors',
|
||||
() => undefined,
|
||||
{ values: ['missing.safetensors', 'present.safetensors'] }
|
||||
)
|
||||
firstInput.widget = { name: 'ckpt_name' }
|
||||
subgraph.add(firstNode)
|
||||
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
|
||||
|
||||
const secondNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
secondNode.type = 'CheckpointLoaderSimple'
|
||||
const secondInput = secondNode.addInput('second_ckpt', '*')
|
||||
secondNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'missing.safetensors',
|
||||
() => undefined,
|
||||
{ values: ['missing.safetensors', 'present.safetensors'] }
|
||||
)
|
||||
secondInput.widget = { name: 'ckpt_name' }
|
||||
subgraph.add(secondNode)
|
||||
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const promotedWidgets =
|
||||
subgraphNode.widgets?.filter(
|
||||
(widget) =>
|
||||
'sourceWidgetName' in widget &&
|
||||
widget.sourceWidgetName === 'ckpt_name'
|
||||
) ?? []
|
||||
expect(promotedWidgets).toHaveLength(2)
|
||||
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const firstExecId = `${subgraphNode.id}:${firstNode.id}`
|
||||
const secondExecId = `${subgraphNode.id}:${secondNode.id}`
|
||||
missingModelStore.setMissingModels([
|
||||
{
|
||||
nodeId: firstExecId,
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'missing.safetensors',
|
||||
isMissing: true
|
||||
} satisfies MissingModelCandidate,
|
||||
{
|
||||
nodeId: secondExecId,
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'missing.safetensors',
|
||||
isMissing: true
|
||||
} satisfies MissingModelCandidate
|
||||
])
|
||||
|
||||
firstWidget.value = 'present.safetensors'
|
||||
subgraphNode.onWidgetChanged!.call(
|
||||
subgraphNode,
|
||||
'ckpt_name',
|
||||
'present.safetensors',
|
||||
'missing.safetensors',
|
||||
firstWidget
|
||||
)
|
||||
|
||||
expect(missingModelStore.missingModelCandidates).toEqual([
|
||||
expect.objectContaining({
|
||||
nodeId: secondExecId,
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'missing.safetensors'
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('installErrorClearingHooks lifecycle', () => {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -45,22 +46,128 @@ import {
|
||||
isAncestorPathActive
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
|
||||
function resolvePromotedExecId(
|
||||
rootGraph: LGraph,
|
||||
node: LGraphNode,
|
||||
interface WidgetErrorClearingTarget {
|
||||
executionId: string
|
||||
validationInputName: string
|
||||
assetWidgetName: string
|
||||
currentValue: unknown
|
||||
options?: { min?: number; max?: number }
|
||||
}
|
||||
|
||||
function getWidgetRangeOptions(widget: IBaseWidget): {
|
||||
min?: number
|
||||
max?: number
|
||||
} {
|
||||
return {
|
||||
min: widget.options?.min,
|
||||
max: widget.options?.max
|
||||
}
|
||||
}
|
||||
|
||||
function plainWidgetToErrorTarget(
|
||||
widget: IBaseWidget,
|
||||
hostExecId: string
|
||||
): string {
|
||||
if (!isPromotedWidgetView(widget)) return hostExecId
|
||||
): WidgetErrorClearingTarget {
|
||||
return {
|
||||
executionId: hostExecId,
|
||||
validationInputName: widget.name,
|
||||
assetWidgetName: widget.name,
|
||||
currentValue: widget.value,
|
||||
options: getWidgetRangeOptions(widget)
|
||||
}
|
||||
}
|
||||
|
||||
function promotedWidgetToErrorTarget(
|
||||
rootGraph: LGraph,
|
||||
hostNode: LGraphNode,
|
||||
widget: PromotedWidgetView,
|
||||
hostExecId: string
|
||||
): WidgetErrorClearingTarget {
|
||||
const result = resolveConcretePromotedWidget(
|
||||
node,
|
||||
hostNode,
|
||||
widget.sourceNodeId,
|
||||
widget.sourceWidgetName
|
||||
)
|
||||
if (result.status === 'resolved' && result.resolved.node) {
|
||||
return getExecutionIdByNode(rootGraph, result.resolved.node) ?? hostExecId
|
||||
const execId =
|
||||
result.status === 'resolved' && result.resolved.node
|
||||
? (getExecutionIdByNode(rootGraph, result.resolved.node) ?? hostExecId)
|
||||
: hostExecId
|
||||
const resolvedWidget =
|
||||
result.status === 'resolved' ? result.resolved.widget : widget
|
||||
|
||||
return {
|
||||
executionId: execId,
|
||||
validationInputName: resolvedWidget.name,
|
||||
assetWidgetName: widget.sourceWidgetName,
|
||||
currentValue: resolvedWidget.value,
|
||||
options: getWidgetRangeOptions(resolvedWidget)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCanvasPathPromotedWidgetTargets(
|
||||
rootGraph: LGraph,
|
||||
hostNode: LGraphNode,
|
||||
widget: IBaseWidget,
|
||||
hostExecId: string,
|
||||
newValue: unknown
|
||||
): WidgetErrorClearingTarget[] {
|
||||
if (!hostNode.isSubgraphNode?.() || isPromotedWidgetView(widget)) return []
|
||||
|
||||
// Canvas-path events lose promoted identity, so the post-write value
|
||||
// disambiguates same-named promoted widgets.
|
||||
return (hostNode.widgets ?? [])
|
||||
.filter(isPromotedWidgetView)
|
||||
.filter((promotedWidget) => promotedWidget.sourceWidgetName === widget.name)
|
||||
.map((promotedWidget) =>
|
||||
promotedWidgetToErrorTarget(
|
||||
rootGraph,
|
||||
hostNode,
|
||||
promotedWidget,
|
||||
hostExecId
|
||||
)
|
||||
)
|
||||
.filter((target) => Object.is(target.currentValue, newValue))
|
||||
}
|
||||
|
||||
function resolveWidgetErrorTargets(
|
||||
rootGraph: LGraph,
|
||||
hostNode: LGraphNode,
|
||||
widget: IBaseWidget,
|
||||
hostExecId: string,
|
||||
newValue: unknown
|
||||
): WidgetErrorClearingTarget[] {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
return [
|
||||
promotedWidgetToErrorTarget(rootGraph, hostNode, widget, hostExecId)
|
||||
]
|
||||
}
|
||||
|
||||
const canvasPathTargets = resolveCanvasPathPromotedWidgetTargets(
|
||||
rootGraph,
|
||||
hostNode,
|
||||
widget,
|
||||
hostExecId,
|
||||
newValue
|
||||
)
|
||||
return canvasPathTargets.length
|
||||
? canvasPathTargets
|
||||
: [plainWidgetToErrorTarget(widget, hostExecId)]
|
||||
}
|
||||
|
||||
function clearWidgetErrorTargets(
|
||||
targets: WidgetErrorClearingTarget[],
|
||||
newValue: unknown
|
||||
): void {
|
||||
const store = useExecutionErrorStore()
|
||||
for (const target of targets) {
|
||||
store.clearWidgetRelatedErrors(
|
||||
target.executionId,
|
||||
target.validationInputName,
|
||||
target.assetWidgetName,
|
||||
newValue,
|
||||
target.options
|
||||
)
|
||||
}
|
||||
return hostExecId
|
||||
}
|
||||
|
||||
const hookedNodes = new WeakSet<LGraphNode>()
|
||||
@@ -103,23 +210,14 @@ function installNodeHooks(node: LGraphNode): void {
|
||||
const hostExecId = getExecutionIdByNode(app.rootGraph, node)
|
||||
if (!hostExecId) return
|
||||
|
||||
const execId = resolvePromotedExecId(
|
||||
const targets = resolveWidgetErrorTargets(
|
||||
app.rootGraph,
|
||||
node,
|
||||
widget,
|
||||
hostExecId
|
||||
)
|
||||
const widgetName = isPromotedWidgetView(widget)
|
||||
? widget.sourceWidgetName
|
||||
: widget.name
|
||||
|
||||
useExecutionErrorStore().clearWidgetRelatedErrors(
|
||||
execId,
|
||||
widget.name,
|
||||
widgetName,
|
||||
newValue,
|
||||
{ min: widget.options?.min, max: widget.options?.max }
|
||||
hostExecId,
|
||||
newValue
|
||||
)
|
||||
clearWidgetErrorTargets(targets, newValue)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -152,8 +152,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
* Clears both validation errors and missing model state for a widget.
|
||||
*
|
||||
* @param errorInputName Name matched against `error.extra_info.input_name`.
|
||||
* For promoted subgraph widgets this is the subgraph input slot name
|
||||
* (`widget.slotName`), which differs from the interior widget name.
|
||||
* For promoted subgraph widgets this is the resolved interior widget name.
|
||||
* @param widgetName The actual widget name, used for missing model lookup.
|
||||
* At the legacy canvas call site both names are identical (`widget.name`).
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user