mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-16 04:30:59 +00:00
Compare commits
2 Commits
gizmo-cont
...
tests/e2e-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24c124c20b | ||
|
|
9b91af567e |
68
browser_tests/assets/missing/node_replacement_multi.json
Normal file
68
browser_tests/assets/missing/node_replacement_multi.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "E2E_OldSampler",
|
||||
"pos": [100, 100],
|
||||
"size": [300, 262],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "E2E_OldSampler" },
|
||||
"widgets_values": [42, 20, 7, "euler", "normal"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "E2E_OldUpscaler",
|
||||
"pos": [500, 100],
|
||||
"size": [300, 80],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "image", "type": "IMAGE", "link": null }],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [2],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "E2E_OldUpscaler" },
|
||||
"widgets_values": ["lanczos", 1.5]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "SaveImage",
|
||||
"pos": [900, 100],
|
||||
"size": [300, 80],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "images", "type": "IMAGE", "link": 2 }],
|
||||
"properties": { "Node name for S&R": "SaveImage" },
|
||||
"widgets_values": ["ComfyUI"]
|
||||
}
|
||||
],
|
||||
"links": [[2, 2, 0, 3, 0, "IMAGE"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": { "ds": { "scale": 1, "offset": [0, 0] } },
|
||||
"version": 0.4
|
||||
}
|
||||
59
browser_tests/assets/missing/node_replacement_simple.json
Normal file
59
browser_tests/assets/missing/node_replacement_simple.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "E2E_OldSampler",
|
||||
"pos": [100, 100],
|
||||
"size": [300, 262],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [1],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "E2E_OldSampler" },
|
||||
"widgets_values": [42, 20, 7, "euler", "normal"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "VAEDecode",
|
||||
"pos": [500, 100],
|
||||
"size": [210, 46],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "samples", "type": "LATENT", "link": 1 },
|
||||
{ "name": "vae", "type": "VAE", "link": null }
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "VAEDecode" },
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [[1, 1, 0, 2, 0, "LATENT"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": { "ds": { "scale": 1, "offset": [0, 0] } },
|
||||
"version": 0.4
|
||||
}
|
||||
47
browser_tests/fixtures/data/nodeReplacements.ts
Normal file
47
browser_tests/fixtures/data/nodeReplacements.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { NodeReplacementResponse } from '@/platform/nodeReplacement/types'
|
||||
|
||||
/**
|
||||
* Mock node replacement mappings for e2e tests.
|
||||
*
|
||||
* Maps fake "missing" node types (E2E_OldSampler, E2E_OldUpscaler) to real
|
||||
* core node types that are always available in the test server.
|
||||
*/
|
||||
export const mockNodeReplacements: NodeReplacementResponse = {
|
||||
E2E_OldSampler: [
|
||||
{
|
||||
new_node_id: 'KSampler',
|
||||
old_node_id: 'E2E_OldSampler',
|
||||
old_widget_ids: ['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'],
|
||||
input_mapping: [
|
||||
{ new_id: 'model', old_id: 'model' },
|
||||
{ new_id: 'positive', old_id: 'positive' },
|
||||
{ new_id: 'negative', old_id: 'negative' },
|
||||
{ new_id: 'latent_image', old_id: 'latent_image' },
|
||||
{ new_id: 'seed', old_id: 'seed' },
|
||||
{ new_id: 'steps', old_id: 'steps' },
|
||||
{ new_id: 'cfg', old_id: 'cfg' },
|
||||
{ new_id: 'sampler_name', old_id: 'sampler_name' },
|
||||
{ new_id: 'scheduler', old_id: 'scheduler' }
|
||||
],
|
||||
output_mapping: [{ new_idx: 0, old_idx: 0 }]
|
||||
}
|
||||
],
|
||||
E2E_OldUpscaler: [
|
||||
{
|
||||
new_node_id: 'ImageScaleBy',
|
||||
old_node_id: 'E2E_OldUpscaler',
|
||||
old_widget_ids: ['upscale_method', 'scale_by'],
|
||||
input_mapping: [
|
||||
{ new_id: 'image', old_id: 'image' },
|
||||
{ new_id: 'upscale_method', old_id: 'upscale_method' },
|
||||
{ new_id: 'scale_by', old_id: 'scale_by' }
|
||||
],
|
||||
output_mapping: [{ new_idx: 0, old_idx: 0 }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/** Subset containing only the E2E_OldSampler replacement. */
|
||||
export const mockNodeReplacementsSingle: NodeReplacementResponse = {
|
||||
E2E_OldSampler: mockNodeReplacements.E2E_OldSampler
|
||||
}
|
||||
@@ -61,6 +61,7 @@ export const TestIds = {
|
||||
missingModelDownload: 'missing-model-download',
|
||||
missingModelImportUnsupported: 'missing-model-import-unsupported',
|
||||
missingMediaGroup: 'error-group-missing-media',
|
||||
swapNodesGroup: 'error-group-swap-nodes',
|
||||
missingMediaRow: 'missing-media-row',
|
||||
missingMediaUploadDropzone: 'missing-media-upload-dropzone',
|
||||
missingMediaLibrarySelect: 'missing-media-library-select',
|
||||
|
||||
186
browser_tests/tests/nodeReplacement.spec.ts
Normal file
186
browser_tests/tests/nodeReplacement.spec.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
mockNodeReplacements,
|
||||
mockNodeReplacementsSingle
|
||||
} from '@e2e/fixtures/data/nodeReplacements'
|
||||
import type { NodeReplacementResponse } from '@/platform/nodeReplacement/types'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
|
||||
/**
|
||||
* Mock the `/api/node_replacements` endpoint and enable the feature flag +
|
||||
* settings required for node replacement to function.
|
||||
*/
|
||||
async function setupNodeReplacement(
|
||||
comfyPage: ComfyPage,
|
||||
replacements: NodeReplacementResponse
|
||||
) {
|
||||
await comfyPage.page.route('**/api/node_replacements', (route) =>
|
||||
route.fulfill({ json: replacements })
|
||||
)
|
||||
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
await comfyPage.settings.setSetting('Comfy.NodeReplacement.Enabled', true)
|
||||
|
||||
// Enable the server feature flag so the store fetches replacements.
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const flags = window.app!.api.serverFeatureFlags
|
||||
flags.value = { ...flags.value, node_replacements: true }
|
||||
})
|
||||
}
|
||||
|
||||
function getSwapNodesGroup(page: Page) {
|
||||
return page.getByTestId(TestIds.dialogs.swapNodesGroup)
|
||||
}
|
||||
|
||||
test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
|
||||
test.describe('Single replacement', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
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).toContainText('E2E_OldSampler')
|
||||
await expect(
|
||||
swapGroup.getByRole('button', { name: 'Replace All', exact: true })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
// Swap group should disappear after replacement
|
||||
await expect(swapGroup).toBeHidden()
|
||||
|
||||
// Verify the replacement was applied correctly via the exported workflow
|
||||
const workflow = await comfyPage.workflow.getExportedWorkflow()
|
||||
|
||||
// Node count stays the same (in-place replacement)
|
||||
expect(
|
||||
workflow.nodes,
|
||||
'Node count should be unchanged after in-place replacement'
|
||||
).toHaveLength(2)
|
||||
|
||||
// The old type should be gone and replaced by KSampler
|
||||
const nodeTypes = workflow.nodes.map((n) => n.type)
|
||||
expect(nodeTypes).not.toContain('E2E_OldSampler')
|
||||
expect(nodeTypes).toContain('KSampler')
|
||||
|
||||
// The replaced node should keep the same id
|
||||
const ksampler = workflow.nodes.find((n) => n.type === 'KSampler')
|
||||
expect(ksampler?.id).toBe(1)
|
||||
|
||||
// Output connection from old node → VAEDecode should be preserved
|
||||
// Link tuple format: [link_id, source_node, source_slot, target_node, target_slot, type]
|
||||
const link = workflow.links?.find((l) => l[1] === 1 && l[3] === 2)
|
||||
expect(
|
||||
link,
|
||||
'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')
|
||||
|
||||
// The original workflow had widgets_values: [42, 20, 7, "euler", "normal"]
|
||||
// mapped to: seed=42, steps=20, cfg=7, sampler_name="euler", scheduler="normal"
|
||||
expect(ksampler?.widgets_values).toBeDefined()
|
||||
const widgetValues = ksampler!.widgets_values as unknown[]
|
||||
expect(widgetValues).toContain(42)
|
||||
expect(widgetValues).toContain(20)
|
||||
})
|
||||
|
||||
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('Multi-type replacement', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
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()
|
||||
|
||||
// Both types should appear
|
||||
await expect(swapGroup).toContainText('E2E_OldSampler')
|
||||
await expect(swapGroup).toContainText('E2E_OldUpscaler')
|
||||
|
||||
// Click "Replace All"
|
||||
await swapGroup
|
||||
.getByRole('button', { name: 'Replace All', exact: true })
|
||||
.click()
|
||||
|
||||
// Swap group should disappear
|
||||
await expect(swapGroup).toBeHidden()
|
||||
|
||||
// Verify both old types are gone
|
||||
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 workflow = await comfyPage.workflow.getExportedWorkflow()
|
||||
|
||||
// E2E_OldUpscaler (id=2) had an output link to SaveImage (id=3).
|
||||
// After replacement to ImageScaleBy, that link should be preserved.
|
||||
// Link tuple format: [link_id, source_node, source_slot, target_node, target_slot, type]
|
||||
const linkToSave = workflow.links?.find((l) => l[1] === 2 && l[3] === 3)
|
||||
expect(
|
||||
linkToSave,
|
||||
'Output link from replaced upscaler to SaveImage should be preserved'
|
||||
).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user