Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions src/components/rightSidePanel/RightSidePanel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'

import RightSidePanel from '@/components/rightSidePanel/RightSidePanel.vue'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { toNodeId } from '@/types/nodeId'

const mockApp = vi.hoisted(() => ({
isGraphReady: true,
rootGraph: null as LGraph | null
}))

vi.mock('@/scripts/app', () => ({ app: mockApp }))

vi.mock('@/composables/graph/useGraphHierarchy', () => ({
useGraphHierarchy: () => ({ findParentGroup: vi.fn(() => null) })
}))

vi.mock('@/platform/telemetry', () => ({ useTelemetry: () => undefined }))

vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) => {
if (key === 'Comfy.RightSidePanel.ShowErrorsTab') return true
if (key === 'Comfy.Sidebar.Location') return 'left'
if (key === 'Comfy.UseNewMenu') return 'Top'
if (key === 'Comfy.RightSidePanel.IsOpen') return true
return undefined
},
set: vi.fn()
})
}))

function renderPanel(activeTab: 'errors' | 'parameters' = 'errors') {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
setActivePinia(pinia)

const graph = new LGraph()
const node = new LGraphNode('CheckpointLoaderSimple')
node.id = toNodeId(1)
graph.add(node)
mockApp.rootGraph = graph

const canvasStore = useCanvasStore()
canvasStore.currentGraph = graph
canvasStore.selectedItems = [node]

const rightSidePanelStore = useRightSidePanelStore()
rightSidePanelStore.activeTab = activeTab
const executionErrorStore = useExecutionErrorStore()
const executionId = createNodeExecutionId([node.id])
const finishScan = executionErrorStore.beginAddedNodeErrorScan(
graph,
executionId
)
const openPanel = vi.spyOn(rightSidePanelStore, 'openPanel')

const i18n = createI18n({

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Other component tests import the real messages (import enMessages from '@/locales/en/main.json' with { type: 'json' }) rather than a hand-picked subset — the subset rots silently into vue-i18n fallback warnings when a key is renamed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for calling this out. The test now imports the real English messages rather than maintaining a brittle subset.

legacy: false,
locale: 'en',
messages: {
en: {
g: { settings: 'Settings' },
rightSidePanel: {
errors: 'Errors',
parameters: 'Parameters',
info: 'Info',
togglePanel: 'Toggle panel',
fallbackNodeTitle: 'Node'
}
}
}
})

const rendered = render(RightSidePanel, {
global: {
plugins: [pinia, i18n],
stubs: {
Button: { template: '<button><slot /></button>' },
EditableText: true,
Tab: { template: '<button v-bind="$attrs"><slot /></button>' },
TabErrors: true,
TabInfo: true,
TabList: { template: '<div><slot /></div>' },
TabNormalInputs: true,
TabSettings: true
}
}
})

return {
...rendered,
executionId,
executionErrorStore,
finishScan,
graph,
node,
openPanel,
rightSidePanelStore
}
}

