test: use targeted screenshots instead of full-canvas captures

Screenshot tests now clip to specific nodes/regions instead of
capturing the entire canvas. This reduces screenshot size and
makes tests more focused on what they actually verify.

- Add getNodeClipRegion() helper for computing page-coordinate
  clip regions from LiteGraph node positions
- Convert 28 full-canvas screenshots to targeted clips across
  widget, nodeDisplay, primitiveNode, noteNode, recordAudio specs
- Use Vue node locators for mute, bypass, and color state tests
- Close floating menu in beforeEach for tests using UseNewMenu=Disabled
- Delete old golden images (will be regenerated in CI)
This commit is contained in:
Johnpaul
2026-04-13 22:16:11 +01:00
parent e39468567a
commit 9b7c35dae1
37 changed files with 216 additions and 48 deletions

View File

@@ -0,0 +1,61 @@
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
/**
* Compute a clip region encompassing one or more nodes on the canvas.
* Returns page-level coordinates for use with
* `page.toHaveScreenshot({ clip })`.
*
* Accounts for zoom scale, pan offset, title bar height, and
* canvas element position on page.
*/
export async function getNodeClipRegion(
comfyPage: ComfyPage,
nodeIds: NodeId[],
padding = 40
): Promise<{ x: number; y: number; width: number; height: number }> {
const canvasBox = await comfyPage.canvas.boundingBox()
if (!canvasBox) throw new Error('Canvas element not visible')
const region = await comfyPage.page.evaluate(
([ids, pad]) => {
const canvas = window.app!.canvas
const ds = canvas.ds
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const id of ids) {
const node = canvas.graph!.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found`)
const pos = ds.convertOffsetToCanvas([node.pos[0], node.pos[1]])
const scaledWidth = node.size[0] * ds.scale
const scaledHeight = node.size[1] * ds.scale
const titleHeight = window.LiteGraph!.NODE_TITLE_HEIGHT * ds.scale
minX = Math.min(minX, pos[0])
minY = Math.min(minY, pos[1] - titleHeight)
maxX = Math.max(maxX, pos[0] + scaledWidth)
maxY = Math.max(maxY, pos[1] + scaledHeight)
}
return {
x: Math.max(0, minX - pad),
y: Math.max(0, minY - pad),
width: maxX - minX + pad * 2,
height: maxY - minY + pad * 2
}
},
[nodeIds, padding] as const
)
return {
x: Math.max(0, canvasBox.x + region.x),
y: Math.max(0, canvasBox.y + region.y),
width: region.width,
height: region.height
}
}

View File

@@ -2,9 +2,11 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { getNodeClipRegion } from '@e2e/fixtures/utils/screenshotClip'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.closeMenu()
})
// If an input is optional by node definition, it should be shown as
@@ -12,27 +14,47 @@ test.beforeEach(async ({ comfyPage }) => {
test.describe('Optional input', { tag: ['@screenshot', '@node'] }, () => {
test('No shape specified', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/optional_input_no_shape')
await expect(comfyPage.canvas).toHaveScreenshot('optional_input.png')
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
const clip = await getNodeClipRegion(comfyPage, [node.id])
await expect(comfyPage.page).toHaveScreenshot('optional_input.png', {
clip
})
})
test('Wrong shape specified', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/optional_input_wrong_shape')
await expect(comfyPage.canvas).toHaveScreenshot('optional_input.png')
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
const clip = await getNodeClipRegion(comfyPage, [node.id])
await expect(comfyPage.page).toHaveScreenshot('optional_input.png', {
clip
})
})
test('Correct shape specified', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/optional_input_correct_shape')
await expect(comfyPage.canvas).toHaveScreenshot('optional_input.png')
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
const clip = await getNodeClipRegion(comfyPage, [node.id])
await expect(comfyPage.page).toHaveScreenshot('optional_input.png', {
clip
})
})
test('Force input', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/force_input')
await expect(comfyPage.canvas).toHaveScreenshot('force_input.png')
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
const clip = await getNodeClipRegion(comfyPage, [node.id])
await expect(comfyPage.page).toHaveScreenshot('force_input.png', {
clip
})
})
test('Default input', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/default_input')
await expect(comfyPage.canvas).toHaveScreenshot('default_input.png')
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
const clip = await getNodeClipRegion(comfyPage, [node.id])
await expect(comfyPage.page).toHaveScreenshot('default_input.png', {
clip
})
})
test('Only optional inputs', async ({ comfyPage }) => {
@@ -74,22 +96,32 @@ test.describe('Optional input', { tag: ['@screenshot', '@node'] }, () => {
test('slider', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/simple_slider')
await expect(comfyPage.canvas).toHaveScreenshot('simple_slider.png')
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
const clip = await getNodeClipRegion(comfyPage, [node.id])
await expect(comfyPage.page).toHaveScreenshot('simple_slider.png', {
clip
})
})
test('unknown converted widget', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'missing/missing_nodes_converted_widget'
)
await expect(comfyPage.canvas).toHaveScreenshot(
'missing_nodes_converted_widget.png'
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
const clip = await getNodeClipRegion(comfyPage, [node.id])
await expect(comfyPage.page).toHaveScreenshot(
'missing_nodes_converted_widget.png',
{ clip }
)
})
test('dynamically added input', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('inputs/dynamically_added_input')
await expect(comfyPage.canvas).toHaveScreenshot(
'dynamically_added_input.png'
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
const clip = await getNodeClipRegion(comfyPage, [node.id])
await expect(comfyPage.page).toHaveScreenshot(
'dynamically_added_input.png',
{ clip }
)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -1,14 +1,18 @@
import { expect } from '@playwright/test'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { getNodeClipRegion } from '@e2e/fixtures/utils/screenshotClip'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.closeMenu()
})
test.describe('Note Node', { tag: '@node' }, () => {
test('Can load node nodes', { tag: '@screenshot' }, async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('nodes/note_nodes')
await expect(comfyPage.canvas).toHaveScreenshot('note_nodes.png')
const clip = await getNodeClipRegion(comfyPage, [1, 2] as NodeId[])
await expect(comfyPage.page).toHaveScreenshot('note_nodes.png', { clip })
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -2,16 +2,22 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { getNodeClipRegion } from '@e2e/fixtures/utils/screenshotClip'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.closeMenu()
})
test.describe('Primitive Node', { tag: ['@screenshot', '@node'] }, () => {
test('Can load with correct size', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('primitive/primitive_node')
await expect(comfyPage.canvas).toHaveScreenshot('primitive_node.png')
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
const clip = await getNodeClipRegion(comfyPage, [node.id])
await expect(comfyPage.page).toHaveScreenshot('primitive_node.png', {
clip
})
})
// When link is dropped on widget, it should automatically convert the widget
@@ -26,8 +32,13 @@ test.describe('Primitive Node', { tag: ['@screenshot', '@node'] }, () => {
await comfyPage.nodeOps.getNodeRefById(2)
// Connect the output of the primitive node to the input of first widget of the ksampler node
await primitiveNode.connectWidget(0, ksamplerNode, 0)
await expect(comfyPage.canvas).toHaveScreenshot(
'primitive_node_connected.png'
const clip = await getNodeClipRegion(comfyPage, [
primitiveNode.id,
ksamplerNode.id
])
await expect(comfyPage.page).toHaveScreenshot(
'primitive_node_connected.png',
{ clip }
)
})
@@ -40,8 +51,13 @@ test.describe('Primitive Node', { tag: ['@screenshot', '@node'] }, () => {
const clipEncoderNode: NodeReference =
await comfyPage.nodeOps.getNodeRefById(2)
await primitiveNode.connectWidget(0, clipEncoderNode, 0)
await expect(comfyPage.canvas).toHaveScreenshot(
'primitive_node_connected_dom_widget.png'
const clip = await getNodeClipRegion(comfyPage, [
primitiveNode.id,
clipEncoderNode.id
])
await expect(comfyPage.page).toHaveScreenshot(
'primitive_node_connected_dom_widget.png',
{ clip }
)
})
@@ -54,8 +70,13 @@ test.describe('Primitive Node', { tag: ['@screenshot', '@node'] }, () => {
const ksamplerNode: NodeReference =
await comfyPage.nodeOps.getNodeRefById(2)
await primitiveNode.connectWidget(0, ksamplerNode, 0)
await expect(comfyPage.canvas).toHaveScreenshot(
'static_primitive_connected.png'
const clip = await getNodeClipRegion(comfyPage, [
primitiveNode.id,
ksamplerNode.id
])
await expect(comfyPage.page).toHaveScreenshot(
'static_primitive_connected.png',
{ clip }
)
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1,9 +1,11 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { getNodeClipRegion } from '@e2e/fixtures/utils/screenshotClip'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.closeMenu()
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
})
@@ -30,6 +32,10 @@ test.describe('Record Audio Node', { tag: '@screenshot' }, () => {
.toBe(1)
// Take a screenshot of the canvas with the RecordAudio node
await expect(comfyPage.canvas).toHaveScreenshot('record_audio_node.png')
const nodes = await comfyPage.nodeOps.getNodeRefsByType('RecordAudio')
const clip = await getNodeClipRegion(comfyPage, [nodes[0].id])
await expect(comfyPage.page).toHaveScreenshot('record_audio_node.png', {
clip
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

View File

@@ -28,9 +28,9 @@ test.describe('Vue Node Bypass', () => {
.getByTestId('node-inner-wrapper')
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-bypassed-state.png'
)
await expect(
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
).toHaveScreenshot('vue-node-bypassed-state.png')
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
await expect(checkpointNode).not.toHaveClass(BYPASS_CLASS)

View File

@@ -32,7 +32,7 @@ test.describe('Vue Node Custom Colors', { tag: '@screenshot' }, () => {
.getByTestId(TestIds.selectionToolbox.colorBlue)
.click()
await expect(comfyPage.canvas).toHaveScreenshot(
await expect(loadCheckpointNode).toHaveScreenshot(
'vue-node-custom-color-blue.png'
)
})

View File

@@ -22,9 +22,7 @@ test.describe('Vue Node Mute', () => {
const checkpointNode =
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
await expect(checkpointNode).toHaveCSS('opacity', MUTE_OPACITY)
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-muted-state.png'
)
await expect(checkpointNode).toHaveScreenshot('vue-node-muted-state.png')
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
await expect(checkpointNode).not.toHaveCSS('opacity', MUTE_OPACITY)

View File

@@ -2,9 +2,11 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
import { getNodeClipRegion } from '@e2e/fixtures/utils/screenshotClip'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.closeMenu()
})
test.describe('Combo text widget', { tag: ['@screenshot', '@widget'] }, () => {
@@ -15,18 +17,29 @@ test.describe('Combo text widget', { tag: ['@screenshot', '@widget'] }, () => {
0.2,
1
)
await expect(comfyPage.canvas).toHaveScreenshot(
'load-checkpoint-resized-min-width.png'
const loadCheckpointNode = (
await comfyPage.nodeOps.getNodeRefsByTitle('Load Checkpoint')
)[0]
const loadCheckpointClip = await getNodeClipRegion(comfyPage, [
loadCheckpointNode.id
])
await expect(comfyPage.page).toHaveScreenshot(
'load-checkpoint-resized-min-width.png',
{ clip: loadCheckpointClip }
)
await comfyPage.closeMenu()
await comfyPage.nodeOps.resizeNode(
DefaultGraphPositions.ksampler.pos,
DefaultGraphPositions.ksampler.size,
0.2,
1
)
await expect(comfyPage.canvas).toHaveScreenshot(
`ksampler-resized-min-width.png`
const ksamplerNode = (
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
)[0]
const ksamplerClip = await getNodeClipRegion(comfyPage, [ksamplerNode.id])
await expect(comfyPage.page).toHaveScreenshot(
`ksampler-resized-min-width.png`,
{ clip: ksamplerClip }
)
})
@@ -37,8 +50,13 @@ test.describe('Combo text widget', { tag: ['@screenshot', '@widget'] }, () => {
0.8,
0.8
)
await expect(comfyPage.canvas).toHaveScreenshot(
'empty-latent-resized-80-percent.png'
const emptyLatentNode = (
await comfyPage.nodeOps.getNodeRefsByTitle('Empty Latent Image')
)[0]
const clip = await getNodeClipRegion(comfyPage, [emptyLatentNode.id])
await expect(comfyPage.page).toHaveScreenshot(
'empty-latent-resized-80-percent.png',
{ clip }
)
})
@@ -50,7 +68,13 @@ test.describe('Combo text widget', { tag: ['@screenshot', '@widget'] }, () => {
1,
true
)
await expect(comfyPage.canvas).toHaveScreenshot('resized-to-original.png')
const loadCheckpointNode = (
await comfyPage.nodeOps.getNodeRefsByTitle('Load Checkpoint')
)[0]
const clip = await getNodeClipRegion(comfyPage, [loadCheckpointNode.id])
await expect(comfyPage.page).toHaveScreenshot('resized-to-original.png', {
clip
})
})
test('should refresh combo values of optional inputs', async ({
@@ -105,12 +129,16 @@ test.describe('Combo text widget', { tag: ['@screenshot', '@widget'] }, () => {
test.describe('Boolean widget', { tag: ['@screenshot', '@widget'] }, () => {
test('Can toggle', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/boolean_widget')
await expect(comfyPage.canvas).toHaveScreenshot('boolean_widget.png')
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
const clip = await getNodeClipRegion(comfyPage, [node.id])
await expect(comfyPage.page).toHaveScreenshot('boolean_widget.png', {
clip
})
const widget = await node.getWidget(0)
await widget.click()
await expect(comfyPage.canvas).toHaveScreenshot(
'boolean_widget_toggled.png'
await expect(comfyPage.page).toHaveScreenshot(
'boolean_widget_toggled.png',
{ clip }
)
})
})
@@ -129,7 +157,10 @@ test.describe('Slider widget', { tag: ['@screenshot', '@widget'] }, () => {
}
})
await widget.dragHorizontal(50)
await expect(comfyPage.canvas).toHaveScreenshot('slider_widget_dragged.png')
const clip = await getNodeClipRegion(comfyPage, [node.id])
await expect(comfyPage.page).toHaveScreenshot('slider_widget_dragged.png', {
clip
})
await expect
.poll(() => comfyPage.page.evaluate(() => window.widgetValue))
@@ -151,7 +182,10 @@ test.describe('Number widget', { tag: ['@screenshot', '@widget'] }, () => {
}
})
await widget.dragHorizontal(50)
await expect(comfyPage.canvas).toHaveScreenshot('seed_widget_dragged.png')
const clip = await getNodeClipRegion(comfyPage, [node.id])
await expect(comfyPage.page).toHaveScreenshot('seed_widget_dragged.png', {
clip
})
await expect
.poll(() => comfyPage.page.evaluate(() => window.widgetValue))
@@ -179,8 +213,10 @@ test.describe(
.poll(async () => (await node.getSize()).height)
.toBeGreaterThan(initialSize.height)
await expect(comfyPage.canvas).toHaveScreenshot(
'ksampler_widget_added.png'
const clip = await getNodeClipRegion(comfyPage, [node.id])
await expect(comfyPage.page).toHaveScreenshot(
'ksampler_widget_added.png',
{ clip }
)
})
}
@@ -189,8 +225,11 @@ test.describe(
test.describe('Image widget', { tag: ['@screenshot', '@widget'] }, () => {
test('Can load image', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await expect(comfyPage.canvas).toHaveScreenshot('load_image_widget.png', {
maxDiffPixels: 50
const nodes = await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
const clip = await getNodeClipRegion(comfyPage, [nodes[0].id])
await expect(comfyPage.page).toHaveScreenshot('load_image_widget.png', {
maxDiffPixels: 50,
clip
})
})
@@ -208,8 +247,10 @@ test.describe('Image widget', { tag: ['@screenshot', '@widget'] }, () => {
})
// Expect the image preview to change automatically
await expect(comfyPage.canvas).toHaveScreenshot(
'image_preview_drag_and_dropped.png'
const clip = await getNodeClipRegion(comfyPage, [loadImageNode.id])
await expect(comfyPage.page).toHaveScreenshot(
'image_preview_drag_and_dropped.png',
{ clip }
)
// Expect the filename combo value to be updated
@@ -264,9 +305,10 @@ test.describe('Image widget', { tag: ['@screenshot', '@widget'] }, () => {
await comfyPage.nextFrame()
// Expect the image preview to change automatically
await expect(comfyPage.canvas).toHaveScreenshot(
const clip = await getNodeClipRegion(comfyPage, [loadImageNode.id])
await expect(comfyPage.page).toHaveScreenshot(
'image_preview_changed_by_combo_value.png',
{ maxDiffPixels: 50 }
{ maxDiffPixels: 50, clip }
)
// Expect the filename combo value to be updated
@@ -403,7 +445,11 @@ test.describe('Load audio widget', { tag: ['@screenshot', '@widget'] }, () => {
test('Can load audio', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/load_audio_widget')
await expect(comfyPage.page.locator('.comfy-audio')).toBeVisible()
await expect(comfyPage.canvas).toHaveScreenshot('load_audio_widget.png')
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
const clip = await getNodeClipRegion(comfyPage, [node.id])
await expect(comfyPage.page).toHaveScreenshot('load_audio_widget.png', {
clip
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB