mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-16 16:58:33 +00:00
Compare commits
6 Commits
codex/cove
...
jaeone/fe-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5647997117 | ||
|
|
50057338f8 | ||
|
|
9f7162d8b8 | ||
|
|
9d52a05a3c | ||
|
|
44f9f56069 | ||
|
|
c6d3f5a400 |
@@ -54,6 +54,7 @@ export const TestIds = {
|
||||
errorDialogFindIssues: 'error-dialog-find-issues',
|
||||
about: 'about-panel',
|
||||
whatsNewSection: 'whats-new-section',
|
||||
errorGroupDisplayMessage: 'error-group-display-message',
|
||||
missingNodePacksGroup: 'error-group-missing-node',
|
||||
missingModelsGroup: 'error-group-missing-model',
|
||||
missingModelExpand: 'missing-model-expand',
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getSwapNodesGroup,
|
||||
setupNodeReplacement
|
||||
} from '@e2e/fixtures/helpers/NodeReplacementHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const renderModes = [
|
||||
{ name: 'vue nodes', vueNodesEnabled: true },
|
||||
@@ -38,6 +39,9 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
|
||||
}) => {
|
||||
const swapGroup = getSwapNodesGroup(comfyPage.page)
|
||||
await expect(swapGroup).toBeVisible()
|
||||
await expect(
|
||||
swapGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
|
||||
).toHaveText(/\S/)
|
||||
await expect(swapGroup).toContainText('E2E_OldSampler')
|
||||
await expect(
|
||||
swapGroup.getByRole('button', { name: 'Replace All', exact: true })
|
||||
|
||||
@@ -36,6 +36,10 @@ function getDropzone(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaUploadDropzone)
|
||||
}
|
||||
|
||||
function getErrorOverlay(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
}
|
||||
|
||||
test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
@@ -46,14 +50,24 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
|
||||
test.describe('Detection', () => {
|
||||
test('Shows missing media group in errors tab', async ({ comfyPage }) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_media_single')
|
||||
|
||||
const overlay = getErrorOverlay(comfyPage)
|
||||
await expect(overlay).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
|
||||
).toBeVisible()
|
||||
overlay.getByTestId(TestIds.dialogs.errorOverlayMessages)
|
||||
).toContainText(/Load Image/)
|
||||
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||
await expect(overlay).toBeHidden()
|
||||
|
||||
const missingMediaGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaGroup
|
||||
)
|
||||
await expect(missingMediaGroup).toBeVisible()
|
||||
await expect(
|
||||
missingMediaGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
|
||||
).toHaveText(/\S/)
|
||||
})
|
||||
|
||||
test('Shows correct number of missing media rows', async ({
|
||||
|
||||
@@ -25,9 +25,13 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const missingModelsGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelsGroup).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
|
||||
).toBeVisible()
|
||||
missingModelsGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
|
||||
).toHaveText(/\S/)
|
||||
})
|
||||
|
||||
test('Should display model name with referencing node count', async ({
|
||||
|
||||
@@ -23,9 +23,13 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
test('Should show missing node packs group', async ({ comfyPage }) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
const missingNodeGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodePacksGroup
|
||||
)
|
||||
await expect(missingNodeGroup).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingNodePacksGroup)
|
||||
).toBeVisible()
|
||||
missingNodeGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
|
||||
).toHaveText(/\S/)
|
||||
})
|
||||
|
||||
test('Should expand pack group to reveal node type names', async ({
|
||||
|
||||
@@ -90,8 +90,6 @@ const i18n = createI18n({
|
||||
en: {
|
||||
rightSidePanel: {
|
||||
missingNodePacks: {
|
||||
ossMessage: 'Missing node packs detected. Install them.',
|
||||
cloudMessage: 'Unsupported node packs detected.',
|
||||
ossManagerDisabledHint:
|
||||
'To install missing nodes, first run {pipCmd} in your Python environment to install Node Manager, then restart ComfyUI with the {flag} flag.',
|
||||
applyChanges: 'Apply Changes'
|
||||
@@ -159,21 +157,6 @@ describe('MissingNodeCard', () => {
|
||||
})
|
||||
|
||||
describe('Rendering & Props', () => {
|
||||
it('renders cloud message when isCloud is true', () => {
|
||||
mockIsCloud.value = true
|
||||
renderCard()
|
||||
expect(
|
||||
screen.getByText('Unsupported node packs detected.')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders OSS message when isCloud is false', () => {
|
||||
renderCard()
|
||||
expect(
|
||||
screen.getByText('Missing node packs detected. Install them.')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders correct number of MissingPackGroupRow components', () => {
|
||||
renderCard({ missingPackGroups: makePackGroups(3) })
|
||||
expect(screen.getAllByTestId('pack-row')).toHaveLength(3)
|
||||
|
||||
@@ -36,19 +36,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sub-label: cloud or OSS message shown above all pack groups -->
|
||||
<p
|
||||
class="m-0 text-sm/relaxed text-muted-foreground"
|
||||
:class="showManagerHint ? 'pb-3' : 'pb-5'"
|
||||
>
|
||||
{{
|
||||
isCloud
|
||||
? t('rightSidePanel.missingNodePacks.cloudMessage')
|
||||
: t('rightSidePanel.missingNodePacks.ossMessage')
|
||||
}}
|
||||
</p>
|
||||
|
||||
<!-- Manager disabled hint: shown on OSS when manager is not active -->
|
||||
<i18n-t
|
||||
v-if="showManagerHint"
|
||||
|
||||
@@ -7,6 +7,8 @@ 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 { MissingNodeType } from '@/types/comfy'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
@@ -36,6 +38,16 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/missingModel/missingModelDownload', () => ({
|
||||
downloadModel: vi.fn(),
|
||||
fetchModelMetadata: vi.fn().mockResolvedValue({
|
||||
fileSize: null,
|
||||
gatedRepoUrl: null
|
||||
}),
|
||||
isModelDownloadable: vi.fn(() => true),
|
||||
toBrowsableUrl: vi.fn((url: string) => url)
|
||||
}))
|
||||
|
||||
describe('TabErrors.vue', () => {
|
||||
let i18n: ReturnType<typeof createI18n>
|
||||
|
||||
@@ -57,6 +69,18 @@ describe('TabErrors.vue', () => {
|
||||
downloadAll: 'Download all',
|
||||
refresh: 'Refresh',
|
||||
refreshing: 'Refreshing missing models.'
|
||||
},
|
||||
missingMedia: {
|
||||
missingMediaTitle: 'Missing Inputs',
|
||||
image: 'Images',
|
||||
uploadFile: 'Upload {type}',
|
||||
useFromLibrary: 'Use from Library',
|
||||
confirmSelection: 'Confirm selection',
|
||||
locateNode: 'Locate node',
|
||||
expandNodes: 'Show referencing nodes',
|
||||
collapseNodes: 'Hide referencing nodes',
|
||||
cancelSelection: 'Cancel selection',
|
||||
or: 'OR'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,6 +306,85 @@ describe('TabErrors.vue', () => {
|
||||
expect(missingModelStore.refreshMissingModels).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders missing model display message below the section title', () => {
|
||||
const missingModel = {
|
||||
nodeId: '1',
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'local-only.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isMissing: true,
|
||||
isAssetSupported: true
|
||||
} satisfies MissingModelCandidate
|
||||
|
||||
renderComponent({
|
||||
missingModel: {
|
||||
missingModelCandidates: [missingModel]
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Download a model, or open the node to replace it.')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders missing media display message below the section title', () => {
|
||||
const missingMedia = {
|
||||
nodeId: '3',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'portrait.png',
|
||||
isMissing: true
|
||||
} satisfies MissingMediaCandidate
|
||||
|
||||
renderComponent({
|
||||
missingMedia: {
|
||||
missingMediaCandidates: [missingMedia]
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Missing Inputs (1)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('A required media input has no file selected.')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders swap node rows below the section display message', () => {
|
||||
const swapNode = {
|
||||
type: 'OldSampler',
|
||||
nodeId: '1',
|
||||
isReplaceable: true,
|
||||
replacement: {
|
||||
old_node_id: 'OldSampler',
|
||||
new_node_id: 'KSampler',
|
||||
old_widget_ids: null,
|
||||
input_mapping: null,
|
||||
output_mapping: null
|
||||
}
|
||||
} satisfies MissingNodeType
|
||||
|
||||
renderComponent({
|
||||
missingNodesError: {
|
||||
missingNodesError: {
|
||||
message: 'Missing Node Packs',
|
||||
nodeTypes: [swapNode]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Swap Nodes (1)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Some nodes can be replaced with alternatives')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('OldSampler (1)')).toBeInTheDocument()
|
||||
expect(screen.getByText('KSampler')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Replace Node/ })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps missing model Refresh in the card actions when models are downloadable', () => {
|
||||
const missingModel = {
|
||||
nodeId: '1',
|
||||
|
||||
@@ -154,6 +154,18 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="group.type !== 'execution' && group.displayMessage"
|
||||
data-testid="error-group-display-message"
|
||||
class="px-4 pt-1 pb-3"
|
||||
>
|
||||
<p
|
||||
class="m-0 text-sm/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ group.displayMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Missing Node Packs -->
|
||||
<MissingNodeCard
|
||||
v-if="group.type === 'missing_node'"
|
||||
@@ -166,7 +178,7 @@
|
||||
|
||||
<!-- Swap Nodes -->
|
||||
<SwapNodesCard
|
||||
v-else-if="group.type === 'swap_nodes'"
|
||||
v-if="group.type === 'swap_nodes'"
|
||||
:swap-node-groups="swapNodeGroups"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="handleLocateMissingNode"
|
||||
@@ -174,7 +186,7 @@
|
||||
/>
|
||||
|
||||
<!-- Execution Errors -->
|
||||
<div v-else-if="group.type === 'execution'" class="space-y-3 px-4">
|
||||
<div v-if="group.type === 'execution'" class="space-y-3 px-4">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
@@ -189,7 +201,7 @@
|
||||
|
||||
<!-- Missing Models -->
|
||||
<MissingModelCard
|
||||
v-else-if="group.type === 'missing_model'"
|
||||
v-if="group.type === 'missing_model'"
|
||||
:missing-model-groups="missingModelGroups"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-model="handleLocateAssetNode"
|
||||
@@ -197,7 +209,7 @@
|
||||
|
||||
<!-- Missing Media -->
|
||||
<MissingMediaCard
|
||||
v-else-if="group.type === 'missing_media'"
|
||||
v-if="group.type === 'missing_media'"
|
||||
:missing-media-groups="missingMediaGroups"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
@locate-node="handleLocateAssetNode"
|
||||
|
||||
@@ -43,6 +43,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':
|
||||
'A node returned a validation error ComfyUI does not recognize.',
|
||||
'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':
|
||||
@@ -302,7 +314,24 @@ describe('useErrorGroups', () => {
|
||||
expect(missingGroup?.groupKey).toBe('missing_node')
|
||||
expect(missingGroup?.displayTitle).toBe('Missing Node Packs (1)')
|
||||
expect(missingGroup?.displayMessage).toBe(
|
||||
'Some nodes are missing and need to be installed'
|
||||
'Install missing packs to use this workflow.'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses Cloud copy for missing_node group in Cloud', async () => {
|
||||
mockIsCloud.value = true
|
||||
const { groups } = createErrorGroups()
|
||||
const missingNodesStore = useMissingNodesErrorStore()
|
||||
missingNodesStore.setMissingNodeTypes([
|
||||
makeMissingNodeType('NodeA', { cnrId: 'pack-1' })
|
||||
])
|
||||
await nextTick()
|
||||
|
||||
const missingGroup = groups.allErrorGroups.value.find(
|
||||
(g) => g.type === 'missing_node'
|
||||
)
|
||||
expect(missingGroup?.displayMessage).toBe(
|
||||
"Required custom nodes aren't supported on Cloud. Replace them with supported nodes."
|
||||
)
|
||||
})
|
||||
|
||||
@@ -367,7 +396,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': {
|
||||
@@ -388,8 +417,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 () => {
|
||||
@@ -438,6 +467,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()
|
||||
|
||||
@@ -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__'
|
||||
@@ -79,6 +79,8 @@ interface ErrorSearchItem {
|
||||
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.
|
||||
@@ -138,44 +140,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)
|
||||
}
|
||||
@@ -333,18 +297,22 @@ 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
|
||||
)
|
||||
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(
|
||||
@@ -395,13 +363,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 +377,10 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
error: e,
|
||||
nodeDisplayName
|
||||
})
|
||||
}
|
||||
}),
|
||||
filterBySelection
|
||||
)
|
||||
},
|
||||
filterBySelection
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,20 +396,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
|
||||
)
|
||||
}
|
||||
@@ -723,7 +689,6 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
kind: 'missing_media',
|
||||
groups: missingMediaGroups.value,
|
||||
count: totalItems,
|
||||
mediaTypes: missingMediaGroups.value.map((group) => group.mediaType),
|
||||
isCloud
|
||||
})
|
||||
}
|
||||
@@ -840,9 +805,6 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
kind: 'missing_media',
|
||||
groups: filteredMissingMediaGroups.value,
|
||||
count: totalItems,
|
||||
mediaTypes: filteredMissingMediaGroups.value.map(
|
||||
(group) => group.mediaType
|
||||
),
|
||||
isCloud
|
||||
})
|
||||
}
|
||||
@@ -871,10 +833,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
|
||||
@@ -887,7 +845,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
...(filterByNode
|
||||
? buildMissingMediaGroupsFiltered()
|
||||
: buildMissingMediaGroups()),
|
||||
...executionGroups
|
||||
...toSortedGroups(groupsMap)
|
||||
]
|
||||
})
|
||||
|
||||
@@ -902,7 +860,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
if (group.type === 'execution') {
|
||||
for (const card of group.cards) {
|
||||
for (const err of card.errors) {
|
||||
messages.add(err.displayMessage ?? err.message)
|
||||
messages.add(err.message)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -3533,7 +3533,6 @@
|
||||
"skipForNow": "Skip for Now",
|
||||
"installMissingNodes": "Install Missing Nodes",
|
||||
"replaceWarning": "This will permanently modify the workflow. Save a copy first if unsure.",
|
||||
"swapNodesGuide": "The following nodes can be automatically replaced with compatible alternatives.",
|
||||
"willBeReplacedBy": "This node will be replaced by:",
|
||||
"replaceNode": "Replace Node",
|
||||
"replaceAll": "Replace All",
|
||||
@@ -3615,8 +3614,6 @@
|
||||
"missingNodePacks": {
|
||||
"title": "Missing Node Packs",
|
||||
"unsupportedTitle": "Unsupported Node Packs",
|
||||
"cloudMessage": "This workflow requires custom nodes not yet available on Comfy Cloud.",
|
||||
"ossMessage": "This workflow uses custom nodes you haven't installed yet.",
|
||||
"ossManagerDisabledHint": "To install missing nodes, first run {pipCmd} in your Python environment to install Node Manager, then restart ComfyUI with the {flag} flag.",
|
||||
"installAll": "Install All",
|
||||
"installNodePack": "Install node pack",
|
||||
@@ -3686,7 +3683,6 @@
|
||||
"viewDetails": "View details",
|
||||
"missingNodes": "Some nodes are missing and need to be installed",
|
||||
"missingModels": "{count} required model is missing | {count} required models are missing",
|
||||
"swapNodes": "Some nodes can be replaced with alternatives",
|
||||
"missingMedia": "Some nodes are missing required inputs"
|
||||
},
|
||||
"errorCatalog": {
|
||||
@@ -3694,6 +3690,46 @@
|
||||
"nodeName": "This node",
|
||||
"inputName": "unknown input"
|
||||
},
|
||||
"missingErrors": {
|
||||
"missing_node": {
|
||||
"displayMessageCloud": "Required custom nodes aren't supported on Cloud. Replace them with supported nodes.",
|
||||
"displayMessageOss": "Install missing packs to use this workflow.",
|
||||
"toastTitleOneCloud": "{nodeType} isn't available on Cloud",
|
||||
"toastTitleOneOss": "Missing node: {nodeType}",
|
||||
"toastTitleManyCloud": "Nodes aren't available on Cloud",
|
||||
"toastTitleManyOss": "Missing nodes",
|
||||
"toastMessageOneCloud": "This node isn't supported on Cloud.",
|
||||
"toastMessageOneOss": "This workflow uses a custom node that isn't installed. Install it from the registry or replace the node.",
|
||||
"toastMessageManyCloud": "This workflow uses nodes that aren't supported on Cloud.",
|
||||
"toastMessageManyOss": "{count} nodes require missing node packs."
|
||||
},
|
||||
"swap_nodes": {
|
||||
"displayMessage": "Some nodes can be replaced with alternatives",
|
||||
"toastTitleOne": "{nodeType} can be replaced",
|
||||
"toastTitleMany": "Nodes can be replaced",
|
||||
"toastMessageOne": "Replace it with {replacementNodeType} from the error panel.",
|
||||
"toastMessageMany": "{count} node types can be replaced with compatible alternatives."
|
||||
},
|
||||
"missing_model": {
|
||||
"displayMessageCloud": "Import a model, or open the node to replace it.",
|
||||
"displayMessageOss": "Download a model, or open the node to replace it.",
|
||||
"toastTitleOneCloud": "{modelName} isn't available on Cloud",
|
||||
"toastTitleOneOss": "{modelName} is missing",
|
||||
"toastTitleMany": "Missing models",
|
||||
"toastTitleManyCloud": "Models aren't available on Cloud",
|
||||
"toastMessageOneCloud": "This model isn't supported. Choose a different one.",
|
||||
"toastMessageOneOss": "{nodeName} is missing a required model file.",
|
||||
"toastMessageManyCloud": "Some models aren't supported. Choose different ones.",
|
||||
"toastMessageManyOss": "{count} model files are missing."
|
||||
},
|
||||
"missing_media": {
|
||||
"displayMessage": "A required media input has no file selected.",
|
||||
"toastTitleOne": "Media input missing",
|
||||
"toastTitleMany": "Missing media inputs",
|
||||
"toastMessageWithNode": "{nodeName} is missing a required media file.",
|
||||
"toastMessageMany": "Please select the missing media inputs before running this workflow."
|
||||
}
|
||||
},
|
||||
"validationErrors": {
|
||||
"required_input_missing": {
|
||||
"title": "Missing connection",
|
||||
@@ -3770,6 +3806,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'
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
} from './errorMessageResolver'
|
||||
import type { NodeValidationError } from './types'
|
||||
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import type { MissingModelGroup } from '@/platform/missingModel/types'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
import { i18n } from '@/i18n'
|
||||
|
||||
function nodeValidationError(
|
||||
@@ -55,6 +58,59 @@ function executionError(
|
||||
}
|
||||
}
|
||||
|
||||
function missingNodeType(
|
||||
type: string,
|
||||
nodeId: string,
|
||||
cnrId?: string
|
||||
): MissingNodeType {
|
||||
return {
|
||||
type,
|
||||
nodeId,
|
||||
cnrId,
|
||||
isReplaceable: false
|
||||
}
|
||||
}
|
||||
|
||||
function replaceableNodeType(
|
||||
type: string,
|
||||
nodeId: string,
|
||||
replacementNodeType: string
|
||||
): MissingNodeType {
|
||||
return {
|
||||
type,
|
||||
nodeId,
|
||||
isReplaceable: true,
|
||||
replacement: {
|
||||
old_node_id: type,
|
||||
new_node_id: replacementNodeType,
|
||||
old_widget_ids: null,
|
||||
input_mapping: null,
|
||||
output_mapping: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function missingModelGroups(...names: string[]): MissingModelGroup[] {
|
||||
return [
|
||||
{
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true,
|
||||
models: names.map((name) => ({
|
||||
name,
|
||||
representative: {
|
||||
name,
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
directory: 'checkpoints',
|
||||
isAssetSupported: true,
|
||||
isMissing: true
|
||||
},
|
||||
referencingNodes: []
|
||||
}))
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
describe('errorMessageResolver', () => {
|
||||
it('resolves required_input_missing to missing connection display copy', () => {
|
||||
const result = resolveRunErrorMessage({
|
||||
@@ -88,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')
|
||||
@@ -1307,17 +1383,342 @@ describe('errorMessageResolver', () => {
|
||||
})
|
||||
|
||||
it('resolves missing error group display copy', () => {
|
||||
const missingNodeTypes = [missingNodeType('FooNode', '7', 'foo-pack')]
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
kind: 'missing_node',
|
||||
nodeTypes: missingNodeTypes,
|
||||
count: 1,
|
||||
isCloud: false
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'missing_node',
|
||||
displayTitle: 'Missing Node Packs (1)',
|
||||
displayMessage: 'Install missing packs to use this workflow.',
|
||||
toastTitle: 'Missing node: FooNode',
|
||||
toastMessage:
|
||||
"This workflow uses a custom node that isn't installed. Install it from the registry or replace the node."
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
kind: 'missing_node',
|
||||
nodeTypes: missingNodeTypes,
|
||||
count: 1,
|
||||
isCloud: true
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'missing_node',
|
||||
displayTitle: 'Unsupported Node Packs (1)',
|
||||
displayMessage:
|
||||
"Required custom nodes aren't supported on Cloud. Replace them with supported nodes.",
|
||||
toastTitle: "FooNode isn't available on Cloud",
|
||||
toastMessage: "This node isn't supported on Cloud."
|
||||
})
|
||||
|
||||
const multipleMissingNodeTypes = [
|
||||
missingNodeType('FooNode', '7', 'foo-pack'),
|
||||
missingNodeType('BarNode', '9', 'bar-pack')
|
||||
]
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
kind: 'missing_node',
|
||||
nodeTypes: multipleMissingNodeTypes,
|
||||
count: 2,
|
||||
isCloud: false
|
||||
})
|
||||
).toMatchObject({
|
||||
toastTitle: 'Missing nodes',
|
||||
toastMessage: '2 nodes require missing node packs.'
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
kind: 'missing_node',
|
||||
nodeTypes: multipleMissingNodeTypes,
|
||||
count: 2,
|
||||
isCloud: true
|
||||
})
|
||||
).toMatchObject({
|
||||
toastTitle: "Nodes aren't available on Cloud",
|
||||
toastMessage: "This workflow uses nodes that aren't supported on Cloud."
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
kind: 'missing_node',
|
||||
nodeTypes: [
|
||||
missingNodeType('FooNode', '7', 'foo-pack'),
|
||||
missingNodeType('FooNode', '8', 'foo-pack')
|
||||
],
|
||||
count: 1,
|
||||
isCloud: false
|
||||
})
|
||||
).toMatchObject({
|
||||
toastTitle: 'Missing node: FooNode',
|
||||
toastMessage:
|
||||
"This workflow uses a custom node that isn't installed. Install it from the registry or replace the node."
|
||||
})
|
||||
|
||||
const swapNodeTypes = [replaceableNodeType('OldNode', '8', 'NewNode')]
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
kind: 'swap_nodes',
|
||||
nodeTypes: swapNodeTypes,
|
||||
count: 1,
|
||||
isCloud: false
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'swap_nodes',
|
||||
displayTitle: 'Swap Nodes (1)',
|
||||
displayMessage: 'Some nodes can be replaced with alternatives',
|
||||
toastTitle: 'OldNode can be replaced',
|
||||
toastMessage: 'Replace it with NewNode from the error panel.'
|
||||
})
|
||||
|
||||
const multipleSwapNodeTypes = [
|
||||
replaceableNodeType('OldNodeA', '8', 'NewNodeA'),
|
||||
replaceableNodeType('OldNodeB', '9', 'NewNodeB')
|
||||
]
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
kind: 'swap_nodes',
|
||||
nodeTypes: multipleSwapNodeTypes,
|
||||
count: 2,
|
||||
isCloud: false
|
||||
})
|
||||
).toMatchObject({
|
||||
displayMessage: 'Some nodes can be replaced with alternatives',
|
||||
toastTitle: 'Nodes can be replaced',
|
||||
toastMessage: '2 node types can be replaced with compatible alternatives.'
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
kind: 'swap_nodes',
|
||||
nodeTypes: [
|
||||
replaceableNodeType('OldNode', '8', 'NewNode'),
|
||||
replaceableNodeType('OldNode', '9', 'NewNode')
|
||||
],
|
||||
count: 1,
|
||||
isCloud: false
|
||||
})
|
||||
).toMatchObject({
|
||||
toastTitle: 'OldNode can be replaced',
|
||||
toastMessage: 'Replace it with NewNode from the error panel.'
|
||||
})
|
||||
|
||||
const groups = missingModelGroups('sdxl.safetensors')
|
||||
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
kind: 'missing_model',
|
||||
groups: [],
|
||||
groups,
|
||||
count: 1,
|
||||
isCloud: false
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'missing_model',
|
||||
displayTitle: 'Missing Models (1)',
|
||||
displayMessage: '1 required model is missing'
|
||||
displayMessage: 'Download a model, or open the node to replace it.',
|
||||
toastTitle: 'sdxl.safetensors is missing',
|
||||
toastMessage: 'Checkpoint Loader Simple is missing a required model file.'
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
kind: 'missing_model',
|
||||
groups,
|
||||
count: 1,
|
||||
isCloud: true
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'missing_model',
|
||||
displayTitle: 'Missing Models (1)',
|
||||
displayMessage: 'Import a model, or open the node to replace it.',
|
||||
toastTitle: "sdxl.safetensors isn't available on Cloud",
|
||||
toastMessage: "This model isn't supported. Choose a different one."
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves missing media group display and toast copy', () => {
|
||||
const groups: MissingMediaGroup[] = [
|
||||
{
|
||||
mediaType: 'image',
|
||||
items: [
|
||||
{
|
||||
name: 'portrait.png',
|
||||
mediaType: 'image',
|
||||
representative: {
|
||||
nodeId: '4',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'portrait.png',
|
||||
isMissing: true
|
||||
},
|
||||
referencingNodes: [{ nodeId: '4', widgetName: 'image' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
kind: 'missing_media',
|
||||
groups,
|
||||
count: 1,
|
||||
isCloud: false
|
||||
})
|
||||
).toEqual({
|
||||
catalogId: 'missing_media',
|
||||
displayTitle: 'Missing Inputs (1)',
|
||||
displayMessage: 'A required media input has no file selected.',
|
||||
toastTitle: 'Media input missing',
|
||||
toastMessage: 'Load Image is missing a required media file.'
|
||||
})
|
||||
})
|
||||
|
||||
it.for([
|
||||
[
|
||||
'image',
|
||||
'LoadImage',
|
||||
'image',
|
||||
'portrait.png',
|
||||
'Media input missing',
|
||||
'Load Image is missing a required media file.'
|
||||
],
|
||||
[
|
||||
'video',
|
||||
'LoadVideo',
|
||||
'file',
|
||||
'clip.mp4',
|
||||
'Media input missing',
|
||||
'Load Video is missing a required media file.'
|
||||
],
|
||||
[
|
||||
'audio',
|
||||
'LoadAudio',
|
||||
'audio',
|
||||
'voice.wav',
|
||||
'Media input missing',
|
||||
'Load Audio is missing a required media file.'
|
||||
]
|
||||
] as const)(
|
||||
'resolves missing %s toast copy from media type and node type',
|
||||
([
|
||||
mediaType,
|
||||
nodeType,
|
||||
widgetName,
|
||||
mediaName,
|
||||
toastTitle,
|
||||
toastMessage
|
||||
]) => {
|
||||
const groups: MissingMediaGroup[] = [
|
||||
{
|
||||
mediaType,
|
||||
items: [
|
||||
{
|
||||
name: mediaName,
|
||||
mediaType,
|
||||
representative: {
|
||||
nodeId: '4',
|
||||
nodeType,
|
||||
widgetName,
|
||||
mediaType,
|
||||
name: mediaName,
|
||||
isMissing: true
|
||||
},
|
||||
referencingNodes: [{ nodeId: '4', widgetName }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
kind: 'missing_media',
|
||||
groups,
|
||||
count: 1,
|
||||
isCloud: false
|
||||
})
|
||||
).toMatchObject({
|
||||
toastTitle,
|
||||
toastMessage
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('summarizes multiple missing model and media items', () => {
|
||||
const modelGroups = missingModelGroups('a.safetensors', 'b.safetensors')
|
||||
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
kind: 'missing_model',
|
||||
groups: modelGroups,
|
||||
count: 2,
|
||||
isCloud: false
|
||||
})
|
||||
).toMatchObject({
|
||||
toastTitle: 'Missing models',
|
||||
toastMessage: '2 model files are missing.'
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
kind: 'missing_model',
|
||||
groups: modelGroups,
|
||||
count: 2,
|
||||
isCloud: true
|
||||
})
|
||||
).toMatchObject({
|
||||
toastTitle: "Models aren't available on Cloud",
|
||||
toastMessage: "Some models aren't supported. Choose different ones."
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveMissingErrorMessage({
|
||||
kind: 'missing_media',
|
||||
groups: [
|
||||
{
|
||||
mediaType: 'image',
|
||||
items: [
|
||||
{
|
||||
name: 'a.png',
|
||||
mediaType: 'image',
|
||||
representative: {
|
||||
nodeId: '1',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'a.png',
|
||||
isMissing: true
|
||||
},
|
||||
referencingNodes: [{ nodeId: '1', widgetName: 'image' }]
|
||||
},
|
||||
{
|
||||
name: 'b.png',
|
||||
mediaType: 'image',
|
||||
representative: {
|
||||
nodeId: '2',
|
||||
nodeType: 'LoadImage',
|
||||
widgetName: 'image',
|
||||
mediaType: 'image',
|
||||
name: 'b.png',
|
||||
isMissing: true
|
||||
},
|
||||
referencingNodes: [{ nodeId: '2', widgetName: 'image' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
count: 2,
|
||||
isCloud: false
|
||||
})
|
||||
).toMatchObject({
|
||||
toastTitle: 'Missing media inputs',
|
||||
toastMessage:
|
||||
'Please select the missing media inputs before running this workflow.'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,19 +2,312 @@ import type {
|
||||
MissingErrorMessageSource,
|
||||
ResolvedMissingErrorMessage
|
||||
} from './types'
|
||||
import { st, t } from '@/i18n'
|
||||
import { translateCatalogMessage } from './catalogI18n'
|
||||
import { st } from '@/i18n'
|
||||
|
||||
// Resolves pre-run missing-resource groups (nodes, models, media, swaps). These
|
||||
// are grouped catalog messages rather than individual execution error items.
|
||||
function formatCountTitle(title: string, count: number): string {
|
||||
return `${title} (${count})`
|
||||
}
|
||||
|
||||
function translateMissingModelOverlayMessage(count: number): string {
|
||||
const translated = t('errorOverlay.missingModels', { count }, count)
|
||||
return translated === 'errorOverlay.missingModels'
|
||||
? `${count} required ${count === 1 ? 'model is' : 'models are'} missing`
|
||||
: translated
|
||||
function formatNodeTypeName(nodeType: string): string | null {
|
||||
const trimmed = nodeType.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
return trimmed
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
type NodeTypeErrorSource = Extract<
|
||||
MissingErrorMessageSource,
|
||||
{ kind: 'missing_node' | 'swap_nodes' }
|
||||
>
|
||||
type NodeTypeErrorItem = NodeTypeErrorSource['nodeTypes'][number]
|
||||
|
||||
function getNodeTypeLabel(nodeType: NodeTypeErrorItem): string {
|
||||
return typeof nodeType === 'string' ? nodeType : nodeType.type
|
||||
}
|
||||
|
||||
function getDistinctNodeTypeLabels(nodeTypes: NodeTypeErrorItem[]): string[] {
|
||||
const labels = new Set<string>()
|
||||
for (const nodeType of nodeTypes) labels.add(getNodeTypeLabel(nodeType))
|
||||
return Array.from(labels)
|
||||
}
|
||||
|
||||
type MissingNodeSource = Extract<
|
||||
MissingErrorMessageSource,
|
||||
{ kind: 'missing_node' }
|
||||
>
|
||||
|
||||
function isMissingNodeType(nodeType: NodeTypeErrorItem): boolean {
|
||||
return typeof nodeType === 'string' || !nodeType.isReplaceable
|
||||
}
|
||||
|
||||
function resolveMissingNodeDisplayMessage(source: MissingNodeSource): string {
|
||||
const key = source.isCloud
|
||||
? 'errorCatalog.missingErrors.missing_node.displayMessageCloud'
|
||||
: 'errorCatalog.missingErrors.missing_node.displayMessageOss'
|
||||
const fallback = source.isCloud
|
||||
? "Required custom nodes aren't supported on Cloud. Replace them with supported nodes."
|
||||
: 'Install missing packs to use this workflow.'
|
||||
return translateCatalogMessage(key, fallback)
|
||||
}
|
||||
|
||||
function resolveMissingNodeToastTitle(source: MissingNodeSource): string {
|
||||
const labels = getDistinctNodeTypeLabels(
|
||||
source.nodeTypes.filter(isMissingNodeType)
|
||||
)
|
||||
const [firstLabel] = labels
|
||||
if (labels.length === 1 && firstLabel) {
|
||||
const key = source.isCloud
|
||||
? 'errorCatalog.missingErrors.missing_node.toastTitleOneCloud'
|
||||
: 'errorCatalog.missingErrors.missing_node.toastTitleOneOss'
|
||||
const fallback = source.isCloud
|
||||
? "{nodeType} isn't available on Cloud"
|
||||
: 'Missing node: {nodeType}'
|
||||
return translateCatalogMessage(key, fallback, { nodeType: firstLabel })
|
||||
}
|
||||
|
||||
const key = source.isCloud
|
||||
? 'errorCatalog.missingErrors.missing_node.toastTitleManyCloud'
|
||||
: 'errorCatalog.missingErrors.missing_node.toastTitleManyOss'
|
||||
const fallback = source.isCloud
|
||||
? "Nodes aren't available on Cloud"
|
||||
: 'Missing nodes'
|
||||
return translateCatalogMessage(key, fallback)
|
||||
}
|
||||
|
||||
function resolveMissingNodeToastMessage(source: MissingNodeSource): string {
|
||||
const labels = getDistinctNodeTypeLabels(
|
||||
source.nodeTypes.filter(isMissingNodeType)
|
||||
)
|
||||
const count = labels.length || source.count
|
||||
|
||||
if (count === 1) {
|
||||
const key = source.isCloud
|
||||
? 'errorCatalog.missingErrors.missing_node.toastMessageOneCloud'
|
||||
: 'errorCatalog.missingErrors.missing_node.toastMessageOneOss'
|
||||
const fallback = source.isCloud
|
||||
? "This node isn't supported on Cloud."
|
||||
: "This workflow uses a custom node that isn't installed. Install it from the registry or replace the node."
|
||||
return translateCatalogMessage(key, fallback)
|
||||
}
|
||||
|
||||
const key = source.isCloud
|
||||
? 'errorCatalog.missingErrors.missing_node.toastMessageManyCloud'
|
||||
: 'errorCatalog.missingErrors.missing_node.toastMessageManyOss'
|
||||
const fallback = source.isCloud
|
||||
? "This workflow uses nodes that aren't supported on Cloud."
|
||||
: '{count} nodes require missing node packs.'
|
||||
return translateCatalogMessage(key, fallback, { count })
|
||||
}
|
||||
|
||||
type SwapNodeSource = Extract<MissingErrorMessageSource, { kind: 'swap_nodes' }>
|
||||
|
||||
function isSwapNodeType(nodeType: NodeTypeErrorItem): nodeType is Exclude<
|
||||
NodeTypeErrorItem,
|
||||
string
|
||||
> & {
|
||||
isReplaceable: true
|
||||
} {
|
||||
return typeof nodeType !== 'string' && nodeType.isReplaceable === true
|
||||
}
|
||||
|
||||
function getSwapNodeTypes(source: SwapNodeSource) {
|
||||
return source.nodeTypes.filter(isSwapNodeType)
|
||||
}
|
||||
|
||||
function resolveSwapNodeToastTitle(source: SwapNodeSource): string {
|
||||
const nodeTypes = getSwapNodeTypes(source)
|
||||
const labels = getDistinctNodeTypeLabels(nodeTypes)
|
||||
const [firstLabel] = labels
|
||||
if (labels.length === 1 && firstLabel) {
|
||||
return translateCatalogMessage(
|
||||
'errorCatalog.missingErrors.swap_nodes.toastTitleOne',
|
||||
'{nodeType} can be replaced',
|
||||
{ nodeType: firstLabel }
|
||||
)
|
||||
}
|
||||
|
||||
return translateCatalogMessage(
|
||||
'errorCatalog.missingErrors.swap_nodes.toastTitleMany',
|
||||
'Nodes can be replaced'
|
||||
)
|
||||
}
|
||||
|
||||
function resolveSwapNodeToastMessage(source: SwapNodeSource): string {
|
||||
const nodeTypes = getSwapNodeTypes(source)
|
||||
const labels = getDistinctNodeTypeLabels(nodeTypes)
|
||||
const [firstNodeType] = nodeTypes
|
||||
if (labels.length === 1 && firstNodeType?.replacement?.new_node_id) {
|
||||
return translateCatalogMessage(
|
||||
'errorCatalog.missingErrors.swap_nodes.toastMessageOne',
|
||||
'Replace it with {replacementNodeType} from the error panel.',
|
||||
{ replacementNodeType: firstNodeType.replacement.new_node_id }
|
||||
)
|
||||
}
|
||||
|
||||
return translateCatalogMessage(
|
||||
'errorCatalog.missingErrors.swap_nodes.toastMessageMany',
|
||||
'{count} node types can be replaced with compatible alternatives.',
|
||||
{ count: labels.length || source.count }
|
||||
)
|
||||
}
|
||||
|
||||
function resolveSwapNodeDisplayMessage(): string {
|
||||
return translateCatalogMessage(
|
||||
'errorCatalog.missingErrors.swap_nodes.displayMessage',
|
||||
'Some nodes can be replaced with alternatives'
|
||||
)
|
||||
}
|
||||
|
||||
type MissingModelSource = Extract<
|
||||
MissingErrorMessageSource,
|
||||
{ kind: 'missing_model' }
|
||||
>
|
||||
|
||||
function getMissingModelCount(source: MissingModelSource): number {
|
||||
const count = source.groups.reduce(
|
||||
(total, group) => total + group.models.length,
|
||||
0
|
||||
)
|
||||
return count || source.count
|
||||
}
|
||||
|
||||
function resolveMissingModelDisplayMessage(source: MissingModelSource): string {
|
||||
const key = source.isCloud
|
||||
? 'errorCatalog.missingErrors.missing_model.displayMessageCloud'
|
||||
: 'errorCatalog.missingErrors.missing_model.displayMessageOss'
|
||||
const fallback = source.isCloud
|
||||
? 'Import a model, or open the node to replace it.'
|
||||
: 'Download a model, or open the node to replace it.'
|
||||
return translateCatalogMessage(key, fallback)
|
||||
}
|
||||
|
||||
function resolveMissingModelToastTitle(source: MissingModelSource): string {
|
||||
const [firstModel] = source.groups.flatMap((group) => group.models)
|
||||
const count = getMissingModelCount(source)
|
||||
|
||||
if (count === 1 && firstModel) {
|
||||
const key = source.isCloud
|
||||
? 'errorCatalog.missingErrors.missing_model.toastTitleOneCloud'
|
||||
: 'errorCatalog.missingErrors.missing_model.toastTitleOneOss'
|
||||
const fallback = source.isCloud
|
||||
? "{modelName} isn't available on Cloud"
|
||||
: '{modelName} is missing'
|
||||
return translateCatalogMessage(key, fallback, {
|
||||
modelName: firstModel.name
|
||||
})
|
||||
}
|
||||
|
||||
const useCloudPluralTitle = source.isCloud && count > 1
|
||||
const key = useCloudPluralTitle
|
||||
? 'errorCatalog.missingErrors.missing_model.toastTitleManyCloud'
|
||||
: 'errorCatalog.missingErrors.missing_model.toastTitleMany'
|
||||
const fallback = useCloudPluralTitle
|
||||
? "Models aren't available on Cloud"
|
||||
: 'Missing models'
|
||||
return translateCatalogMessage(key, fallback)
|
||||
}
|
||||
|
||||
function getMissingModelNodeName(
|
||||
model: MissingModelSource['groups'][number]['models'][number]
|
||||
): string {
|
||||
return (
|
||||
formatNodeTypeName(model.representative.nodeType) ??
|
||||
translateCatalogMessage('errorCatalog.fallbacks.nodeName', 'This node')
|
||||
)
|
||||
}
|
||||
|
||||
function resolveMissingModelToastMessage(source: MissingModelSource): string {
|
||||
const [firstModel] = source.groups.flatMap((group) => group.models)
|
||||
const count = getMissingModelCount(source)
|
||||
|
||||
if (!firstModel || count !== 1) {
|
||||
const key = source.isCloud
|
||||
? 'errorCatalog.missingErrors.missing_model.toastMessageManyCloud'
|
||||
: 'errorCatalog.missingErrors.missing_model.toastMessageManyOss'
|
||||
const fallback = source.isCloud
|
||||
? "Some models aren't supported. Choose different ones."
|
||||
: '{count} model files are missing.'
|
||||
return translateCatalogMessage(key, fallback, { count })
|
||||
}
|
||||
|
||||
if (source.isCloud) {
|
||||
return translateCatalogMessage(
|
||||
'errorCatalog.missingErrors.missing_model.toastMessageOneCloud',
|
||||
"This model isn't supported. Choose a different one."
|
||||
)
|
||||
}
|
||||
|
||||
return translateCatalogMessage(
|
||||
'errorCatalog.missingErrors.missing_model.toastMessageOneOss',
|
||||
'{nodeName} is missing a required model file.',
|
||||
{ nodeName: getMissingModelNodeName(firstModel) }
|
||||
)
|
||||
}
|
||||
|
||||
type MissingMediaSource = Extract<
|
||||
MissingErrorMessageSource,
|
||||
{ kind: 'missing_media' }
|
||||
>
|
||||
|
||||
function getMissingMediaItems(source: MissingMediaSource) {
|
||||
return source.groups.flatMap((group) => group.items)
|
||||
}
|
||||
|
||||
function getMissingMediaNodeName(
|
||||
item: ReturnType<typeof getMissingMediaItems>[number]
|
||||
): string | null {
|
||||
return formatNodeTypeName(item.representative.nodeType)
|
||||
}
|
||||
|
||||
function resolveMissingMediaDisplayMessage(): string {
|
||||
return translateCatalogMessage(
|
||||
'errorCatalog.missingErrors.missing_media.displayMessage',
|
||||
'A required media input has no file selected.'
|
||||
)
|
||||
}
|
||||
|
||||
function resolveMissingMediaToastTitle(source: MissingMediaSource): string {
|
||||
const items = getMissingMediaItems(source)
|
||||
if (items.length !== 1) {
|
||||
return translateCatalogMessage(
|
||||
'errorCatalog.missingErrors.missing_media.toastTitleMany',
|
||||
'Missing media inputs'
|
||||
)
|
||||
}
|
||||
|
||||
return translateCatalogMessage(
|
||||
'errorCatalog.missingErrors.missing_media.toastTitleOne',
|
||||
'Media input missing'
|
||||
)
|
||||
}
|
||||
|
||||
function resolveMissingMediaToastMessage(source: MissingMediaSource): string {
|
||||
const items = getMissingMediaItems(source)
|
||||
const [firstItem] = items
|
||||
if (!firstItem || items.length !== 1) {
|
||||
return translateCatalogMessage(
|
||||
'errorCatalog.missingErrors.missing_media.toastMessageMany',
|
||||
'Please select the missing media inputs before running this workflow.'
|
||||
)
|
||||
}
|
||||
|
||||
const nodeName = getMissingMediaNodeName(firstItem)
|
||||
const displayNodeName =
|
||||
nodeName ??
|
||||
translateCatalogMessage('errorCatalog.fallbacks.nodeName', 'This node')
|
||||
return translateCatalogMessage(
|
||||
'errorCatalog.missingErrors.missing_media.toastMessageWithNode',
|
||||
'{nodeName} is missing a required media file.',
|
||||
{
|
||||
nodeName: displayNodeName
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveMissingErrorMessage(
|
||||
@@ -33,10 +326,9 @@ export function resolveMissingErrorMessage(
|
||||
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
|
||||
source.count
|
||||
),
|
||||
displayMessage: st(
|
||||
'errorOverlay.missingNodes',
|
||||
'Some nodes are missing and need to be installed'
|
||||
)
|
||||
displayMessage: resolveMissingNodeDisplayMessage(source),
|
||||
toastTitle: resolveMissingNodeToastTitle(source),
|
||||
toastMessage: resolveMissingNodeToastMessage(source)
|
||||
}
|
||||
case 'swap_nodes':
|
||||
return {
|
||||
@@ -45,10 +337,9 @@ export function resolveMissingErrorMessage(
|
||||
st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
|
||||
source.count
|
||||
),
|
||||
displayMessage: st(
|
||||
'errorOverlay.swapNodes',
|
||||
'Some nodes can be replaced with alternatives'
|
||||
)
|
||||
displayMessage: resolveSwapNodeDisplayMessage(),
|
||||
toastTitle: resolveSwapNodeToastTitle(source),
|
||||
toastMessage: resolveSwapNodeToastMessage(source)
|
||||
}
|
||||
case 'missing_model':
|
||||
return {
|
||||
@@ -60,7 +351,9 @@ export function resolveMissingErrorMessage(
|
||||
),
|
||||
source.count
|
||||
),
|
||||
displayMessage: translateMissingModelOverlayMessage(source.count)
|
||||
displayMessage: resolveMissingModelDisplayMessage(source),
|
||||
toastTitle: resolveMissingModelToastTitle(source),
|
||||
toastMessage: resolveMissingModelToastMessage(source)
|
||||
}
|
||||
case 'missing_media':
|
||||
return {
|
||||
@@ -69,10 +362,9 @@ export function resolveMissingErrorMessage(
|
||||
st('rightSidePanel.missingMedia.missingMediaTitle', 'Missing Inputs'),
|
||||
source.count
|
||||
),
|
||||
displayMessage: st(
|
||||
'errorOverlay.missingMedia',
|
||||
'Some nodes are missing required inputs'
|
||||
)
|
||||
displayMessage: resolveMissingMediaDisplayMessage(),
|
||||
toastTitle: resolveMissingMediaToastTitle(source),
|
||||
toastMessage: resolveMissingMediaToastMessage(source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,10 +3,7 @@ import type {
|
||||
NodeError,
|
||||
PromptError
|
||||
} from '@/schemas/apiSchema'
|
||||
import type {
|
||||
MissingMediaGroup,
|
||||
MediaType
|
||||
} from '@/platform/missingMedia/types'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import type { MissingModelGroup } from '@/platform/missingModel/types'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
|
||||
@@ -28,6 +25,10 @@ export interface ResolvedErrorMessage {
|
||||
toastMessage?: string
|
||||
}
|
||||
|
||||
export type ResolvedCatalogErrorMessage = ResolvedErrorMessage & {
|
||||
catalogId: string
|
||||
}
|
||||
|
||||
export type ResolvedMissingErrorMessage = ResolvedErrorMessage & {
|
||||
displayTitle: string
|
||||
displayMessage: string
|
||||
@@ -73,6 +74,5 @@ export type MissingErrorMessageSource =
|
||||
kind: 'missing_media'
|
||||
groups: MissingMediaGroup[]
|
||||
count: number
|
||||
mediaTypes: MediaType[]
|
||||
isCloud: boolean
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
@@ -272,7 +278,7 @@ function resolveValidationCatalogCopy(
|
||||
context: ErrorResolveContext,
|
||||
localeKey: string,
|
||||
rule: ValidationCatalogRule
|
||||
): ResolvedErrorMessage {
|
||||
): ResolvedCatalogErrorMessage {
|
||||
const nodeName = normalizeNodeName(context.nodeDisplayName)
|
||||
const inputName = getInputName(error)
|
||||
const trimmedDetails = error.details.trim()
|
||||
@@ -282,6 +288,7 @@ function resolveValidationCatalogCopy(
|
||||
: trimmedDetails
|
||||
const params = {
|
||||
...getValidationParams(error, nodeName, inputName),
|
||||
errorType: error.type,
|
||||
rawDetails
|
||||
}
|
||||
const keyPrefix = `errorCatalog.validationErrors.${localeKey}`
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -422,6 +422,7 @@ describe('groupCandidatesByName', () => {
|
||||
const photoGroup = result.find((g) => g.name === 'photo.png')
|
||||
expect(photoGroup?.referencingNodes).toHaveLength(2)
|
||||
expect(photoGroup?.mediaType).toBe('image')
|
||||
expect(photoGroup?.representative.nodeType).toBe('LoadImage')
|
||||
|
||||
const otherGroup = result.find((g) => g.name === 'other.png')
|
||||
expect(otherGroup?.referencingNodes).toHaveLength(1)
|
||||
|
||||
@@ -262,6 +262,7 @@ export function groupCandidatesByName(
|
||||
map.set(c.name, {
|
||||
name: c.name,
|
||||
mediaType: c.mediaType,
|
||||
representative: c,
|
||||
referencingNodes: [{ nodeId: c.nodeId, widgetName: c.widgetName }]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface MissingMediaCandidate {
|
||||
export interface MissingMediaViewModel {
|
||||
name: string
|
||||
mediaType: MediaType
|
||||
representative: MissingMediaCandidate
|
||||
referencingNodes: Array<{
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
|
||||
@@ -3,7 +3,6 @@ import userEvent from '@testing-library/user-event'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { SwapNodeGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
|
||||
@@ -19,14 +18,6 @@ vi.mock('./SwapNodeGroupRow.vue', () => ({
|
||||
|
||||
import SwapNodesCard from './SwapNodesCard.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
function makeGroups(count = 2): SwapNodeGroup[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
type: `Type${i}`,
|
||||
@@ -56,19 +47,13 @@ function mountCard(
|
||||
...(callbacks?.onReplace ? { onReplace: callbacks.onReplace } : {})
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue, i18n]
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), PrimeVue]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('SwapNodesCard', () => {
|
||||
describe('Rendering', () => {
|
||||
it('renders guidance message', () => {
|
||||
const { container } = mountCard()
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
expect(container.querySelector('p')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('renders correct number of SwapNodeGroupRow components', () => {
|
||||
const { container } = mountCard({ swapNodeGroups: makeGroups(3) })
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
<template>
|
||||
<div class="mt-2 px-4 pb-2">
|
||||
<!-- Sub-label: guidance message shown above all swap groups -->
|
||||
<p class="m-0 pb-5 text-sm/relaxed text-muted-foreground">
|
||||
{{
|
||||
t(
|
||||
'nodeReplacement.swapNodesGuide',
|
||||
'The following nodes can be automatically replaced with compatible alternatives.'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<!-- Group Rows -->
|
||||
<SwapNodeGroupRow
|
||||
v-for="group in swapNodeGroups"
|
||||
:key="group.type"
|
||||
@@ -22,12 +12,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { SwapNodeGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
|
||||
import SwapNodeGroupRow from '@/platform/nodeReplacement/components/SwapNodeGroupRow.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { swapNodeGroups, showNodeIdBadge } = defineProps<{
|
||||
swapNodeGroups: SwapNodeGroup[]
|
||||
showNodeIdBadge: boolean
|
||||
|
||||
Reference in New Issue
Block a user