describe('RightSidePanel active tab fallback', () => {
beforeEach(() => {
vi.restoreAllMocks()
mockApp.rootGraph = null
})

it('keeps the active errors tab until the selected node scan settles', async () => {
const { finishScan, openPanel, rightSidePanelStore } = renderPanel()

expect(screen.getByTestId('panel-tab-errors')).toBeInTheDocument()
expect(rightSidePanelStore.activeTab).toBe('errors')
expect(openPanel).not.toHaveBeenCalled()

finishScan()
await Promise.resolve()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Tests 1 and 4 use await Promise.resolve() while test 2 uses await nextTick(). The Promise.resolve() variant only works because Vue's scheduler queued its flush first; docs/guidance/vitest.md says to wait for reactivity with nextTick() — and for the not.toHaveBeenCalled() assertion an under-flushed await would pass vacuously.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the precise scheduler analysis. The component tests now use nextTick(). The scan-settlement test additionally blocks queueMicrotask deliberately so it remains mutation-sensitive and fails against the old deferred fallback implementation.


expect(rightSidePanelStore.activeTab).toBe('parameters')
expect(openPanel).toHaveBeenCalledWith('parameters')
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})

it('keeps errors active when the scan surfaces an error before settling', async () => {
const { executionId, finishScan, openPanel, rightSidePanelStore } =
renderPanel()

useMissingModelStore().addMissingModels([
{
nodeId: executionId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'missing.safetensors',
directory: 'checkpoints',
isMissing: true
}
])
finishScan()
await nextTick()

expect(rightSidePanelStore.activeTab).toBe('errors')
expect(openPanel).not.toHaveBeenCalled()
})

it('does not show errors solely because a scan is pending', () => {
const { openPanel } = renderPanel('parameters')

expect(screen.queryByTestId('panel-tab-errors')).not.toBeInTheDocument()
expect(openPanel).not.toHaveBeenCalled()
})

it('does not update the panel after unmount', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: This test can't fail — finishScan() fires and flushes before unmount(), and after mockClear() nothing in the system can still call openPanel, for any implementation. Reordering (unmount first, then finishScan()) makes it meaningful, though at that point it mostly asserts Vue's own effect-scope teardown — deleting it may be the honest fix.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you—this was exactly right, and it prevented a false-confidence test from surviving. The test now unmounts first, finishes the scan afterward, and waits for Vue reactivity before asserting.

const { finishScan, openPanel, unmount } = renderPanel()
finishScan()
await Promise.resolve()
openPanel.mockClear()
unmount()
await Promise.resolve()

expect(openPanel).not.toHaveBeenCalled()
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
35 changes: 28 additions & 7 deletions src/components/rightSidePanel/RightSidePanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useGraphHierarchy } from '@/composables/graph/useGraphHierarchy'
import { app } from '@/scripts/app'
import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
import {
getActiveGraphNodeIds,
getExecutionIdByNode
} from '@/utils/graphTraversalUtil'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
Expand Down Expand Up @@ -173,12 +176,27 @@ const hasRelevantErrors = computed(() => {
)
})

const hasPendingErrorScanSelected = computed(() => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: If this computed's first evaluation ever happens while app.rootGraph is unset, the early return tracks zero reactive dependencies, so it caches false for the component's lifetime and the tab-hold silently degrades. It's hard to reach today (activeTab starts as 'parameters' and the tabs short-circuit means the first read happens post-init), but cheap to harden: read selectedNodes.value before the guard, and use app.isGraphReady per the file's existing convention (line 57) — rootGraph is typed non-nullable and its getter logs console.error on pre-init access.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Many thanks for spotting this quiet reactivity trap. The computed now subscribes to selectedNodes before the readiness guard and follows the existing app.isGraphReady convention.

if (!app.rootGraph) return false
return selectedNodes.value.some((node) => {
const executionId = getExecutionIdByNode(app.rootGraph, node)
return (
executionId !== null &&
executionErrorStore.hasPendingAddedNodeErrorScan(
app.rootGraph,
executionId
)
)
})
})

