Files
ComfyUI_frontend/browser_tests/tests/nodeReplacement.spec.ts
jaeone94 c7797b201e Simplify swap node error presentation (#12768)
## Summary

Simplifies the Swap Nodes error card as the fourth slice of the
catalog/error-tab presentation refactor, aligning it with the newer
compact error-row patterns while preserving the existing replace and
locate behavior.

This follows the staged rollout plan from the earlier error-tab PRs:

1. #12683 refined execution-style errors: validation, runtime, and
prompt errors.
2. #12705 simplified missing media errors into flat, locatable rows.
3. #12735 simplified missing node pack errors and aligned grouped-row
behavior.
4. This PR applies the same simplification pass to Swap Nodes errors.
5. A later PR is expected to handle Missing Models, which is larger and
intentionally kept separate.

After the Missing Models slice lands, a follow-up consistency PR will
normalize the shared row/disclosure pattern across Missing Node Packs,
Swap Nodes, and Missing Models together. That follow-up will cover
parameterized i18n labels for disclosure controls, shared text-button
styling, and consistent disclosure semantics/accessibility across those
grouped rows.

## Changes

- **What**: Reworks the Swap Nodes card rows so each replacement group
is presented as a compact row with the source node type, replacement
target, replace action, and locate action.
- **What**: For a single affected node, the visible row label can be
clicked to locate the node, matching the interaction model used by the
newer missing-media and missing-node rows.
- **What**: For multiple affected nodes with the same replacement
target, the group renders a count badge and a disclosure row. Expanding
the group shows the affected node rows, each with its own locate action.
- **What**: Removes the old node-id badge path from Swap Nodes rows.
Node-id badges remain available to the other error cards that still own
that behavior.
- **What**: Keeps replacement behavior unchanged: per-group replacement
and replace-all still call through the existing node replacement store
flow.
- **What**: Adds regression coverage for the new grouped-row UI,
including same-type grouping in both Vue Nodes and LiteGraph render
modes.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

Please focus on the Swap Nodes presentation and interaction symmetry
with the previous error-tab PRs:

- Single-node groups should remain directly locatable via the row label
and the locate icon.
- Multi-node groups should expose the count and expand/collapse behavior
without adding duplicate focusable disclosure controls.
- The visible row labels intentionally keep their own accessible names,
while the separate locate icon uses the generic `Locate node on canvas`
accessible name. This mirrors the established pattern from the previous
slices.
- The newly added Playwright fixture covers two same-type replaceable
nodes so duplicate group keys and grouped disclosure behavior are
exercised end-to-end.

## Validation

- `pnpm format`
- `pnpm test:unit
src/platform/nodeReplacement/components/SwapNodeGroupRow.test.ts`
- `pnpm test:browser:local browser_tests/tests/nodeReplacement.spec.ts
--project=chromium`
- Pre-commit hook: lint-staged, stylelint, oxfmt, oxlint, eslint, `pnpm
typecheck`, `pnpm typecheck:browser`
- Pre-push hook: `pnpm knip --cache`
- Additional parallel code review pass completed locally; no blocker or
major issues remained.

## Screenshots (if applicable)

This PR 

<img width="561" height="362" alt="스크린샷 2026-06-11 오전 3 46 06"
src="https://github.com/user-attachments/assets/65395467-6c2f-4aa1-84c5-3d9614c00c80"
/>

old (Main)
<img width="611" height="798" alt="스크린샷 2026-06-11 오전 3 46 32"
src="https://github.com/user-attachments/assets/3862d5df-f839-40c0-9488-ce64b051378e"
/>
2026-06-11 03:50:16 +00:00

252 lines
8.4 KiB
TypeScript

import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import {
mockNodeReplacements,
mockNodeReplacementsSingle
} from '@e2e/fixtures/data/nodeReplacements'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import {
getSwapNodesGroup,
setupNodeReplacement
} from '@e2e/fixtures/helpers/NodeReplacementHelper'
import { TestIds } from '@e2e/fixtures/selectors'
const renderModes = [
{ name: 'vue nodes', vueNodesEnabled: true },
{ name: 'litegraph', vueNodesEnabled: false }
] as const
test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
for (const mode of renderModes) {
test.describe(`(${mode.name})`, () => {
test.describe('Single replacement', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.VueNodes.Enabled',
mode.vueNodesEnabled
)
await setupNodeReplacement(comfyPage, mockNodeReplacementsSingle)
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/node_replacement_simple'
)
})
test('Swap Nodes group appears in errors tab for replaceable nodes', async ({
comfyPage
}) => {
const swapGroup = getSwapNodesGroup(comfyPage.page)
await expect(swapGroup).toBeVisible()
await expect(
swapGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
).toHaveText(/\S/)
await expect(swapGroup).toContainText('E2E_OldSampler')
await expect(
swapGroup.getByRole('button', { name: 'Replace All', exact: true })
).toBeVisible()
})
test('Shows direct row label and locate action for a single replacement group', async ({
comfyPage
}) => {
const swapGroup = getSwapNodesGroup(comfyPage.page)
const rowLabel = swapGroup.getByRole('button', {
name: 'E2E_OldSampler',
exact: true
})
await expect(rowLabel).toBeVisible()
await expect(
swapGroup.getByRole('button', {
name: 'Locate node on canvas',
exact: true
})
).toBeVisible()
await expect(
swapGroup.getByTestId(TestIds.dialogs.swapNodeGroupCount)
).toHaveCount(0)
await comfyPage.canvasOps.pan({ x: -800, y: -800 })
const offsetBeforeLocate = await comfyPage.canvasOps.getOffset()
await rowLabel.click()
await expect
.poll(() => comfyPage.canvasOps.getOffset())
.not.toEqual(offsetBeforeLocate)
})
test('Replace Node replaces a single group in-place', async ({
comfyPage
}) => {
const swapGroup = getSwapNodesGroup(comfyPage.page)
await swapGroup.getByRole('button', { name: /replace node/i }).click()
await expect(swapGroup).toBeHidden()
const workflow = await comfyPage.workflow.getExportedWorkflow()
expect(
workflow.nodes,
'Node count should be unchanged after in-place replacement'
).toHaveLength(2)
const nodeTypes = workflow.nodes.map((n) => n.type)
expect(nodeTypes).not.toContain('E2E_OldSampler')
expect(nodeTypes).toContain('KSampler')
const ksampler = workflow.nodes.find((n) => n.type === 'KSampler')
expect(
ksampler?.id,
'Replaced node should keep the original id'
).toBe(1)
const linkFromReplacedToDecode = workflow.links?.find(
(l) => l[1] === 1 && l[3] === 2
)
expect(
linkFromReplacedToDecode,
'Output link from replaced node to VAEDecode should be preserved'
).toBeDefined()
})
test('Widget values are preserved after replacement', async ({
comfyPage
}) => {
await getSwapNodesGroup(comfyPage.page)
.getByRole('button', { name: /replace node/i })
.click()
const workflow = await comfyPage.workflow.getExportedWorkflow()
const ksampler = workflow.nodes.find((n) => n.type === 'KSampler')
expect(ksampler?.widgets_values).toBeDefined()
const widgetValues = ksampler!.widgets_values as unknown[]
expect(widgetValues).toEqual([
42,
'randomize',
20,
7,
'euler',
'normal',
1
])
})
test('Success toast is shown after replacement', async ({
comfyPage
}) => {
await getSwapNodesGroup(comfyPage.page)
.getByRole('button', { name: /replace node/i })
.click()
await expect(comfyPage.visibleToasts.first()).toContainText(
/replaced|swapped/i
)
})
})
test.describe('Same-type replacement group', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.VueNodes.Enabled',
mode.vueNodesEnabled
)
await setupNodeReplacement(comfyPage, mockNodeReplacementsSingle)
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/node_replacement_same_type'
)
})
test('Groups same-type replacement rows behind the title disclosure', async ({
comfyPage
}) => {
const swapGroup = getSwapNodesGroup(comfyPage.page)
const countBadge = swapGroup.getByTestId(
TestIds.dialogs.swapNodeGroupCount
)
const childRows = swapGroup.getByRole('listitem')
const expandButton = swapGroup.getByRole('button', {
name: 'Expand E2E_OldSampler',
exact: true
})
await expect(expandButton).toBeVisible()
await expect(countBadge).toHaveText('2')
await expect(childRows).toHaveCount(0)
await expandButton.click()
await expect(childRows).toHaveCount(2)
await expect(
swapGroup.getByRole('button', {
name: 'E2E_OldSampler',
exact: true
})
).toHaveCount(2)
await swapGroup
.getByRole('button', {
name: 'Collapse E2E_OldSampler',
exact: true
})
.click()
await expect(childRows).toHaveCount(0)
})
})
test.describe('Multi-type replacement', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.VueNodes.Enabled',
mode.vueNodesEnabled
)
await setupNodeReplacement(comfyPage, mockNodeReplacements)
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/node_replacement_multi'
)
})
test('Replace All replaces all groups across multiple types', async ({
comfyPage
}) => {
const swapGroup = getSwapNodesGroup(comfyPage.page)
await expect(swapGroup).toBeVisible()
await expect(swapGroup).toContainText('E2E_OldSampler')
await expect(swapGroup).toContainText('E2E_OldUpscaler')
await swapGroup
.getByRole('button', { name: 'Replace All', exact: true })
.click()
await expect(swapGroup).toBeHidden()
const workflow = await comfyPage.workflow.getExportedWorkflow()
const nodeTypes = workflow.nodes.map((n) => n.type)
expect(nodeTypes).not.toContain('E2E_OldSampler')
expect(nodeTypes).not.toContain('E2E_OldUpscaler')
expect(nodeTypes).toContain('KSampler')
expect(nodeTypes).toContain('ImageScaleBy')
})
test('Output connections are preserved across replacement with output mapping', async ({
comfyPage
}) => {
await getSwapNodesGroup(comfyPage.page)
.getByRole('button', { name: 'Replace All', exact: true })
.click()
const replacedNodeOutputLinkCount = await comfyPage.page.evaluate(
() =>
window.app!.graph!.getNodeById(2)?.outputs[0]?.links?.length ?? 0
)
expect(
replacedNodeOutputLinkCount,
'Replaced upscaler should still drive its downstream consumer'
).toBeGreaterThan(0)
})
})
})
}
})