mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-14 11:41:34 +00:00
Compare commits
2 Commits
v1.44.4
...
test/image
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d32981f2e9 | ||
|
|
ec6d393f0d |
@@ -1,3 +1,7 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import { MIN_CROP_SIZE } from '@/composables/useImageCrop'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
@@ -6,6 +10,111 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
type CropValue = { x: number; y: number; width: number; height: number } | null
|
||||
|
||||
const POINTER_OPTS = { bubbles: true, cancelable: true, pointerId: 1 } as const
|
||||
|
||||
async function getCropValue(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: number
|
||||
): Promise<CropValue> {
|
||||
return comfyPage.page.evaluate((id) => {
|
||||
const n = window.app!.graph.getNodeById(id)
|
||||
const w = n?.widgets?.find((x) => x.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
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
async function setCropBounds(
|
||||
comfyPage: ComfyPage,
|
||||
nodeId: number,
|
||||
bounds: { x: number; y: number; width: number; height: number }
|
||||
) {
|
||||
await comfyPage.page.evaluate(
|
||||
({ id, b }) => {
|
||||
const n = window.app!.graph.getNodeById(id)
|
||||
const w = n?.widgets?.find((x) => x.type === 'imagecrop')
|
||||
if (w) {
|
||||
w.value = { ...b }
|
||||
w.callback?.(b)
|
||||
}
|
||||
},
|
||||
{ id: nodeId, b: bounds }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async function waitForImageNaturalSize(
|
||||
img: Locator,
|
||||
options?: { timeout?: number }
|
||||
) {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
img.evaluate(
|
||||
(el: HTMLImageElement) => el.naturalWidth > 0 && el.naturalHeight > 0
|
||||
),
|
||||
{
|
||||
message: 'image naturalWidth and naturalHeight should be ready',
|
||||
...(options?.timeout != null ? { timeout: options.timeout } : {})
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function dragOnLocator(
|
||||
comfyPage: ComfyPage,
|
||||
target: Locator,
|
||||
deltaClientX: number,
|
||||
deltaClientY: number
|
||||
) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const b = await target.boundingBox()
|
||||
return b !== null && b.width > 0 && b.height > 0
|
||||
},
|
||||
{ message: 'drag target should have a laid-out bounding box' }
|
||||
)
|
||||
.toBe(true)
|
||||
const box = await target.boundingBox()
|
||||
if (!box) throw new Error('drag target has no bounding box')
|
||||
const x0 = box.x + box.width / 2
|
||||
const y0 = box.y + box.height / 2
|
||||
await target.dispatchEvent('pointerdown', {
|
||||
...POINTER_OPTS,
|
||||
clientX: x0,
|
||||
clientY: y0
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await target.dispatchEvent('pointermove', {
|
||||
...POINTER_OPTS,
|
||||
clientX: x0 + deltaClientX,
|
||||
clientY: y0 + deltaClientY
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await target.dispatchEvent('pointerup', {
|
||||
...POINTER_OPTS,
|
||||
clientX: x0 + deltaClientX,
|
||||
clientY: y0 + deltaClientY
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
test.describe('Image Crop', { tag: '@widget' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
@@ -22,12 +131,21 @@ test.describe('Image Crop', { tag: '@widget' }, () => {
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
await expect(node, 'image crop node should render').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)
|
||||
await expect
|
||||
.soft(node.getByTestId('crop-empty-icon'), 'empty state icon')
|
||||
.toBeVisible()
|
||||
await expect
|
||||
.soft(node, 'empty state copy')
|
||||
.toContainText('No input image connected')
|
||||
await expect
|
||||
.soft(node.getByTestId('crop-overlay'), 'no overlay without image')
|
||||
.toHaveCount(0)
|
||||
await expect.soft(node.locator('img'), 'no preview img').toHaveCount(0)
|
||||
await expect
|
||||
.soft(node.getByTestId('crop-resize-right'), 'no resize handles')
|
||||
.toBeHidden()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -36,50 +154,63 @@ test.describe('Image Crop', { tag: '@widget' }, () => {
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
await expect(node, 'image crop node should render').toBeVisible()
|
||||
|
||||
await expect(node.getByText('Ratio')).toBeVisible()
|
||||
await expect(node.getByText('Ratio'), 'ratio label').toBeVisible()
|
||||
await expect(
|
||||
node.locator('button:has(.icon-\\[lucide--lock-open\\])')
|
||||
node.locator('button:has(.icon-\\[lucide--lock-open\\])'),
|
||||
'ratio unlock button'
|
||||
).toBeVisible()
|
||||
await expect(node.locator('input')).toHaveCount(4)
|
||||
await expect(
|
||||
node.locator('input'),
|
||||
'bounding box numeric inputs'
|
||||
).toHaveCount(4)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Empty state matches screenshot baseline',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node, 'image crop node should render').toBeVisible()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(node).toHaveScreenshot('image-crop-empty-state.png', {
|
||||
maxDiffPixelRatio: 0.05
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test('Pointer drag on empty state does not change crop widget value', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const before = await getCropValue(comfyPage, 1)
|
||||
expect(before, 'Fixture should define imagecrop bounds').not.toBeNull()
|
||||
|
||||
const empty = node.getByTestId('crop-empty-state')
|
||||
await dragOnLocator(comfyPage, empty, 40, 30)
|
||||
|
||||
await expect
|
||||
.poll(() => getCropValue(comfyPage, 1), {
|
||||
message: 'empty-state drag should not mutate crop value'
|
||||
})
|
||||
.toStrictEqual(before)
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
comfyPage.vueNodes.getNodeLocator('2').locator('img'),
|
||||
'source image preview should appear after run'
|
||||
).toBeVisible({ timeout: 30_000 })
|
||||
})
|
||||
|
||||
@@ -90,11 +221,19 @@ test.describe('Image Crop', { tag: '@widget' }, () => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const img = node.locator('img')
|
||||
|
||||
await expect
|
||||
.poll(() => img.evaluate((el: HTMLImageElement) => el.naturalWidth))
|
||||
.toBeGreaterThan(0)
|
||||
await waitForImageNaturalSize(img)
|
||||
|
||||
await expect(
|
||||
node.getByTestId('crop-overlay'),
|
||||
'crop overlay should show when image is ready'
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
node
|
||||
.locator('[data-testid^="crop-resize-"]')
|
||||
.filter({ visible: true }),
|
||||
'unlocked ratio should expose eight handles'
|
||||
).toHaveCount(8)
|
||||
|
||||
await expect(node.getByTestId('crop-overlay')).toBeVisible()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(node).toHaveScreenshot('image-crop-with-source.png', {
|
||||
@@ -109,54 +248,623 @@ test.describe('Image Crop', { tag: '@widget' }, () => {
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const cropBox = node.getByTestId('crop-overlay')
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const b = await cropBox.boundingBox()
|
||||
return b !== null && b.width > 0 && b.height > 0 ? b : null
|
||||
},
|
||||
{ message: 'crop overlay should have a stable bounding box' }
|
||||
)
|
||||
.not.toBeNull()
|
||||
const box = await cropBox.boundingBox()
|
||||
if (!box) throw new Error('Crop box not found')
|
||||
|
||||
const valueBefore = await getCropValue(comfyPage)
|
||||
const valueBefore = await getCropValue(comfyPage, 2)
|
||||
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,
|
||||
...POINTER_OPTS,
|
||||
clientX: startX,
|
||||
clientY: startY
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await cropBox.dispatchEvent('pointermove', {
|
||||
...pointerOpts,
|
||||
...POINTER_OPTS,
|
||||
clientX: startX + 15,
|
||||
clientY: startY + 10
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await cropBox.dispatchEvent('pointermove', {
|
||||
...pointerOpts,
|
||||
...POINTER_OPTS,
|
||||
clientX: startX + 30,
|
||||
clientY: startY + 20
|
||||
})
|
||||
await cropBox.dispatchEvent('pointerup', {
|
||||
...pointerOpts,
|
||||
...POINTER_OPTS,
|
||||
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
|
||||
.poll(
|
||||
async () => {
|
||||
const v = await getCropValue(comfyPage, 2)
|
||||
if (
|
||||
!v ||
|
||||
v.x <= valueBefore.x ||
|
||||
v.y <= valueBefore.y ||
|
||||
v.width !== valueBefore.width ||
|
||||
v.height !== valueBefore.height
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return v
|
||||
},
|
||||
{
|
||||
timeout: 5000,
|
||||
message: 'crop drag should increase x/y without changing size'
|
||||
}
|
||||
)
|
||||
.not.toBeNull()
|
||||
|
||||
await expect(node).toHaveScreenshot('image-crop-after-drag.png', {
|
||||
maxDiffPixelRatio: 0.05
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test('Drag clamps crop box to the right and bottom image edge', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const img = node.locator('img')
|
||||
await waitForImageNaturalSize(img)
|
||||
const { nw, nh } = await img.evaluate((el: HTMLImageElement) => ({
|
||||
nw: el.naturalWidth,
|
||||
nh: el.naturalHeight
|
||||
}))
|
||||
|
||||
await setCropBounds(comfyPage, 2, {
|
||||
x: nw - 100,
|
||||
y: nh - 90,
|
||||
width: 70,
|
||||
height: 70
|
||||
})
|
||||
|
||||
const cropBox = node.getByTestId('crop-overlay')
|
||||
await dragOnLocator(comfyPage, cropBox, 400, 200)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const v = await getCropValue(comfyPage, 2)
|
||||
return v ? v.x + v.width : 0
|
||||
},
|
||||
{ message: 'crop drag should clamp to image right edge' }
|
||||
)
|
||||
.toBeLessThanOrEqual(nw)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const v = await getCropValue(comfyPage, 2)
|
||||
return v ? v.y + v.height : 0
|
||||
},
|
||||
{ message: 'crop drag should clamp to image bottom edge' }
|
||||
)
|
||||
.toBeLessThanOrEqual(nh)
|
||||
})
|
||||
|
||||
test('Drag clamps crop box to the top-left image corner', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await setCropBounds(comfyPage, 2, {
|
||||
x: 8,
|
||||
y: 8,
|
||||
width: 120,
|
||||
height: 100
|
||||
})
|
||||
|
||||
const cropBox = comfyPage.vueNodes
|
||||
.getNodeLocator('2')
|
||||
.getByTestId('crop-overlay')
|
||||
await dragOnLocator(comfyPage, cropBox, -400, -350)
|
||||
|
||||
await expect
|
||||
.poll(async () => (await getCropValue(comfyPage, 2))?.x ?? -1, {
|
||||
message: 'crop drag should clamp x to the image'
|
||||
})
|
||||
.toBeGreaterThanOrEqual(0)
|
||||
await expect
|
||||
.poll(async () => (await getCropValue(comfyPage, 2))?.y ?? -1, {
|
||||
message: 'crop drag should clamp y to the image'
|
||||
})
|
||||
.toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
test('Resize from right edge increases width only', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
await setCropBounds(comfyPage, 2, {
|
||||
x: 40,
|
||||
y: 40,
|
||||
width: 160,
|
||||
height: 120
|
||||
})
|
||||
|
||||
const before = await getCropValue(comfyPage, 2)
|
||||
if (!before) throw new Error('missing crop')
|
||||
|
||||
const handle = node.getByTestId('crop-resize-right')
|
||||
await dragOnLocator(comfyPage, handle, 55, 0)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const v = await getCropValue(comfyPage, 2)
|
||||
if (!v) return false
|
||||
return (
|
||||
v.width > before.width &&
|
||||
v.x === before.x &&
|
||||
v.y === before.y &&
|
||||
v.height === before.height
|
||||
)
|
||||
},
|
||||
{ message: 'right-edge resize should grow width only' }
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('Resize from left edge decreases x and increases width', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
await setCropBounds(comfyPage, 2, {
|
||||
x: 200,
|
||||
y: 100,
|
||||
width: 300,
|
||||
height: 200
|
||||
})
|
||||
|
||||
const before = await getCropValue(comfyPage, 2)
|
||||
if (!before) throw new Error('missing crop')
|
||||
|
||||
await dragOnLocator(
|
||||
comfyPage,
|
||||
node.getByTestId('crop-resize-left'),
|
||||
-50,
|
||||
0
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const v = await getCropValue(comfyPage, 2)
|
||||
if (!v) return false
|
||||
return (
|
||||
v.x < before.x &&
|
||||
v.width > before.width &&
|
||||
v.y === before.y &&
|
||||
v.height === before.height
|
||||
)
|
||||
},
|
||||
{ message: 'left-edge resize should move x and grow width only' }
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('Resize from bottom edge increases height only', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
await setCropBounds(comfyPage, 2, {
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 140,
|
||||
height: 110
|
||||
})
|
||||
|
||||
const before = await getCropValue(comfyPage, 2)
|
||||
if (!before) throw new Error('missing crop')
|
||||
|
||||
await dragOnLocator(
|
||||
comfyPage,
|
||||
node.getByTestId('crop-resize-bottom'),
|
||||
0,
|
||||
50
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const v = await getCropValue(comfyPage, 2)
|
||||
if (!v) return false
|
||||
return (
|
||||
v.height > before.height &&
|
||||
v.x === before.x &&
|
||||
v.y === before.y &&
|
||||
v.width === before.width
|
||||
)
|
||||
},
|
||||
{ message: 'bottom-edge resize should grow height only' }
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('Resize from top edge decreases y and increases height', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
await setCropBounds(comfyPage, 2, {
|
||||
x: 60,
|
||||
y: 120,
|
||||
width: 160,
|
||||
height: 140
|
||||
})
|
||||
|
||||
const before = await getCropValue(comfyPage, 2)
|
||||
if (!before) throw new Error('missing crop')
|
||||
|
||||
await dragOnLocator(
|
||||
comfyPage,
|
||||
node.getByTestId('crop-resize-top'),
|
||||
0,
|
||||
-45
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const v = await getCropValue(comfyPage, 2)
|
||||
if (!v) return false
|
||||
return (
|
||||
v.y < before.y &&
|
||||
v.height > before.height &&
|
||||
v.x === before.x &&
|
||||
v.width === before.width
|
||||
)
|
||||
},
|
||||
{ message: 'top-edge resize should move y and grow height only' }
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test(
|
||||
'Resize from SE corner increases width and height',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
await setCropBounds(comfyPage, 2, {
|
||||
x: 70,
|
||||
y: 80,
|
||||
width: 130,
|
||||
height: 110
|
||||
})
|
||||
|
||||
const before = await getCropValue(comfyPage, 2)
|
||||
if (!before) throw new Error('missing crop')
|
||||
|
||||
await dragOnLocator(
|
||||
comfyPage,
|
||||
node.getByTestId('crop-resize-se'),
|
||||
40,
|
||||
35
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const v = await getCropValue(comfyPage, 2)
|
||||
if (!v) return false
|
||||
return (
|
||||
v.width > before.width &&
|
||||
v.height > before.height &&
|
||||
v.x === before.x &&
|
||||
v.y === before.y
|
||||
)
|
||||
},
|
||||
{ message: 'SE corner resize should grow width and height' }
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(node).toHaveScreenshot('image-crop-resize-se.png', {
|
||||
maxDiffPixelRatio: 0.05
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Resize from NW corner moves top-left and grows box',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
await setCropBounds(comfyPage, 2, {
|
||||
x: 140,
|
||||
y: 130,
|
||||
width: 160,
|
||||
height: 140
|
||||
})
|
||||
|
||||
const before = await getCropValue(comfyPage, 2)
|
||||
if (!before) throw new Error('missing crop')
|
||||
|
||||
await dragOnLocator(
|
||||
comfyPage,
|
||||
node.getByTestId('crop-resize-nw'),
|
||||
-45,
|
||||
-40
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const v = await getCropValue(comfyPage, 2)
|
||||
if (!v) return false
|
||||
return (
|
||||
v.x < before.x &&
|
||||
v.y < before.y &&
|
||||
v.width > before.width &&
|
||||
v.height > before.height
|
||||
)
|
||||
},
|
||||
{ message: 'NW corner resize should move origin and grow box' }
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(node).toHaveScreenshot('image-crop-resize-nw.png', {
|
||||
maxDiffPixelRatio: 0.05
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test('Resize enforces minimum crop dimensions', async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
await setCropBounds(comfyPage, 2, {
|
||||
x: 80,
|
||||
y: 80,
|
||||
width: 50,
|
||||
height: 50
|
||||
})
|
||||
|
||||
await dragOnLocator(
|
||||
comfyPage,
|
||||
node.getByTestId('crop-resize-right'),
|
||||
-200,
|
||||
0
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(async () => (await getCropValue(comfyPage, 2))?.width ?? 0, {
|
||||
message: 'crop width should respect minimum size'
|
||||
})
|
||||
.toBeGreaterThanOrEqual(MIN_CROP_SIZE)
|
||||
await expect
|
||||
.poll(async () => (await getCropValue(comfyPage, 2))?.height ?? 0, {
|
||||
message: 'crop height should respect minimum size'
|
||||
})
|
||||
.toBeGreaterThanOrEqual(MIN_CROP_SIZE)
|
||||
})
|
||||
|
||||
test('Resize clamps to image boundaries on the right and bottom edges', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const img = node.locator('img')
|
||||
await waitForImageNaturalSize(img)
|
||||
const { nw, nh } = await img.evaluate((el: HTMLImageElement) => ({
|
||||
nw: el.naturalWidth,
|
||||
nh: el.naturalHeight
|
||||
}))
|
||||
|
||||
await setCropBounds(comfyPage, 2, {
|
||||
x: nw - 120,
|
||||
y: 40,
|
||||
width: 80,
|
||||
height: 90
|
||||
})
|
||||
|
||||
await dragOnLocator(
|
||||
comfyPage,
|
||||
node.getByTestId('crop-resize-right'),
|
||||
400,
|
||||
0
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const v = await getCropValue(comfyPage, 2)
|
||||
return v ? v.x + v.width : 0
|
||||
},
|
||||
{ message: 'right-edge resize should not extend past image width' }
|
||||
)
|
||||
.toBeLessThanOrEqual(nw)
|
||||
|
||||
await dragOnLocator(
|
||||
comfyPage,
|
||||
node.getByTestId('crop-resize-bottom'),
|
||||
0,
|
||||
400
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const v = await getCropValue(comfyPage, 2)
|
||||
return v ? v.y + v.height : 0
|
||||
},
|
||||
{
|
||||
message: 'bottom-edge resize should not extend past image height'
|
||||
}
|
||||
)
|
||||
.toBeLessThanOrEqual(nh)
|
||||
})
|
||||
|
||||
test(
|
||||
'Eight resize handles are visible when ratio is unlocked',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
await expect(
|
||||
node
|
||||
.locator('[data-testid^="crop-resize-"]')
|
||||
.filter({ visible: true }),
|
||||
'unlocked ratio should expose edge and corner handles'
|
||||
).toHaveCount(8)
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(node).toHaveScreenshot('image-crop-eight-handles.png', {
|
||||
maxDiffPixelRatio: 0.05
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test('Four corner resize handles are visible when aspect ratio is locked', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
await node.getByRole('button', { name: 'Lock aspect ratio' }).click()
|
||||
await expect(
|
||||
node
|
||||
.locator('[data-testid^="crop-resize-"]')
|
||||
.filter({ visible: true }),
|
||||
'locked ratio should only show corner handles'
|
||||
).toHaveCount(4)
|
||||
})
|
||||
|
||||
test('Resize with locked aspect ratio keeps width and height proportional', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
await setCropBounds(comfyPage, 2, {
|
||||
x: 70,
|
||||
y: 80,
|
||||
width: 160,
|
||||
height: 120
|
||||
})
|
||||
await node.getByRole('button', { name: 'Lock aspect ratio' }).click()
|
||||
|
||||
const before = await getCropValue(comfyPage, 2)
|
||||
if (!before) throw new Error('missing crop')
|
||||
|
||||
const ratio = before.width / before.height
|
||||
|
||||
await dragOnLocator(
|
||||
comfyPage,
|
||||
node.getByTestId('crop-resize-se'),
|
||||
48,
|
||||
36
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const v = await getCropValue(comfyPage, 2)
|
||||
if (!v || v.width <= before.width || v.height <= before.height) {
|
||||
return null
|
||||
}
|
||||
const r = v.width / v.height
|
||||
if (Math.abs(r - ratio) > 0.06) return null
|
||||
return v
|
||||
},
|
||||
{ message: 'locked ratio resize should preserve aspect ratio' }
|
||||
)
|
||||
.not.toBeNull()
|
||||
})
|
||||
|
||||
test('Broken image URL resets widget to empty state', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const img = node.locator('img')
|
||||
await waitForImageNaturalSize(img)
|
||||
|
||||
let failExamplePng = false
|
||||
await comfyPage.page.route('**/api/view**', async (route) => {
|
||||
const u = route.request().url()
|
||||
if (failExamplePng && u.includes('example.png')) {
|
||||
await route.abort('failed')
|
||||
return
|
||||
}
|
||||
await route.continue()
|
||||
})
|
||||
try {
|
||||
failExamplePng = true
|
||||
const runDone = comfyPage.runButton.click()
|
||||
await expect(
|
||||
node.getByTestId('crop-empty-state'),
|
||||
'failed view fetch should show empty state'
|
||||
).toBeVisible({ timeout: 15_000 })
|
||||
await expect(
|
||||
node.getByTestId('crop-overlay'),
|
||||
'crop overlay should hide when image fails'
|
||||
).toHaveCount(0)
|
||||
await runDone
|
||||
} finally {
|
||||
await comfyPage.page.unroute('**/api/view**')
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test.describe(
|
||||
'with source image (slow view)',
|
||||
{ tag: ['@widget', '@slow'] },
|
||||
() => {
|
||||
test('Shows loading text while the view image is delayed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Slow only example.png view fetches — simulates network latency for the
|
||||
// loading overlay. Delay lives in the route handler (not
|
||||
// page.waitForTimeout in the test body).
|
||||
await comfyPage.page.route('**/api/view**', async (route) => {
|
||||
const url = route.request().url()
|
||||
if (!url.includes('example.png')) {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 500)
|
||||
})
|
||||
await route.continue()
|
||||
})
|
||||
|
||||
try {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'widgets/image_crop_with_source'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const runDone = comfyPage.runButton.click()
|
||||
await expect(
|
||||
node.getByText('Loading...'),
|
||||
'delayed view should show loading overlay'
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
await runDone
|
||||
|
||||
const img = node.locator('img')
|
||||
await waitForImageNaturalSize(img, { timeout: 30_000 })
|
||||
|
||||
await expect(
|
||||
node.getByText('Loading...'),
|
||||
'loading overlay should hide after delayed image loads'
|
||||
).toBeHidden()
|
||||
} finally {
|
||||
await comfyPage.page.unroute('**/api/view**')
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -10,12 +10,8 @@
|
||||
ref="containerEl"
|
||||
class="relative min-h-0 flex-1 overflow-hidden rounded-[5px] bg-node-component-surface"
|
||||
>
|
||||
<div v-if="isLoading" class="flex size-full items-center justify-center">
|
||||
<span class="text-sm">{{ $t('imageCrop.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!imageUrl"
|
||||
v-if="!imageUrl"
|
||||
class="flex size-full flex-col items-center justify-center text-center"
|
||||
data-testid="crop-empty-state"
|
||||
>
|
||||
@@ -26,48 +22,58 @@
|
||||
<p class="text-sm">{{ $t('imageCrop.noInputImage') }}</p>
|
||||
</div>
|
||||
|
||||
<img
|
||||
v-else
|
||||
ref="imageEl"
|
||||
:src="imageUrl"
|
||||
:alt="$t('imageCrop.cropPreviewAlt')"
|
||||
draggable="false"
|
||||
class="block size-full object-contain select-none"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
@dragstart.prevent
|
||||
/>
|
||||
<template v-else>
|
||||
<img
|
||||
ref="imageEl"
|
||||
:src="imageUrl"
|
||||
:alt="$t('imageCrop.cropPreviewAlt')"
|
||||
draggable="false"
|
||||
class="block size-full object-contain select-none"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
@dragstart.prevent
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="imageUrl && !isLoading"
|
||||
:class="
|
||||
cn(
|
||||
'absolute box-content cursor-move border-2 border-white shadow-[0_0_0_9999px_rgba(0,0,0,0.5)]',
|
||||
isDisabled && 'pointer-events-none opacity-60'
|
||||
)
|
||||
"
|
||||
:style="cropBoxStyle"
|
||||
data-testid="crop-overlay"
|
||||
@pointerdown="handleDragStart"
|
||||
@pointermove="handleDragMove"
|
||||
@pointerup="handleDragEnd"
|
||||
/>
|
||||
|
||||
<template v-for="handle in resizeHandles" :key="handle.direction">
|
||||
<div
|
||||
v-show="imageUrl && !isLoading"
|
||||
v-if="isLoading"
|
||||
aria-live="polite"
|
||||
class="absolute inset-0 z-10 flex size-full items-center justify-center bg-node-component-surface/90"
|
||||
>
|
||||
<span class="text-sm">{{ $t('imageCrop.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isLoading"
|
||||
:class="
|
||||
cn(
|
||||
'absolute',
|
||||
handle.class,
|
||||
'absolute box-content cursor-move border-2 border-white shadow-[0_0_0_9999px_rgba(0,0,0,0.5)]',
|
||||
isDisabled && 'pointer-events-none opacity-60'
|
||||
)
|
||||
"
|
||||
:style="handle.style"
|
||||
@pointerdown="(e) => handleResizeStart(e, handle.direction)"
|
||||
@pointermove="handleResizeMove"
|
||||
@pointerup="handleResizeEnd"
|
||||
:style="cropBoxStyle"
|
||||
data-testid="crop-overlay"
|
||||
@pointerdown="handleDragStart"
|
||||
@pointermove="handleDragMove"
|
||||
@pointerup="handleDragEnd"
|
||||
/>
|
||||
|
||||
<template v-for="handle in resizeHandles" :key="handle.direction">
|
||||
<div
|
||||
v-show="!isLoading"
|
||||
:data-testid="`crop-resize-${handle.direction}`"
|
||||
:class="
|
||||
cn(
|
||||
'absolute',
|
||||
handle.class,
|
||||
isDisabled && 'pointer-events-none opacity-60'
|
||||
)
|
||||
"
|
||||
:style="handle.style"
|
||||
@pointerdown="(e) => handleResizeStart(e, handle.direction)"
|
||||
@pointermove="handleResizeMove"
|
||||
@pointerup="handleResizeEnd"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
|
||||
25
src/composables/useImageCrop.test.ts
Normal file
25
src/composables/useImageCrop.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { imageCropLoadingAfterUrlChange } from '@/composables/useImageCrop'
|
||||
|
||||
describe('imageCropLoadingAfterUrlChange', () => {
|
||||
it('clears loading when url becomes null', () => {
|
||||
expect(imageCropLoadingAfterUrlChange(null, 'https://a/b.png')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps loading off when url stays null', () => {
|
||||
expect(imageCropLoadingAfterUrlChange(null, null)).toBe(false)
|
||||
})
|
||||
|
||||
it('starts loading when url changes to a new string', () => {
|
||||
expect(imageCropLoadingAfterUrlChange('https://b', 'https://a')).toBe(true)
|
||||
})
|
||||
|
||||
it('starts loading when first url is set', () => {
|
||||
expect(imageCropLoadingAfterUrlChange('https://a', undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns null when url is unchanged so caller can skip updating', () => {
|
||||
expect(imageCropLoadingAfterUrlChange('https://a', 'https://a')).toBe(null)
|
||||
})
|
||||
})
|
||||
@@ -19,9 +19,23 @@ type ResizeDirection =
|
||||
|
||||
const HANDLE_SIZE = 8
|
||||
const CORNER_SIZE = 10
|
||||
const MIN_CROP_SIZE = 16
|
||||
/** Minimum crop width/height in source image pixel space. */
|
||||
export const MIN_CROP_SIZE = 16
|
||||
const CROP_BOX_BORDER = 2
|
||||
|
||||
/**
|
||||
* Next `isLoading` when `imageUrl` transitions. `null` means do not change
|
||||
* `isLoading` (e.g. same URL).
|
||||
*/
|
||||
export function imageCropLoadingAfterUrlChange(
|
||||
url: string | null,
|
||||
previous: string | null | undefined
|
||||
): boolean | null {
|
||||
if (url == null) return false
|
||||
if (url !== previous) return true
|
||||
return null
|
||||
}
|
||||
|
||||
export const ASPECT_RATIOS = {
|
||||
'1:1': 1,
|
||||
'3:4': 3 / 4,
|
||||
@@ -179,6 +193,13 @@ export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
|
||||
imageUrl.value = getInputImageUrl()
|
||||
}
|
||||
|
||||
watch(imageUrl, (url, previous) => {
|
||||
const next = imageCropLoadingAfterUrlChange(url, previous)
|
||||
if (next !== null) {
|
||||
isLoading.value = next
|
||||
}
|
||||
})
|
||||
|
||||
const updateDisplayedDimensions = () => {
|
||||
if (!imageEl.value || !containerEl.value) return
|
||||
|
||||
|
||||
Reference in New Issue
Block a user