mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 14:45:36 +00:00
## 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>
284 lines
9.6 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|