mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 22:25:05 +00:00
## Summary Clear missing media validation errors after paste/drop media uploads by emitting the existing widget-change event path. ## Changes - **What**: Emit `node.onWidgetChanged` after image/video upload completion updates the file combo widget. - **What**: Emit the same widget-change path after Load Audio upload completion. - **What**: Add unit coverage for upload completion emitting `onWidgetChanged` and for missing media clearing through that existing hook path. - **What**: Add E2E coverage for Load Image drag/drop and paste clearing validation rings, with red/green verified from a fresh `main` base. - **Dependencies**: None. ## Review Focus Please check that paste/drop upload paths now reuse the existing widget-change error-clearing path instead of expanding `widget.callback` patching. Also check the Load Image E2E helper path for synthetic paste/drop behavior. Supersedes #12207. Ref: FE-687 ## Screenshots Before https://github.com/user-attachments/assets/2cee52bc-b1c8-4dff-8a02-5b18a69ae639 After https://github.com/user-attachments/assets/e1ecd147-1d8a-470e-b77d-13345d473ef3 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-12212-fix-clear-media-upload-errors-via-widget-change-35f6d73d365081bcb1a0dfc042d417eb) by [Unito](https://www.unito.io)
410 lines
14 KiB
TypeScript
410 lines
14 KiB
TypeScript
import { mergeTests } from '@playwright/test'
|
|
|
|
import {
|
|
comfyExpect as expect,
|
|
comfyPageFixture
|
|
} from '@e2e/fixtures/ComfyPage'
|
|
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
|
import {
|
|
cleanupFakeModel,
|
|
dismissErrorOverlay,
|
|
enableErrorsOverlay
|
|
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
|
import {
|
|
ExecutionHelper,
|
|
buildKSamplerError
|
|
} from '@e2e/fixtures/helpers/ExecutionHelper'
|
|
import type { NodeError } from '@/schemas/apiSchema'
|
|
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
|
import { assetPath } from '@e2e/fixtures/utils/paths'
|
|
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'
|
|
const LOAD_IMAGE_INPUT_NAME = 'image'
|
|
const LOAD_IMAGE_UPLOAD_FILE = 'test_upload_image.png'
|
|
|
|
function buildLoadImageRequiredInputError(): NodeError {
|
|
return {
|
|
class_type: 'LoadImage',
|
|
dependent_outputs: [],
|
|
errors: [
|
|
{
|
|
type: 'required_input_missing',
|
|
message: `Required input is missing: ${LOAD_IMAGE_INPUT_NAME}`,
|
|
details: '',
|
|
extra_info: { input_name: LOAD_IMAGE_INPUT_NAME }
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
async function surfaceLoadImageMissingInputError(
|
|
comfyPage: ComfyPage,
|
|
loadImageId: string
|
|
): Promise<void> {
|
|
const exec = new ExecutionHelper(comfyPage)
|
|
await exec.mockValidationFailure({
|
|
[loadImageId]: buildLoadImageRequiredInputError()
|
|
})
|
|
await comfyPage.runButton.click()
|
|
await dismissErrorOverlay(comfyPage)
|
|
}
|
|
|
|
async function selectLoadImageNodeForPaste(
|
|
comfyPage: ComfyPage,
|
|
loadImageId: string
|
|
): Promise<void> {
|
|
await comfyPage.page.evaluate((nodeId) => {
|
|
const node = window.app!.graph.getNodeById(Number(nodeId))
|
|
if (!node) throw new Error(`Load Image node ${nodeId} not found`)
|
|
window.app!.canvas.selectNode(node)
|
|
window.app!.canvas.current_node = node
|
|
}, loadImageId)
|
|
}
|
|
|
|
async function setupLoadImageErrorScenario(comfyPage: ComfyPage) {
|
|
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
|
const loadImageNode = (
|
|
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
|
)[0]
|
|
const loadImageId = String(loadImageNode.id)
|
|
|
|
return {
|
|
loadImageId,
|
|
innerWrapper: comfyPage.vueNodes.getNodeInnerWrapper(loadImageId),
|
|
imageWidget: await loadImageNode.getWidgetByName(LOAD_IMAGE_INPUT_NAME)
|
|
}
|
|
}
|
|
|
|
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('clears error ring when user drops an image file onto Load Image', async ({
|
|
comfyPage
|
|
}) => {
|
|
const { loadImageId, innerWrapper, imageWidget } =
|
|
await setupLoadImageErrorScenario(comfyPage)
|
|
|
|
await test.step('queue with missing image input to surface the error', async () => {
|
|
await surfaceLoadImageMissingInputError(comfyPage, loadImageId)
|
|
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
|
})
|
|
|
|
await test.step('drop an image onto the Load Image node', async () => {
|
|
const dropPosition =
|
|
await comfyPage.canvasOps.getNodeCenterByTitle('Load Image')
|
|
if (!dropPosition) {
|
|
throw new Error('Load Image node center must be available for drop')
|
|
}
|
|
|
|
await comfyPage.dragDrop.dragAndDropFile(LOAD_IMAGE_UPLOAD_FILE, {
|
|
dropPosition,
|
|
waitForUpload: true
|
|
})
|
|
await expect
|
|
.poll(() => imageWidget.getValue())
|
|
.toContain(LOAD_IMAGE_UPLOAD_FILE)
|
|
})
|
|
|
|
await expect(innerWrapper).not.toHaveClass(ERROR_CLASS)
|
|
})
|
|
|
|
test('clears error ring when user pastes an image file onto Load Image', async ({
|
|
comfyPage
|
|
}) => {
|
|
const { loadImageId, innerWrapper, imageWidget } =
|
|
await setupLoadImageErrorScenario(comfyPage)
|
|
|
|
await test.step('queue with missing image input to surface the error', async () => {
|
|
await surfaceLoadImageMissingInputError(comfyPage, loadImageId)
|
|
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
|
|
})
|
|
|
|
await test.step('paste an image while Load Image is selected', async () => {
|
|
await comfyPage.canvas.focus()
|
|
await selectLoadImageNodeForPaste(comfyPage, loadImageId)
|
|
await expect
|
|
.poll(() =>
|
|
comfyPage.page.evaluate(() => window.app!.canvas.current_node?.type)
|
|
)
|
|
.toBe('LoadImage')
|
|
|
|
const uploadResponse = comfyPage.page.waitForResponse(
|
|
(resp) => resp.url().includes('/upload/') && resp.status() === 200,
|
|
{ timeout: 10_000 }
|
|
)
|
|
// File clipboard contents cannot be seeded reliably in Playwright;
|
|
// use the direct document paste mode to exercise usePaste.
|
|
await comfyPage.clipboard.pasteFile(assetPath(LOAD_IMAGE_UPLOAD_FILE), {
|
|
mode: 'direct'
|
|
})
|
|
await uploadResponse
|
|
await expect
|
|
.poll(() => imageWidget.getValue())
|
|
.toContain(LOAD_IMAGE_UPLOAD_FILE)
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|
|
})
|