Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ const CLOUD_UNKNOWN_MODEL_NAME = 'cloud_unknown_model.safetensors'
const CLOUD_IMPORTED_CANONICAL_MODEL_NAME =
'models/checkpoints/cloud_importable_model.safetensors'

const FAKE_MODEL_ASSET: Asset = {
id: 'test-fake-checkpoint',
name: FAKE_MODEL_NAME,
size: 1_024,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
created_at: '2026-05-05T00:00:00Z',
updated_at: '2026-05-05T00:00:00Z',
last_access_time: '2026-05-05T00:00:00Z',
user_metadata: { filename: FAKE_MODEL_NAME }
}

const LOTUS_DIFFUSION_MODEL: Asset & { hash?: string } = {
id: 'test-lotus-depth-d-v1-1',
name: LOTUS_MODEL_NAME,
Expand Down Expand Up @@ -183,6 +195,64 @@ test.describe(
await expect(errorsTab).toBeHidden()
})

test('keeps Errors active through pasted-node verification and falls back when resolved', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const panel = new PropertiesPanelHelper(comfyPage.page)
const missingModelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
let visibleAssets: Asset[] = []
let markVerificationStarted: () => void = () => undefined
const verificationStarted = new Promise<void>((resolve) => {
markVerificationStarted = resolve
})
let releaseVerification: () => void = () => undefined
const verificationGate = new Promise<void>((resolve) => {
releaseVerification = resolve
})
await comfyPage.page.route(/\/api\/assets(?:\?.*)?$/, async (route) => {
markVerificationStarted()
await verificationGate
const response: ListAssetsResponse = {
assets: visibleAssets,
total: visibleAssets.length,
has_more: false
}
await route.fulfill({ json: response })
})

const source = await comfyPage.nodeOps.getNodeRefById('1')
await source.click('title')
await comfyPage.clipboard.copy()
await comfyPage.clipboard.paste()
await verificationStarted

await expect(panel.errorsTab).toHaveAttribute('aria-selected', 'true')
Comment on lines +230 to +232

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Tie the pending-state assertion to the pasted node.

verificationStarted resolves on the first /api/assets request that the route intercepts. Any asset request can resolve it, not only the added-node verification. If an unrelated request arrives first, Line 232 can assert aria-selected before the pasted-node scan starts, so the test passes without exercising the pending-scan hold.

Wait for the pasted node to exist before asserting the tab state.

💚 Proposed fix
       await comfyPage.clipboard.paste()
+      await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
       await verificationStarted
 
       await expect(panel.errorsTab).toHaveAttribute('aria-selected', 'true')
       releaseVerification()
-      await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)

As per path instructions, browser_tests/README.md is the canonical guide for browser tests, including flake prevention.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await verificationStarted
await expect(panel.errorsTab).toHaveAttribute('aria-selected', 'true')
await comfyPage.clipboard.paste()
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
await verificationStarted
await expect(panel.errorsTab).toHaveAttribute('aria-selected', 'true')
releaseVerification()
🤖 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 `@browser_tests/tests/propertiesPanel/errorsTabCloudMissingModels.spec.ts`
around lines 230 - 232, Update the test around verificationStarted so it first
waits for the pasted node to exist, using the pasted-node locator or assertion
already defined in the test, and only then asserts errorsTab has
aria-selected="true". Keep the existing verificationStarted synchronization, but
ensure the pending-state assertion is tied to the added node’s verification
rather than any unrelated asset request.

Source: Path instructions

releaseVerification()
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
await expect(
missingModelsGroup.getByTestId(
TestIds.dialogs.missingModelReferenceCount
)
).toHaveText('2')
await expect(panel.errorsTab).toHaveAttribute('aria-selected', 'true')

visibleAssets = [FAKE_MODEL_ASSET]
await source.click('title')
await comfyPage.clipboard.copy()
await comfyPage.clipboard.paste()

await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(3)
await expect(missingModelsGroup).toBeHidden()
await expect(panel.errorsTab).toBeHidden()
await expect(panel.getTab('Parameters')).toHaveAttribute(
'aria-selected',
'true'
)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test('separates importable cloud models from unsupported rows', async ({
comfyPage
}) => {
Expand Down
196 changes: 196 additions & 0 deletions src/components/rightSidePanel/RightSidePanel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
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 { markRaw, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'

import RightSidePanel from '@/components/rightSidePanel/RightSidePanel.vue'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
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 { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
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',
graphContext?: {
rootGraph: LGraph
currentGraph: LGraph
node: LGraphNode
}
) {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
setActivePinia(pinia)

const rootGraph = graphContext?.rootGraph ?? new LGraph()
const currentGraph = graphContext?.currentGraph ?? rootGraph
const node = graphContext?.node ?? new LGraphNode('CheckpointLoaderSimple')
if (!graphContext) {
node.id = toNodeId(1)
rootGraph.add(node)
}
mockApp.rootGraph = rootGraph

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

const rightSidePanelStore = useRightSidePanelStore()
rightSidePanelStore.activeTab = activeTab
const executionErrorStore = useExecutionErrorStore()
const executionId = getExecutionIdByNode(rootGraph, node)
if (!executionId) throw new Error('Expected selected node execution ID')
const finishScan = executionErrorStore.beginAddedNodeErrorScan(
rootGraph,
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: enMessages }
})

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: rootGraph,
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()

vi.spyOn(globalThis, 'queueMicrotask').mockImplementation(() => undefined)
finishScan()
await nextTick()
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 when a pending scan finishes after unmount', async () => {
const { finishScan, openPanel, unmount } = renderPanel()
unmount()
finishScan()
await nextTick()

expect(openPanel).not.toHaveBeenCalled()
})

it('keeps errors active for a pending subgraph interior node scan', () => {
setActivePinia(createTestingPinia({ createSpy: vi.fn, stubActions: false }))
const subgraph = createTestSubgraph()
const node = new LGraphNode('CheckpointLoaderSimple')
node.id = toNodeId(7)
subgraph.add(node)
const host = createTestSubgraphNode(subgraph, { id: 65 })
const rootGraph = host.graph as LGraph
rootGraph.add(host)

const { executionErrorStore, executionId, openPanel, rightSidePanelStore } =
renderPanel('errors', { rootGraph, currentGraph: subgraph, node })

expect(
executionErrorStore.hasPendingAddedNodeErrorScan(rootGraph, executionId)
).toBe(true)
expect(screen.getByTestId('panel-tab-errors')).toBeInTheDocument()
expect(rightSidePanelStore.activeTab).toBe('errors')
expect(openPanel).not.toHaveBeenCalled()
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
34 changes: 27 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,26 @@ 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.

const nodes = selectedNodes.value
if (!app.isGraphReady) return false
const rootGraph = app.rootGraph
return nodes.some((node) => {
const executionId = getExecutionIdByNode(rootGraph, node)
return (
executionId !== null &&
executionErrorStore.hasPendingAddedNodeErrorScan(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 +239,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
Loading
Loading