const tabs = computed<RightSidePanelTabList>(() => {
const list: RightSidePanelTabList = []

if (
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab') &&
hasRelevantErrors.value
(hasRelevantErrors.value ||
(activeTab.value === 'errors' && hasPendingErrorScanSelected.value))
) {
list.push({
label: () => t('rightSidePanel.errors'),
Expand Down Expand Up @@ -222,12 +240,15 @@ const tabs = computed<RightSidePanelTabList>(() => {
return list
})

// Use global state for activeTab and ensure it's valid
function isActiveTabAvailable() {
return (
tabs.value.some((tab) => tab.value === activeTab.value) ||
(activeTab.value === 'subgraph' && isSingleSubgraphNode.value)
)
}

watchEffect(() => {
if (
!tabs.value.some((tab) => tab.value === activeTab.value) &&
!(activeTab.value === 'subgraph' && isSingleSubgraphNode.value)
) {
if (!isActiveTabAvailable()) {
rightSidePanelStore.openPanel(tabs.value[0].value)
}
})
Expand Down
131 changes: 131 additions & 0 deletions src/composables/graph/useErrorClearingHooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,137 @@ describe('installErrorClearingHooks lifecycle', () => {
expect(useMissingMediaStore().missingMediaCandidates).toBeNull()
expect(mediaScan).toHaveBeenCalledOnce()
})

it('keeps an added-node scan pending until async verification settles', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (coverage): The new abort tests only exercise the model path — scanNodeMediaCandidates is mocked to [], so removing signal?.aborted || from verifyAndAddPendingMedia fails nothing. The existing deferMediaVerification fixture makes this cheap to add. Also worth one subgraph-interior-node case: both sides of the pending-scan seam compute the ID via getExecutionIdByNode, but tests only ever check root-level IDs where the two trivially coincide.

Nit: these two async tests duplicate ~25 lines of candidate/spy/deferred-resolve scaffolding — the file already has the deferMediaVerification fixture pattern to mirror.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extremely grateful for this coverage review. 4873e0d482 adds direct media cancellation/late-result coverage, the analogous model case, and a subgraph-interior execution-ID component case. The existing media deferral fixture is reused.

const graph = new LGraph()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const candidate = fromAny<MissingModelCandidate, unknown>({
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'pending.safetensors',
isMissing: undefined
})
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
candidate
])
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
let resolveVerification: () => void = () => undefined
vi.spyOn(
missingModelScan,
'verifyAssetSupportedCandidates'
).mockImplementation(async () => {
await new Promise<void>((resolve) => {
resolveVerification = resolve
})
candidate.isMissing = true
})
Comment on lines +533 to +553

@coderabbitai coderabbitai Bot Aug 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract a model-verification deferral fixture.

Five new tests repeat the same candidate literal, scanNodeModelCandidates mock, scanNodeMediaCandidates mock, and deferred verifyAssetSupportedCandidates implementation. The repository already provides deferMediaVerification in src/platform/missingMedia/__fixtures__/promotedMedia.ts. Add the model-side equivalent and reuse it, so future changes to the verification signature touch one place.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/composables/graph/useErrorClearingHooks.test.ts` around lines 533 - 553,
Extract the repeated model verification setup into a shared model-side deferral
fixture, parallel to deferMediaVerification in promotedMedia.ts. Update the
affected tests to use this fixture for the MissingModelCandidate,
scanNodeModelCandidates, scanNodeMediaCandidates, and deferred
verifyAssetSupportedCandidates behavior, centralizing the verification signature
and preserving each test’s ability to resolve verification and mark the
candidate missing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open a tracking issue for this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo="Comfy-Org/ComfyUI_frontend"
body=$(cat <<'EOF'
## Summary

Extract a shared deferred model-verification fixture for
`useErrorClearingHooks` tests.

## Required changes

Add a model-side fixture that is parallel to
`deferMediaVerification` in
`src/platform/missingMedia/__fixtures__/promotedMedia.ts`.

Update the affected tests in
`src/composables/graph/useErrorClearingHooks.test.ts` to use the
fixture for:

- `MissingModelCandidate` setup
- `scanNodeModelCandidates` mocking
- `scanNodeMediaCandidates` mocking
- deferred `verifyAssetSupportedCandidates` behavior

The fixture must preserve each test's ability to resolve verification
and set the candidate `isMissing` state when required.

## Rationale

Several tests duplicate the same deferred model-verification setup.
A shared fixture will centralize the verification signature and reduce
maintenance work when that signature changes.

## Affected areas

- `src/composables/graph/useErrorClearingHooks.test.ts`
- Model verification test fixtures near missing-model test support

## Acceptance criteria

- A reusable deferred model-verification fixture exists.
- The repeated model-verification test setup uses the fixture.
- The updated tests retain their current behavioral coverage.
- Changes are limited to test support and affected tests.

## Backlinks

- Pull request: https://github.com/Comfy-Org/ComfyUI_frontend/pull/15012
- Review comment: https://github.com/Comfy-Org/ComfyUI_frontend/pull/15012#discussion_r3771038268
- Requested by: `@DrJKL`
EOF
)

gh issue create \
  --repo "$repo" \
  --title "Extract a deferred model-verification fixture for error-clearing tests" \
  --body "$body" \
  --assignee "DrJKL"

Length of output: 4305


@DrJKL, I created and assigned tracking issue #15203:
#15203


🧬 Code Graph Analysis Results

src/composables/graph/useErrorClearingHooks.ts:550-636

installErrorClearingHooks(graph) installs node-added, node-removed, and trigger callbacks; tracks and cancels pending scans, derives removed-node execution IDs, removes related errors, restores callbacks during cleanup, and prevents disposed hooks from scheduling scans.

src/lib/litegraph/src/LGraph.ts:1051-1157

LGraph.remove(node) invokes node.onRemoved, sets node.graph = null, then invokes graph.onNodeRemoved(node). This ordering is relevant to removal-hook execution-ID derivation.

src/platform/missingModel/missingModelStore.ts:21-180

Defines missing-model candidate state and methods for setting candidates, removing candidates by node ID or execution-ID prefix, and clearing associated interaction state.

src/platform/missingMedia/missingMediaStore.ts:18-175

Defines missing-media candidate state and methods for setting, adding, and removing candidates by widget, node ID, or execution-ID prefix; verification cancellation is handled by clearMissingMedia().

src/platform/nodeReplacement/missingNodesErrorStore.ts:19-154

Defines missing-node error state and methods for surfacing and removing missing-node entries by node ID or execution-ID prefix; string-based group entries are preserved during prefix removal.

src/types/nodeIdentification.ts:139-148

createNodeExecutionId(nodeIds) validates and joins serialized node-ID segments with :; returns null for an empty or invalid path.

src/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers.ts:268-288

createTestSubgraphNode(subgraph, options) creates a subgraph container with an optional parent graph and explicit node ID, used by tests involving nested execution-ID paths.

src/platform/missingMedia/__fixtures__/promotedMedia.ts:78-92

deferMediaVerification() returns a verification spy and resolver. Verification remains pending until the resolver is called, then marks all supplied media candidates as missing.

src/platform/missingMedia/__fixtures__/promotedMedia.ts:153-245

createPromotedMediaRuntime(options) builds root, nested subgraph, promoted host, and source-node relationships with configurable depth, IDs, fanout, widget values, and options. It returns graphs, hosts, source nodes, and intermediate hosts for promotion lifecycle tests.

You are interacting with an AI system.

installErrorClearingHooks(graph)

const node = new LGraphNode('CheckpointLoaderSimple')
node.id = toNodeId(1)
graph.add(node)

const store = useExecutionErrorStore()
const executionId = createNodeExecutionId([node.id])
expect(store.hasPendingAddedNodeErrorScan(graph, executionId)).toBe(true)

await vi.waitFor(() =>
expect(
missingModelScan.verifyAssetSupportedCandidates
).toHaveBeenCalledOnce()
)
expect(store.hasPendingAddedNodeErrorScan(graph, executionId)).toBe(true)

resolveVerification()
await vi.waitFor(() =>
expect(store.hasPendingAddedNodeErrorScan(graph, executionId)).toBe(false)
)
expect(useMissingModelStore().missingModelCandidates).toEqual([candidate])
})

it('releases a pending added-node scan when hooks are disposed', () => {
const graph = new LGraph()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const cleanup = installErrorClearingHooks(graph)
const node = new LGraphNode('test')
graph.add(node)

const store = useExecutionErrorStore()
const executionId = createNodeExecutionId([node.id])
expect(store.hasPendingAddedNodeErrorScan(graph, executionId)).toBe(true)

cleanup()

expect(store.hasPendingAddedNodeErrorScan(graph, executionId)).toBe(false)
})

it('does not surface async verification after hooks are disposed', async () => {
const graph = new LGraph()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const candidate = fromAny<MissingModelCandidate, unknown>({
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'pending.safetensors',
isMissing: undefined
})
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([
candidate
])
vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates').mockReturnValue([])
let resolveVerification: () => void = () => undefined
vi.spyOn(
missingModelScan,
'verifyAssetSupportedCandidates'
).mockImplementation(async () => {
await new Promise<void>((resolve) => {
resolveVerification = resolve
})
candidate.isMissing = true
})
const cleanup = installErrorClearingHooks(graph)
const node = new LGraphNode('CheckpointLoaderSimple')
node.id = toNodeId(1)
graph.add(node)

await vi.waitFor(() =>
expect(
missingModelScan.verifyAssetSupportedCandidates
).toHaveBeenCalledOnce()
)
cleanup()
resolveVerification()
await vi.waitFor(() => expect(candidate.isMissing).toBe(true))

expect(
useExecutionErrorStore().hasPendingAddedNodeErrorScan(
graph,
createNodeExecutionId([node.id])
)
).toBe(false)
expect(useMissingModelStore().missingModelCandidates).toBeNull()
})

it('does not schedule scans through a retained disposed callback', () => {
const graph = new LGraph()
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const cleanup = installErrorClearingHooks(graph)
const disposedOnNodeAdded = graph.onNodeAdded
cleanup()

const node = new LGraphNode('test')
node.id = toNodeId(1)
graph.add(node, true)
disposedOnNodeAdded?.(node)

expect(
useExecutionErrorStore().hasPendingAddedNodeErrorScan(
graph,
createNodeExecutionId([node.id])
)
).toBe(false)
})
})

describe('onNodeRemoved clears missing asset errors by execution ID', () => {
Expand Down
Loading
Loading