Compare commits

...

1 Commits

Author SHA1 Message Date
Kelly Yang
7b0d59552d test: add E2E tests for ImageCropV2 drag and free resize (PR 1/4)
- Update workflow asset to include LoadImage source node connected to
  ImageCropV2, enabling image injection in tests
- Add injectSourceImage helper that sets preview images via the Pinia
  nodeOutput store, triggering the composable's reactive image URL update
- Add setCropState / getCropState helpers for controlled test setup and
  assertion
- Add dragFrom / getResizeHandle helpers for pointer interaction
- Level 2 tests (drag): moves box, clamps right, clamps top-left,
  no-op when no image
- Level 3 tests (free resize): all 8 handle directions, MIN_CROP_SIZE
  enforcement, boundary clamping, handle count when ratio unlocked
2026-03-29 16:51:56 -07:00
2 changed files with 498 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,429 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
// ---- Helpers ---------------------------------------------------------------
function createTestImageDataUrl(
width: number,
height: number,
color: string
): string {
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">` +
`<rect width="${width}" height="${height}" fill="${color}"/>` +
`</svg>`
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
}
type Bounds = { x: number; y: number; width: number; height: number }
/**
* Injects an image into the node output store for the given source node,
* simulating what happens after a node executes and produces image output.
*/
async function injectSourceImage(
page: Page,
sourceNodeId: number,
dataUrl: string
): Promise<void> {
await page.evaluate(
({ nodeId, url }) => {
type NodeOutputStore = {
setNodePreviewsByNodeId: (id: number, urls: string[]) => void
}
type VueAppElement = HTMLElement & {
__vue_app__: {
config: {
globalProperties: {
$pinia: { _s: Map<string, NodeOutputStore> }
}
}
}
}
const el = document.getElementById('vue-app') as unknown as VueAppElement
const store =
el.__vue_app__.config.globalProperties.$pinia._s.get('nodeOutput')!
store.setNodePreviewsByNodeId(nodeId, [url])
},
{ nodeId: sourceNodeId, url: dataUrl }
)
}
async function setCropState(page: Page, bounds: Bounds): Promise<void> {
await page.evaluate((bounds) => {
type BoundsValue = { x: number; y: number; width: number; height: number }
const node = window.app!.graph.getNodeById(1)
const widget = node?.widgets?.find((w) => w.type === 'imagecrop')
if (widget?.value) {
const value = widget.value as unknown as BoundsValue
value.x = bounds.x
value.y = bounds.y
value.width = bounds.width
value.height = bounds.height
widget.callback?.(widget.value)
}
}, bounds)
}
async function getCropState(page: Page): Promise<Bounds> {
return page.evaluate(() => {
type BoundsValue = { x: number; y: number; width: number; height: number }
const node = window.app!.graph.getNodeById(1)
const widget = node?.widgets?.find((w) => w.type === 'imagecrop')
const v = widget?.value as unknown as BoundsValue
return { x: v.x, y: v.y, width: v.width, height: v.height }
})
}
/**
* Injects a test image into the source node and waits for the crop widget to
* be fully ready: image loaded, scale factor computed, crop box visible.
*/
async function setupWithImage(
comfyPage: ComfyPage,
imageWidth: number,
imageHeight: number,
initialBounds: Bounds
): Promise<void> {
await injectSourceImage(
comfyPage.page,
2,
createTestImageDataUrl(imageWidth, imageHeight, 'steelblue')
)
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node.locator('img')).toBeVisible()
await comfyPage.page.waitForFunction(() => {
const img = document.querySelector(
'[data-node-id="1"] img'
) as HTMLImageElement | null
return (img?.complete ?? false) && (img?.naturalWidth ?? 0) > 0
})
await setCropState(comfyPage.page, initialBounds)
await comfyPage.nextFrame()
await expect(node.locator('.cursor-move')).toBeVisible()
}
/**
* Returns a locator for one of the 8 resize handles on the crop widget.
* Handles are ordered in DOM as: top, bottom, left, right, nw, ne, sw, se.
*/
function getResizeHandle(
nodeLocator: Locator,
direction: 'top' | 'bottom' | 'left' | 'right' | 'nw' | 'ne' | 'sw' | 'se'
): Locator {
switch (direction) {
case 'top':
return nodeLocator.locator('.cursor-ns-resize').first()
case 'bottom':
return nodeLocator.locator('.cursor-ns-resize').last()
case 'left':
return nodeLocator.locator('.cursor-ew-resize').first()
case 'right':
return nodeLocator.locator('.cursor-ew-resize').last()
case 'nw':
return nodeLocator.locator('.cursor-nwse-resize').first()
case 'se':
return nodeLocator.locator('.cursor-nwse-resize').last()
case 'ne':
return nodeLocator.locator('.cursor-nesw-resize').first()
case 'sw':
return nodeLocator.locator('.cursor-nesw-resize').last()
}
}
async function dragFrom(
page: Page,
locator: Locator,
deltaX: number,
deltaY: number
): Promise<void> {
const box = await locator.boundingBox()
expect(box).not.toBeNull()
const startX = box!.x + box!.width / 2
const startY = box!.y + box!.height / 2
await page.mouse.move(startX, startY)
await page.mouse.down()
await page.mouse.move(startX + deltaX, startY + deltaY, { steps: 10 })
await page.mouse.up()
}
// ---- Tests -----------------------------------------------------------------
test.describe('Image Crop', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('widgets/image_crop_widget')
await comfyPage.vueNodes.waitForNodes()
})
test(
'shows empty state when no input image is available',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
await expect(node).toContainText('No input image connected')
await expect(node.locator('.cursor-move')).not.toBeVisible()
}
)
test(
'enforces minimum node size of 300×450',
{ tag: '@node' },
async ({ comfyPage }) => {
const size = await comfyPage.page.evaluate(() => {
const node = window.app!.graph.getNodeById(1)
return node?.size as [number, number]
})
expect(size[0]).toBeGreaterThanOrEqual(300)
expect(size[1]).toBeGreaterThanOrEqual(450)
}
)
test.describe('drag', { tag: '@widget' }, () => {
test('moves the crop box', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 100,
y: 100,
width: 200,
height: 200
})
const cropBox = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.cursor-move')
await dragFrom(comfyPage.page, cropBox, 100, 0)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.x).toBeGreaterThan(100)
expect(state.y).toBe(100)
expect(state.width).toBe(200)
expect(state.height).toBe(200)
})
test('clamps to right boundary', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 550,
y: 100,
width: 200,
height: 200
})
const cropBox = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.cursor-move')
await dragFrom(comfyPage.page, cropBox, 500, 0)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.x + state.width).toBeLessThanOrEqual(800)
})
test('clamps to top-left boundary', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 50,
y: 50,
width: 200,
height: 200
})
const cropBox = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.cursor-move')
await dragFrom(comfyPage.page, cropBox, -500, -500)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.x).toBeGreaterThanOrEqual(0)
expect(state.y).toBeGreaterThanOrEqual(0)
})
test('does nothing when no image is loaded', async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node.locator('.cursor-move')).not.toBeVisible()
const stateBefore = await getCropState(comfyPage.page)
const nodeBox = await node.boundingBox()
if (nodeBox) {
await comfyPage.page.mouse.click(
nodeBox.x + nodeBox.width / 2,
nodeBox.y + nodeBox.height / 2
)
}
await comfyPage.nextFrame()
const stateAfter = await getCropState(comfyPage.page)
expect(stateAfter).toEqual(stateBefore)
})
})
test.describe('free resize', { tag: '@widget' }, () => {
test('right edge increases width', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 100,
y: 100,
width: 200,
height: 200
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await dragFrom(comfyPage.page, getResizeHandle(node, 'right'), 80, 0)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.width).toBeGreaterThan(200)
expect(state.x).toBe(100)
expect(state.y).toBe(100)
expect(state.height).toBe(200)
})
test('left edge adjusts x and width', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 200,
y: 100,
width: 300,
height: 200
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await dragFrom(comfyPage.page, getResizeHandle(node, 'left'), -80, 0)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.x).toBeLessThan(200)
expect(state.width).toBeGreaterThan(300)
expect(state.y).toBe(100)
expect(state.height).toBe(200)
})
test('bottom edge increases height', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 100,
y: 100,
width: 200,
height: 200
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await dragFrom(comfyPage.page, getResizeHandle(node, 'bottom'), 0, 80)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.height).toBeGreaterThan(200)
expect(state.x).toBe(100)
expect(state.y).toBe(100)
expect(state.width).toBe(200)
})
test('top edge adjusts y and height', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 100,
y: 200,
width: 200,
height: 200
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await dragFrom(comfyPage.page, getResizeHandle(node, 'top'), 0, -80)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.y).toBeLessThan(200)
expect(state.height).toBeGreaterThan(200)
expect(state.x).toBe(100)
expect(state.width).toBe(200)
})
test('SE corner increases width and height', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 100,
y: 100,
width: 200,
height: 200
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await dragFrom(comfyPage.page, getResizeHandle(node, 'se'), 80, 80)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.width).toBeGreaterThan(200)
expect(state.height).toBeGreaterThan(200)
expect(state.x).toBe(100)
expect(state.y).toBe(100)
})
test('NW corner adjusts x, y, width, and height', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 200,
y: 200,
width: 200,
height: 200
})
const node = comfyPage.vueNodes.getNodeLocator('1')
await dragFrom(comfyPage.page, getResizeHandle(node, 'nw'), -80, -80)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.x).toBeLessThan(200)
expect(state.y).toBeLessThan(200)
expect(state.width).toBeGreaterThan(200)
expect(state.height).toBeGreaterThan(200)
})
test('enforces minimum crop size of 16px', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 100,
y: 100,
width: 50,
height: 50
})
const node = comfyPage.vueNodes.getNodeLocator('1')
// Drag right edge far left to try to collapse width below the minimum
await dragFrom(comfyPage.page, getResizeHandle(node, 'right'), -500, 0)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.width).toBeGreaterThanOrEqual(16)
})
test('clamps resize to image boundary', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 600,
y: 100,
width: 100,
height: 200
})
const node = comfyPage.vueNodes.getNodeLocator('1')
// Drag right edge far past the image right boundary
await dragFrom(comfyPage.page, getResizeHandle(node, 'right'), 500, 0)
await comfyPage.nextFrame()
const state = await getCropState(comfyPage.page)
expect(state.x + state.width).toBeLessThanOrEqual(800)
})
test('shows 8 handles when ratio is unlocked', async ({ comfyPage }) => {
await setupWithImage(comfyPage, 800, 600, {
x: 100,
y: 100,
width: 200,
height: 200
})
const node = comfyPage.vueNodes.getNodeLocator('1')
const handles = node.locator(
'.cursor-ns-resize, .cursor-ew-resize, .cursor-nwse-resize, .cursor-nesw-resize'
)
await expect(handles).toHaveCount(8)
})
})
})