test: add E2E tests for ImageCropV2 widget (#10737)

## Summary
Adds Playwright E2E tests for the ImageCropV2 widget covering 
1. the empty state (no source image)
2. default control rendering
3. source image display with crop overlay
4. drag-to-reposition behavior.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10737-test-add-E2E-tests-for-ImageCropV2-widget-3336d73d365081b28ed9db63e5df383e)
by [Unito](https://www.unito.io)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: primarily adds Playwright E2E coverage and introduces
`data-testid` attributes for more stable selectors, with no changes to
core crop behavior.
> 
> **Overview**
> Adds new Playwright E2E coverage for the `ImageCropV2` Vue-node
widget, including workflows/fixtures for a disconnected input and a
`LoadImage -> ImageCropV2 -> PreviewImage` pipeline.
> 
> Tests validate the empty state and default controls, verify the crop
overlay renders after execution with screenshot assertions, and exercise
drag-to-reposition by dispatching pointer events and asserting the
widget’s crop value updates.
> 
> Updates `WidgetImageCrop.vue` to add `data-testid` hooks (empty
state/icon and crop overlay) to make the E2E selectors stable.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
9f29272742. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
This commit is contained in:
Kelly Yang
2026-04-12 20:58:01 -07:00
committed by GitHub
parent 63435bdb34
commit cab46567c0
6 changed files with 318 additions and 1 deletions

View File

@@ -0,0 +1,50 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "ImageCropV2",
"pos": [50, 50],
"size": [400, 500],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
}
],
"properties": {
"Node name for S&R": "ImageCropV2"
},
"widgets_values": [
{
"x": 0,
"y": 0,
"width": 512,
"height": 512
}
]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -0,0 +1,100 @@
{
"last_node_id": 3,
"last_link_id": 2,
"nodes": [
{
"id": 1,
"type": "LoadImage",
"pos": [50, 50],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [1]
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["example.png", "image"]
},
{
"id": 2,
"type": "ImageCropV2",
"pos": [450, 50],
"size": [400, 500],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 1
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [2]
}
],
"properties": {
"Node name for S&R": "ImageCropV2"
},
"widgets_values": [
{
"x": 10,
"y": 10,
"width": 100,
"height": 100
}
]
},
{
"id": 3,
"type": "PreviewImage",
"pos": [900, 50],
"size": [315, 270],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 2
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
}
],
"links": [
[1, 1, 0, 2, 0, "IMAGE"],
[2, 2, 0, 3, 0, "IMAGE"]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -0,0 +1,162 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
type CropValue = { x: number; y: number; width: number; height: number } | null
test.describe('Image Crop', { tag: '@widget' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test.describe('without source image', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/image_crop_widget')
await comfyPage.vueNodes.waitForNodes()
})
test(
'Shows empty state when no input image is connected',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
await expect.soft(node.getByTestId('crop-empty-icon')).toBeVisible()
await expect.soft(node).toContainText('No input image connected')
await expect.soft(node.getByTestId('crop-overlay')).toHaveCount(0)
await expect.soft(node.locator('img')).toHaveCount(0)
}
)
test(
'Renders controls in default state',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
await expect(node.getByText('Ratio')).toBeVisible()
await expect(
node.locator('button:has(.icon-\\[lucide--lock-open\\])')
).toBeVisible()
await expect(node.locator('input')).toHaveCount(4)
}
)
})
test.describe(
'with source image after execution',
{ tag: ['@widget', '@slow'] },
() => {
async function getCropValue(comfyPage: ComfyPage): Promise<CropValue> {
return comfyPage.page.evaluate(() => {
const n = window.app!.graph.getNodeById(2)
const w = n?.widgets?.find((w) => w.type === 'imagecrop')
const v = w?.value
if (v && typeof v === 'object' && 'x' in v) {
const crop = v as {
x: number
y: number
width: number
height: number
}
return {
x: crop.x,
y: crop.y,
width: crop.width,
height: crop.height
}
}
return null
})
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/image_crop_with_source')
await comfyPage.vueNodes.waitForNodes()
await comfyPage.runButton.click()
await expect(
comfyPage.vueNodes.getNodeLocator('2').locator('img')
).toBeVisible({ timeout: 30_000 })
})
test(
'Displays source image with crop overlay after execution',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
const img = node.locator('img')
await expect
.poll(() => img.evaluate((el: HTMLImageElement) => el.naturalWidth))
.toBeGreaterThan(0)
await expect(node.getByTestId('crop-overlay')).toBeVisible()
await comfyPage.nextFrame()
await comfyPage.nextFrame()
await expect(node).toHaveScreenshot('image-crop-with-source.png', {
maxDiffPixelRatio: 0.05
})
}
)
test(
'Drag crop box updates crop position',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
const cropBox = node.getByTestId('crop-overlay')
const box = await cropBox.boundingBox()
if (!box) throw new Error('Crop box not found')
const valueBefore = await getCropValue(comfyPage)
if (!valueBefore)
throw new Error('Widget value missing — check fixture setup')
const startX = box.x + box.width / 2
const startY = box.y + box.height / 2
const pointerOpts = { bubbles: true, cancelable: true, pointerId: 1 }
await cropBox.dispatchEvent('pointerdown', {
...pointerOpts,
clientX: startX,
clientY: startY
})
await comfyPage.nextFrame()
await cropBox.dispatchEvent('pointermove', {
...pointerOpts,
clientX: startX + 15,
clientY: startY + 10
})
await comfyPage.nextFrame()
await cropBox.dispatchEvent('pointermove', {
...pointerOpts,
clientX: startX + 30,
clientY: startY + 20
})
await cropBox.dispatchEvent('pointerup', {
...pointerOpts,
clientX: startX + 30,
clientY: startY + 20
})
await comfyPage.nextFrame()
await expect(async () => {
const valueAfter = await getCropValue(comfyPage)
expect(valueAfter?.x).toBeGreaterThan(valueBefore.x)
expect(valueAfter?.y).toBeGreaterThan(valueBefore.y)
expect(valueAfter?.width).toBe(valueBefore.width)
expect(valueAfter?.height).toBe(valueBefore.height)
}).toPass({ timeout: 5000 })
await expect(node).toHaveScreenshot('image-crop-after-drag.png', {
maxDiffPixelRatio: 0.05
})
}
)
}
)
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -17,8 +17,12 @@
<div
v-else-if="!imageUrl"
class="flex size-full flex-col items-center justify-center text-center"
data-testid="crop-empty-state"
>
<i class="mb-2 icon-[lucide--image] size-12" />
<i
class="mb-2 icon-[lucide--image] size-12"
data-testid="crop-empty-icon"
/>
<p class="text-sm">{{ $t('imageCrop.noInputImage') }}</p>
</div>
@@ -43,6 +47,7 @@
)
"
:style="cropBoxStyle"
data-testid="crop-overlay"
@pointerdown="handleDragStart"
@pointermove="handleDragMove"
@pointerup="handleDragEnd"