Files
ComfyUI_frontend/browser_tests/tests/vueNodes/nodeStates/error.spec.ts
jaeone94 0307281ff2 fix: highlight missing input slots on Vue nodes (#11950)
## Summary

Restores required-input validation highlighting on Vue node input slots.

## Changes

- **What**: Passes validation error state from `NodeSlots` to
`InputSlot` using node locator IDs, including subgraph and nested
subgraph execution IDs.
- **What**: Adds unit coverage for root, one-level subgraph, and nested
subgraph slot error mapping.
- **What**: Adds a Vue Nodes screenshot regression test that asserts the
missing required input slot itself receives the error highlight.
- **Dependencies**: None.

## Review Focus

- Required input errors on Vue-rendered node's slots.
- The new Playwright screenshot expectation will need the `New Browser
Test Expectation` label for Linux baseline generation.

## Screenshots (if applicable)
Before

<img width="499" height="324" alt="스크린샷 2026-05-05 오후 3 00 44"
src="https://github.com/user-attachments/assets/285fdf91-6d7e-480b-99b9-715705f78914"
/>

After 

<img width="482" height="356" alt="스크린샷 2026-05-05 오후 3 01 11"
src="https://github.com/user-attachments/assets/51b8db49-eb9c-4155-8aa5-109c0bd7699b"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11950-fix-highlight-missing-input-slots-on-Vue-nodes-3576d73d365081bd85bfd1ea149d45c5)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-05-05 14:10:35 +00:00

284 lines
9.6 KiB
TypeScript

import { mergeTests } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture
} from '@e2e/fixtures/ComfyPage'
import {
cleanupFakeModel,
dismissErrorOverlay,
enableErrorsOverlay
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
import {
ExecutionHelper,
buildKSamplerError
} from '@e2e/fixtures/helpers/ExecutionHelper'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
import { webSocketFixture } from '@e2e/fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
const ERROR_CLASS = /ring-destructive-background/
const UNKNOWN_NODE_ID = '1'
const INNER_EXECUTION_ID = '2:1'
const KSAMPLER_MODEL_INPUT_NAME = 'model'
test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
test('should display error state when node is missing (node from workflow is not installed)', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
await expect(
comfyPage.vueNodes.getNodeInnerWrapper(UNKNOWN_NODE_ID)
).toHaveClass(ERROR_CLASS)
})
test('should display error state when node causes execution error', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
const raiseErrorId =
await comfyPage.vueNodes.getNodeIdByTitle('Raise Error')
await comfyPage.runButton.click()
await expect(
comfyPage.vueNodes.getNodeInnerWrapper(raiseErrorId)
).toHaveClass(ERROR_CLASS)
})
test.describe('validation errors', () => {
test.beforeEach(async ({ comfyPage }) => {
await enableErrorsOverlay(comfyPage)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
})
test('shows error ring when a validation error is returned for a node', async ({
comfyPage
}) => {
const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[ksamplerId]: buildKSamplerError(
'value_bigger_than_max',
'steps',
'steps: 99999 is bigger than max 10000'
)
})
await comfyPage.runButton.click()
await expect(
comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId)
).toHaveClass(ERROR_CLASS)
})
test(
'highlights the missing required input slot',
{ tag: ['@screenshot', '@node'] },
async ({ comfyPage }) => {
const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
const ksamplerNode = comfyPage.vueNodes.getNodeLocator(ksamplerId)
const modelInputIndex = await comfyPage.page.evaluate(
({ nodeId, inputName }) => {
const node = window.app!.graph.getNodeById(nodeId)
const index =
node?.inputs?.findIndex((input) => input.name === inputName) ?? -1
if (index < 0) {
throw new Error(`Input slot "${inputName}" not found`)
}
return index
},
{ nodeId: ksamplerId, inputName: KSAMPLER_MODEL_INPUT_NAME }
)
const modelInputSlotRow = comfyPage.vueNodes.getInputSlotRow(
ksamplerId,
modelInputIndex
)
const modelInputSlotHighlight =
comfyPage.vueNodes.getInputSlotConnectionDot(
ksamplerId,
modelInputIndex
)
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[ksamplerId]: buildKSamplerError(
'required_input_missing',
KSAMPLER_MODEL_INPUT_NAME,
`Required input is missing: ${KSAMPLER_MODEL_INPUT_NAME}`
)
})
await comfyPage.runButton.click()
await dismissErrorOverlay(comfyPage)
await fitToViewInstant(comfyPage)
await expect(modelInputSlotRow).toBeVisible()
await expect(modelInputSlotRow).toBeInViewport()
await expect(modelInputSlotHighlight).toHaveClass(/before:ring-error/)
await expect(
comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId)
).toHaveClass(ERROR_CLASS)
await comfyPage.expectScreenshot(
ksamplerNode,
'vue-node-required-input-missing-slot-error.png'
)
}
)
test('clears error ring when user edits an out-of-range number widget back into range', async ({
comfyPage
}) => {
const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
const innerWrapper = comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId)
const exec = new ExecutionHelper(comfyPage)
await test.step('queue with out-of-range steps to surface the error', async () => {
await exec.mockValidationFailure({
[ksamplerId]: buildKSamplerError(
'value_bigger_than_max',
'steps',
'steps: 99999 is bigger than max 10000'
)
})
await comfyPage.runButton.click()
await dismissErrorOverlay(comfyPage)
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
})
await test.step('edit steps widget so the new value is within range', async () => {
const stepsWidget = comfyPage.vueNodes.getWidgetByName(
'KSampler',
'steps'
)
const controls = comfyPage.vueNodes.getInputNumberControls(stepsWidget)
// ScrubableNumberInput commits on blur — explicit blur avoids a race
// with the keyup-Enter handler in case Enter is consumed elsewhere.
await controls.input.fill('25')
await controls.input.blur()
})
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
})
test('clears error ring when user picks a different combo option', async ({
comfyPage
}) => {
const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
const innerWrapper = comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId)
const exec = new ExecutionHelper(comfyPage)
await test.step('queue with invalid sampler to surface the error', async () => {
await exec.mockValidationFailure({
[ksamplerId]: buildKSamplerError(
'value_not_in_list',
'sampler_name',
'sampler_name: bogus_sampler is not in list'
)
})
await comfyPage.runButton.click()
await dismissErrorOverlay(comfyPage)
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
})
await test.step('select a different sampler option', async () => {
await comfyPage.vueNodes.selectComboOption(
'KSampler',
'sampler_name',
'dpmpp_2m'
)
})
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
})
})
test.describe('subgraph propagation', { tag: '@subgraph' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await enableErrorsOverlay(comfyPage)
await cleanupFakeModel(comfyPage)
})
test('parent subgraph node shows error ring when an interior node is missing', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes_in_subgraph')
const subgraphParentId = await comfyPage.vueNodes.getNodeIdByTitle(
'Subgraph with Missing Node'
)
await expect(
comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId)
).toHaveClass(ERROR_CLASS)
})
test('parent subgraph node shows error ring when an interior node has a missing model', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'missing/missing_models_in_subgraph'
)
const subgraphParentId = await comfyPage.vueNodes.getNodeIdByTitle(
'Subgraph with Missing Model'
)
await expect(
comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId)
).toHaveClass(ERROR_CLASS)
})
test('parent subgraph node shows error ring when an interior node fails execution', async ({
comfyPage,
getWebSocket
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphParentId =
await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
const innerWrapper =
comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId)
await expect(
innerWrapper,
'subgraph parent must mount before injecting WS execution_error'
).toBeVisible()
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
const ws = await getWebSocket()
const exec = new ExecutionHelper(comfyPage, ws)
exec.executionError(
'mocked-prompt',
INNER_EXECUTION_ID,
'boom inside the subgraph'
)
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
})
test('parent subgraph node shows error ring when interior node has a validation error', async ({
comfyPage
}) => {
// Validation errors are keyed by execution id, so an interior error
// ("2:1") must propagate the ring up to the root-level subgraph
// container ("2") via errorAncestorExecutionIds.
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphParentId =
await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
const innerWrapper =
comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId)
await expect(innerWrapper).toBeVisible()
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[INNER_EXECUTION_ID]: buildKSamplerError(
'value_bigger_than_max',
'steps',
'steps: 99999 is bigger than max 10000'
)
})
await comfyPage.runButton.click()
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
})
})
})