mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-18 09:48:09 +00:00
## Summary Adds a branded local `NodeId` helper and starts separating local node identity from serialized workflow IDs. ## Changes - **What**: Adds central `NodeId` parsing/branding helpers, migrates nearby widget identity types, keeps queue results at the serialized boundary, and removes misleading workflow `NodeId` usage from execution error maps. ## Review Focus Check that the first migration slice keeps serialized/API IDs as raw `number | string` while local UI/store IDs use the branded string type. ## Caveat `SUBGRAPH_INPUT_ID` and `SUBGRAPH_OUTPUT_ID` are now branded local `NodeId` string values internally instead of numeric sentinels. Reviewers should double-check extension compatibility for callers that import `Constants` and compare those values numerically. ## Screenshots (if applicable) N/A --------- Co-authored-by: GitHub Action <action@github.com> Co-authored-by: AustinMroz <austin@comfy.org> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
426 lines
12 KiB
TypeScript
426 lines
12 KiB
TypeScript
import { createTestingPinia } from '@pinia/testing'
|
|
import { render, screen, waitFor } 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 ErrorNodeCard from './ErrorNodeCard.vue'
|
|
import type { ErrorCardData } from './types'
|
|
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
|
import { toNodeId } from '@/types/nodeId'
|
|
|
|
const mockGetLogs = vi.fn(() => Promise.resolve('mock server logs'))
|
|
const mockSerialize = vi.fn(() => ({ nodes: [] }))
|
|
const mockGenerateErrorReport = vi.fn(
|
|
(_data?: unknown) => '# ComfyUI Error Report\n...'
|
|
)
|
|
|
|
vi.mock('@/scripts/api', () => ({
|
|
api: {
|
|
getLogs: () => mockGetLogs()
|
|
}
|
|
}))
|
|
|
|
vi.mock('@/scripts/app', () => ({
|
|
app: {
|
|
rootGraph: {
|
|
serialize: () => mockSerialize()
|
|
}
|
|
}
|
|
}))
|
|
|
|
vi.mock('@/utils/errorReportUtil', () => ({
|
|
generateErrorReport: (data: unknown) => mockGenerateErrorReport(data)
|
|
}))
|
|
|
|
const mockTrackHelpResourceClicked = vi.fn()
|
|
|
|
vi.mock('@/platform/telemetry', () => ({
|
|
useTelemetry: vi.fn(() => ({
|
|
trackUiButtonClicked: vi.fn(),
|
|
trackHelpResourceClicked: mockTrackHelpResourceClicked
|
|
}))
|
|
}))
|
|
|
|
const mockExecuteCommand = vi.fn()
|
|
vi.mock('@/stores/commandStore', () => ({
|
|
useCommandStore: vi.fn(() => ({
|
|
execute: mockExecuteCommand
|
|
}))
|
|
}))
|
|
|
|
vi.mock('@/composables/useExternalLink', () => ({
|
|
useExternalLink: vi.fn(() => ({
|
|
staticUrls: {
|
|
githubIssues: 'https://github.com/Comfy-Org/ComfyUI/issues'
|
|
}
|
|
}))
|
|
}))
|
|
|
|
describe('ErrorNodeCard.vue', () => {
|
|
let i18n: ReturnType<typeof createI18n>
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
cardIdCounter = 0
|
|
mockGetLogs.mockResolvedValue('mock server logs')
|
|
mockGenerateErrorReport.mockReturnValue('# ComfyUI Error Report\n...')
|
|
|
|
i18n = createI18n({
|
|
legacy: false,
|
|
locale: 'en',
|
|
messages: {
|
|
en: {
|
|
g: {
|
|
copy: 'Copy',
|
|
details: 'Details',
|
|
findIssues: 'Find Issues',
|
|
findOnGithub: 'Find on GitHub',
|
|
getHelpAction: 'Get Help'
|
|
},
|
|
rightSidePanel: {
|
|
locateNode: 'Locate Node',
|
|
errorLog: 'Error log',
|
|
findOnGithubTooltip: 'Search GitHub issues for related problems',
|
|
getHelpTooltip:
|
|
'Report this error and we\u0027ll help you resolve it'
|
|
},
|
|
issueReport: {
|
|
helpFix: 'Help Fix This'
|
|
}
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
function renderCard(
|
|
card: ErrorCardData,
|
|
options: { initialState?: Record<string, unknown> } = {}
|
|
) {
|
|
const user = userEvent.setup()
|
|
const onCopyToClipboard = vi.fn()
|
|
const onLocateNode = vi.fn()
|
|
render(ErrorNodeCard, {
|
|
props: { card, onCopyToClipboard, onLocateNode },
|
|
global: {
|
|
plugins: [
|
|
PrimeVue,
|
|
i18n,
|
|
createTestingPinia({
|
|
createSpy: vi.fn,
|
|
initialState: options.initialState ?? {
|
|
systemStats: {
|
|
systemStats: {
|
|
system: {
|
|
os: 'Linux',
|
|
python_version: '3.11.0',
|
|
embedded_python: false,
|
|
comfyui_version: '1.0.0',
|
|
pytorch_version: '2.1.0',
|
|
argv: ['--listen']
|
|
},
|
|
devices: [
|
|
{
|
|
name: 'NVIDIA RTX 4090',
|
|
type: 'cuda',
|
|
vram_total: 24000,
|
|
vram_free: 12000,
|
|
torch_vram_total: 24000,
|
|
torch_vram_free: 12000
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
})
|
|
],
|
|
stubs: {
|
|
TransitionCollapse: { template: '<div><slot /></div>' },
|
|
Button: {
|
|
template: '<button v-bind="$attrs"><slot /></button>'
|
|
}
|
|
}
|
|
}
|
|
})
|
|
return { user, onCopyToClipboard, onLocateNode }
|
|
}
|
|
|
|
async function toggleRuntimeDetails(
|
|
user: ReturnType<typeof userEvent.setup>
|
|
) {
|
|
await user.click(screen.getByRole('button', { name: /Details/ }))
|
|
}
|
|
|
|
let cardIdCounter = 0
|
|
|
|
function makeRuntimeErrorCard(): ErrorCardData {
|
|
return {
|
|
id: `exec-${++cardIdCounter}`,
|
|
title: 'KSampler',
|
|
nodeId: createNodeExecutionId([toNodeId(10)]),
|
|
nodeTitle: 'KSampler',
|
|
errors: [
|
|
{
|
|
message: 'RuntimeError: CUDA out of memory',
|
|
details: 'Traceback line 1\nTraceback line 2',
|
|
isRuntimeError: true,
|
|
exceptionType: 'RuntimeError'
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
function makePromptErrorCard(): ErrorCardData {
|
|
return {
|
|
id: '__prompt__',
|
|
title: 'Prompt has no outputs',
|
|
errors: [
|
|
{
|
|
message: 'Server Error: No outputs',
|
|
details: 'Error details',
|
|
displayMessage:
|
|
'The workflow does not contain any output nodes to produce a result.'
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
const { user } = renderCard(makeRuntimeErrorCard())
|
|
|
|
await waitFor(() => {
|
|
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(makePromptErrorCard())
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Error details')).toBeInTheDocument()
|
|
})
|
|
|
|
expect(mockGetLogs).not.toHaveBeenCalled()
|
|
expect(mockGenerateErrorReport).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('displays original details for non-runtime errors', async () => {
|
|
renderCard(makePromptErrorCard())
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Error details')).toBeInTheDocument()
|
|
})
|
|
expect(screen.queryByText(/ComfyUI Error Report/)).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('hides grouped catalog copy and shows the item label as a list item', async () => {
|
|
renderCard({
|
|
id: `node-${++cardIdCounter}`,
|
|
title: 'KSampler',
|
|
nodeId: createNodeExecutionId([toNodeId(10)]),
|
|
nodeTitle: 'KSampler',
|
|
errors: [
|
|
{
|
|
message: 'Required input is missing',
|
|
details: 'model',
|
|
displayTitle: 'Missing connection',
|
|
displayMessage:
|
|
'Required input slots have no connection feeding them.',
|
|
displayDetails: 'KSampler is missing a required input: model',
|
|
displayItemLabel: 'KSampler - model'
|
|
}
|
|
]
|
|
})
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('KSampler - model')).toBeInTheDocument()
|
|
})
|
|
expect(screen.getByRole('listitem')).toHaveTextContent('KSampler - model')
|
|
expect(screen.queryByText('Missing connection')).not.toBeInTheDocument()
|
|
expect(
|
|
screen.queryByText(
|
|
'Required input slots have no connection feeding them.'
|
|
)
|
|
).not.toBeInTheDocument()
|
|
expect(
|
|
screen.queryByText('KSampler is missing a required input: model')
|
|
).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('copies enriched report when copy button is clicked for runtime error', async () => {
|
|
const reportText = '# Full Report Content'
|
|
mockGenerateErrorReport.mockReturnValue(reportText)
|
|
|
|
const { user, onCopyToClipboard } = renderCard(makeRuntimeErrorCard())
|
|
|
|
await waitFor(() => {
|
|
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
|
|
})
|
|
expect(screen.getByText(/Full Report Content/)).toBeInTheDocument()
|
|
|
|
await user.click(screen.getByRole('button', { name: /Copy/ }))
|
|
|
|
expect(onCopyToClipboard).toHaveBeenCalledTimes(1)
|
|
expect(onCopyToClipboard.mock.calls[0][0]).toContain(
|
|
'# Full Report Content'
|
|
)
|
|
})
|
|
|
|
it('generates report with fallback logs when getLogs fails', async () => {
|
|
mockGetLogs.mockRejectedValue(new Error('Network error'))
|
|
|
|
renderCard(makeRuntimeErrorCard())
|
|
|
|
await waitFor(() => {
|
|
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
|
|
})
|
|
expect(mockGenerateErrorReport).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
serverLogs: 'Failed to retrieve server logs'
|
|
})
|
|
)
|
|
expect(screen.getByText(/ComfyUI Error Report/)).toBeInTheDocument()
|
|
})
|
|
|
|
it('falls back to original details when generateErrorReport throws', async () => {
|
|
mockGenerateErrorReport.mockImplementation(() => {
|
|
throw new Error('Serialization error')
|
|
})
|
|
|
|
renderCard(makeRuntimeErrorCard())
|
|
|
|
await waitFor(() => {
|
|
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
|
|
})
|
|
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
|
|
})
|
|
|
|
it('opens GitHub issues search when Find Issue button is clicked', async () => {
|
|
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
|
|
|
const { user } = renderCard(makeRuntimeErrorCard())
|
|
|
|
await waitFor(() => {
|
|
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
await user.click(screen.getByRole('button', { name: /Find on GitHub/ }))
|
|
|
|
expect(openSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('github.com/Comfy-Org/ComfyUI/issues?q='),
|
|
'_blank',
|
|
'noopener,noreferrer'
|
|
)
|
|
expect(openSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('CUDA%20out%20of%20memory'),
|
|
expect.any(String),
|
|
expect.any(String)
|
|
)
|
|
|
|
openSpy.mockRestore()
|
|
})
|
|
|
|
it('executes ContactSupport command when Get Help button is clicked', async () => {
|
|
const { user } = renderCard(makeRuntimeErrorCard())
|
|
|
|
await waitFor(() => {
|
|
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
await user.click(screen.getByRole('button', { name: /Get Help/ }))
|
|
|
|
expect(mockExecuteCommand).toHaveBeenCalledWith('Comfy.ContactSupport')
|
|
expect(mockTrackHelpResourceClicked).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
resource_type: 'help_feedback',
|
|
source: 'error_dialog'
|
|
})
|
|
)
|
|
})
|
|
|
|
it('passes exceptionType from error item to report generator', async () => {
|
|
renderCard(makeRuntimeErrorCard())
|
|
|
|
await waitFor(() => {
|
|
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
|
|
})
|
|
expect(mockGenerateErrorReport).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
exceptionType: 'RuntimeError'
|
|
})
|
|
)
|
|
})
|
|
|
|
it('uses fallback exception type when error item has no exceptionType', async () => {
|
|
const card: ErrorCardData = {
|
|
id: `exec-${++cardIdCounter}`,
|
|
title: 'KSampler',
|
|
nodeId: createNodeExecutionId([toNodeId(10)]),
|
|
nodeTitle: 'KSampler',
|
|
errors: [
|
|
{
|
|
message: 'Unknown error occurred',
|
|
details: 'Some traceback',
|
|
isRuntimeError: true
|
|
}
|
|
]
|
|
}
|
|
|
|
renderCard(card)
|
|
|
|
await waitFor(() => {
|
|
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
|
|
})
|
|
expect(mockGenerateErrorReport).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
exceptionType: 'Runtime Error'
|
|
})
|
|
)
|
|
})
|
|
|
|
it('falls back to original details when systemStats is unavailable', async () => {
|
|
renderCard(makeRuntimeErrorCard(), {
|
|
initialState: {
|
|
systemStats: { systemStats: null }
|
|
}
|
|
})
|
|
|
|
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
|
|
|
|
expect(mockGenerateErrorReport).not.toHaveBeenCalled()
|
|
})
|
|
})
|