mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
test: add e2e coverage for image crop, curve widget, minimap, mask editor, painter
New specs: - imageCrop.spec.ts: 6 tests (empty state, bounding box, ratio selector, lock toggle, presets, programmatic update) - curveWidget.spec.ts: 6 tests (render, add/remove/drag points, interpolation mode, min-points guard) Deepened existing specs: - minimap.spec.ts: +6 tests (click-to-pan, drag-to-pan, zoom viewport, node changes, workflow reload, pan state) - maskEditor.spec.ts: +10 tests (brush drawing, undo/redo, clear, cancel, invert, keyboard undo, tools, settings, save, eraser) - painter.spec.ts: +11 tests (clear, eraser, control visibility, brush size, stroke widths, canvas controls, background, multi-stroke, color picker, opacity, partial erase)
This commit is contained in:
56
browser_tests/assets/widgets/curve_widget.json
Normal file
56
browser_tests/assets/widgets/curve_widget.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CurveEditor",
|
||||
"pos": [50, 50],
|
||||
"size": [400, 500],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "curve",
|
||||
"type": "CURVE",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "histogram",
|
||||
"type": "HISTOGRAM",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "curve",
|
||||
"type": "CURVE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CurveEditor"
|
||||
},
|
||||
"widgets_values": [
|
||||
{
|
||||
"points": [
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
],
|
||||
"interpolation": "monotone_cubic"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
"id": 1,
|
||||
"type": "ImageCropV2",
|
||||
"pos": [50, 50],
|
||||
"size": [400, 500],
|
||||
"size": [400, 550],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
@@ -27,14 +27,7 @@
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageCropV2"
|
||||
},
|
||||
"widgets_values": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 512,
|
||||
"height": 512
|
||||
}
|
||||
]
|
||||
"widgets_values": [{ "x": 0, "y": 0, "width": 512, "height": 512 }]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
|
||||
171
browser_tests/tests/curveWidget.spec.ts
Normal file
171
browser_tests/tests/curveWidget.spec.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Curve Widget', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('widgets/curve_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
function getCurveWidgetLocators(comfyPage: ComfyPage) {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const svg = node.locator('svg')
|
||||
const curvePath = node.getByTestId("curve-path")
|
||||
const controlPoints = svg.locator('circle')
|
||||
return { node, svg, curvePath, controlPoints }
|
||||
}
|
||||
|
||||
test(
|
||||
'Renders SVG editor with default control points and curve path',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const { node, svg, curvePath, controlPoints } =
|
||||
getCurveWidgetLocators(comfyPage)
|
||||
|
||||
await expect(node).toBeVisible()
|
||||
await expect(svg).toBeVisible()
|
||||
await expect(curvePath).toBeVisible()
|
||||
await expect(controlPoints).toHaveCount(2)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Clicking on SVG adds a new control point',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const { svg, controlPoints } = getCurveWidgetLocators(comfyPage)
|
||||
await expect(controlPoints).toHaveCount(2)
|
||||
|
||||
const box = await svg.boundingBox()
|
||||
if (!box) throw new Error('SVG bounding box not found')
|
||||
|
||||
await comfyPage.page.mouse.click(
|
||||
box.x + box.width * 0.5,
|
||||
box.y + box.height * 0.5
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(controlPoints).toHaveCount(3)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Dragging a control point reshapes the curve',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const { svg, curvePath } = getCurveWidgetLocators(comfyPage)
|
||||
|
||||
const pathBefore = await curvePath.getAttribute('d')
|
||||
|
||||
const box = await svg.boundingBox()
|
||||
if (!box) throw new Error('SVG bounding box not found')
|
||||
|
||||
await comfyPage.page.mouse.click(
|
||||
box.x + box.width * 0.5,
|
||||
box.y + box.height * 0.5
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newPoint = svg.locator('circle').nth(1)
|
||||
const pointBox = await newPoint.boundingBox()
|
||||
if (!pointBox) throw new Error('Control point bounding box not found')
|
||||
|
||||
const startX = pointBox.x + pointBox.width / 2
|
||||
const startY = pointBox.y + pointBox.height / 2
|
||||
|
||||
await comfyPage.page.mouse.move(startX, startY)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(startX, startY - 40, { steps: 5 })
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => curvePath.getAttribute('d')).not.toBe(pathBefore)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Ctrl+clicking a control point removes it',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const { svg, controlPoints } = getCurveWidgetLocators(comfyPage)
|
||||
|
||||
const box = await svg.boundingBox()
|
||||
if (!box) throw new Error('SVG bounding box not found')
|
||||
|
||||
await comfyPage.page.mouse.click(
|
||||
box.x + box.width * 0.5,
|
||||
box.y + box.height * 0.5
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(controlPoints).toHaveCount(3)
|
||||
|
||||
const middlePoint = controlPoints.nth(1)
|
||||
const pointBox = await middlePoint.boundingBox()
|
||||
if (!pointBox) throw new Error('Control point bounding box not found')
|
||||
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.mouse.click(
|
||||
pointBox.x + pointBox.width / 2,
|
||||
pointBox.y + pointBox.height / 2
|
||||
)
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(controlPoints).toHaveCount(2)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Switching interpolation mode changes curve path',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const { svg, curvePath, controlPoints } =
|
||||
getCurveWidgetLocators(comfyPage)
|
||||
|
||||
const box = await svg.boundingBox()
|
||||
if (!box) throw new Error('SVG bounding box not found')
|
||||
await comfyPage.page.mouse.click(
|
||||
box.x + box.width * 0.5,
|
||||
box.y + box.height * 0.5
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(controlPoints).toHaveCount(3)
|
||||
|
||||
const pathBefore = await curvePath.getAttribute('d')
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const select = node.getByRole("combobox")
|
||||
await select.click()
|
||||
await comfyPage.page.getByRole('option', { name: /linear/i }).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => curvePath.getAttribute('d')).not.toBe(pathBefore)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Cannot remove points below minimum of two',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const { controlPoints } = getCurveWidgetLocators(comfyPage)
|
||||
await expect(controlPoints).toHaveCount(2)
|
||||
|
||||
const firstPoint = controlPoints.first()
|
||||
const pointBox = await firstPoint.boundingBox()
|
||||
if (!pointBox) throw new Error('Control point bounding box not found')
|
||||
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.mouse.click(
|
||||
pointBox.x + pointBox.width / 2,
|
||||
pointBox.y + pointBox.height / 2
|
||||
)
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(controlPoints).toHaveCount(2)
|
||||
}
|
||||
)
|
||||
})
|
||||
130
browser_tests/tests/imageCrop.spec.ts
Normal file
130
browser_tests/tests/imageCrop.spec.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
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 connected',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
await expect(node.getByText('No input image connected')).toBeVisible()
|
||||
await expect(node.locator('img[alt="Crop preview"]')).toHaveCount(0)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Renders bounding box coordinate inputs',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
await expect(node.getByText('X')).toBeVisible()
|
||||
await expect(node.getByText('Y')).toBeVisible()
|
||||
await expect(node.getByText('Width')).toBeVisible()
|
||||
await expect(node.getByText('Height')).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Renders ratio selector and lock button',
|
||||
{ tag: '@ui' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
await expect(node.getByText('Ratio')).toBeVisible()
|
||||
await expect(node.getByRole('button', { name: /lock/i })).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Lock button toggles aspect ratio lock',
|
||||
{ tag: '@ui' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
|
||||
const lockButton = node.getByRole('button', {
|
||||
name: 'Lock aspect ratio'
|
||||
})
|
||||
await expect(lockButton).toBeVisible()
|
||||
|
||||
await lockButton.click()
|
||||
await expect(
|
||||
node.getByRole('button', { name: 'Unlock aspect ratio' })
|
||||
).toBeVisible()
|
||||
|
||||
await node.getByRole('button', { name: 'Unlock aspect ratio' }).click()
|
||||
await expect(
|
||||
node.getByRole('button', { name: 'Lock aspect ratio' })
|
||||
).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Ratio selector offers expected presets',
|
||||
{ tag: '@ui' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
|
||||
const trigger = node.getByRole("combobox")
|
||||
await trigger.click()
|
||||
|
||||
const expectedRatios = ['1:1', '3:4', '4:3', '16:9', '9:16', 'Custom']
|
||||
for (const label of expectedRatios) {
|
||||
await expect(
|
||||
comfyPage.page.getByRole('option', { name: label, exact: true })
|
||||
).toBeVisible()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Programmatically setting widget value updates bounding box inputs',
|
||||
{ tag: '@ui' },
|
||||
async ({ comfyPage }) => {
|
||||
const newBounds = { x: 50, y: 100, width: 200, height: 300 }
|
||||
|
||||
await comfyPage.page.evaluate(
|
||||
({ bounds }) => {
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
const widget = node?.widgets?.find((w) => w.type === 'imagecrop')
|
||||
if (widget) {
|
||||
widget.value = bounds
|
||||
widget.callback?.(bounds)
|
||||
}
|
||||
},
|
||||
{ bounds: newBounds }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const inputs = node.locator('input[inputmode="decimal"]')
|
||||
|
||||
await expect
|
||||
.poll(async () => inputs.nth(0).inputValue(), { timeout: 5000 })
|
||||
.toBe('50')
|
||||
|
||||
await expect
|
||||
.poll(async () => inputs.nth(1).inputValue(), { timeout: 5000 })
|
||||
.toBe('100')
|
||||
|
||||
await expect
|
||||
.poll(async () => inputs.nth(2).inputValue(), { timeout: 5000 })
|
||||
.toBe('200')
|
||||
|
||||
await expect
|
||||
.poll(async () => inputs.nth(3).inputValue(), { timeout: 5000 })
|
||||
.toBe('300')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
@@ -32,6 +33,69 @@ test.describe('Mask Editor', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function openMaskEditorDialog(comfyPage: ComfyPage) {
|
||||
const { imagePreview } = await loadImageOnNode(comfyPage)
|
||||
|
||||
await imagePreview.getByRole('region').hover()
|
||||
await comfyPage.page.getByLabel('Edit or mask image').click()
|
||||
|
||||
const dialog = comfyPage.page.locator('.mask-editor-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole('heading', { name: 'Mask Editor' })
|
||||
).toBeVisible()
|
||||
|
||||
const canvasContainer = dialog.locator('#maskEditorCanvasContainer')
|
||||
await expect(canvasContainer).toBeVisible()
|
||||
await expect(canvasContainer.locator('canvas')).toHaveCount(4)
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
async function getMaskCanvasPixelData(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const canvases = document.querySelectorAll(
|
||||
'#maskEditorCanvasContainer canvas'
|
||||
)
|
||||
// The mask canvas is the 3rd canvas (index 2, z-30)
|
||||
const maskCanvas = canvases[2] as HTMLCanvasElement
|
||||
if (!maskCanvas) return null
|
||||
const ctx = maskCanvas.getContext('2d')
|
||||
if (!ctx) return null
|
||||
const data = ctx.getImageData(0, 0, maskCanvas.width, maskCanvas.height)
|
||||
let nonTransparentPixels = 0
|
||||
for (let i = 3; i < data.data.length; i += 4) {
|
||||
if (data.data[i] > 0) nonTransparentPixels++
|
||||
}
|
||||
return { nonTransparentPixels, totalPixels: data.data.length / 4 }
|
||||
})
|
||||
}
|
||||
|
||||
async function drawStrokeOnPointerZone(
|
||||
page: Page,
|
||||
dialog: ReturnType<typeof page.locator>
|
||||
) {
|
||||
const pointerZone = dialog.locator(
|
||||
'.maskEditor-ui-container [class*="w-[calc"]'
|
||||
)
|
||||
await expect(pointerZone).toBeVisible()
|
||||
|
||||
const box = await pointerZone.boundingBox()
|
||||
if (!box) throw new Error('Pointer zone bounding box not found')
|
||||
|
||||
const startX = box.x + box.width * 0.3
|
||||
const startY = box.y + box.height * 0.5
|
||||
const endX = box.x + box.width * 0.7
|
||||
const endY = box.y + box.height * 0.5
|
||||
|
||||
await page.mouse.move(startX, startY)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(endX, endY, { steps: 10 })
|
||||
await page.mouse.up()
|
||||
|
||||
return { startX, startY, endX, endY, box }
|
||||
}
|
||||
|
||||
test(
|
||||
'opens mask editor from image preview button',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
@@ -89,4 +153,331 @@ test.describe('Mask Editor', () => {
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('draws a brush stroke on the mask canvas', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
const dataBefore = await getMaskCanvasPixelData(comfyPage.page)
|
||||
expect(dataBefore).not.toBeNull()
|
||||
expect(dataBefore!.nonTransparentPixels).toBe(0)
|
||||
|
||||
await drawStrokeOnPointerZone(comfyPage.page, dialog)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const dataAfter = await getMaskCanvasPixelData(comfyPage.page)
|
||||
return dataAfter?.nonTransparentPixels ?? 0
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('undo reverts a brush stroke', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
await drawStrokeOnPointerZone(comfyPage.page, dialog)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await getMaskCanvasPixelData(comfyPage.page)
|
||||
return data?.nonTransparentPixels ?? 0
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
const undoButton = dialog.locator('button[title="Undo"]')
|
||||
await expect(undoButton).toBeVisible()
|
||||
await undoButton.click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await getMaskCanvasPixelData(comfyPage.page)
|
||||
return data?.nonTransparentPixels ?? 0
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
.toBe(0)
|
||||
})
|
||||
|
||||
test('redo restores an undone stroke', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
await drawStrokeOnPointerZone(comfyPage.page, dialog)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await getMaskCanvasPixelData(comfyPage.page)
|
||||
return data?.nonTransparentPixels ?? 0
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
const undoButton = dialog.locator('button[title="Undo"]')
|
||||
await undoButton.click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await getMaskCanvasPixelData(comfyPage.page)
|
||||
return data?.nonTransparentPixels ?? 0
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
.toBe(0)
|
||||
|
||||
const redoButton = dialog.locator('button[title="Redo"]')
|
||||
await expect(redoButton).toBeVisible()
|
||||
await redoButton.click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await getMaskCanvasPixelData(comfyPage.page)
|
||||
return data?.nonTransparentPixels ?? 0
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('clear button removes all mask content', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
await drawStrokeOnPointerZone(comfyPage.page, dialog)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await getMaskCanvasPixelData(comfyPage.page)
|
||||
return data?.nonTransparentPixels ?? 0
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
const clearButton = dialog.getByRole('button', { name: 'Clear' })
|
||||
await expect(clearButton).toBeVisible()
|
||||
await clearButton.click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await getMaskCanvasPixelData(comfyPage.page)
|
||||
return data?.nonTransparentPixels ?? 0
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
.toBe(0)
|
||||
})
|
||||
|
||||
test('cancel closes the dialog without saving', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
await drawStrokeOnPointerZone(comfyPage.page, dialog)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await getMaskCanvasPixelData(comfyPage.page)
|
||||
return data?.nonTransparentPixels ?? 0
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
const cancelButton = dialog.getByRole('button', { name: 'Cancel' })
|
||||
await cancelButton.click()
|
||||
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
|
||||
test('invert button inverts the mask', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
const dataBefore = await getMaskCanvasPixelData(comfyPage.page)
|
||||
expect(dataBefore).not.toBeNull()
|
||||
const pixelsBefore = dataBefore!.nonTransparentPixels
|
||||
|
||||
const invertButton = dialog.getByRole('button', { name: 'Invert' })
|
||||
await expect(invertButton).toBeVisible()
|
||||
await invertButton.click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const dataAfter = await getMaskCanvasPixelData(comfyPage.page)
|
||||
return dataAfter?.nonTransparentPixels ?? 0
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
.toBeGreaterThan(pixelsBefore)
|
||||
})
|
||||
|
||||
test('keyboard shortcut Ctrl+Z triggers undo', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
await drawStrokeOnPointerZone(comfyPage.page, dialog)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await getMaskCanvasPixelData(comfyPage.page)
|
||||
return data?.nonTransparentPixels ?? 0
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
const modifier = process.platform === 'darwin' ? 'Meta+z' : 'Control+z'
|
||||
await comfyPage.page.keyboard.press(modifier)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await getMaskCanvasPixelData(comfyPage.page)
|
||||
return data?.nonTransparentPixels ?? 0
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
.toBe(0)
|
||||
})
|
||||
|
||||
test(
|
||||
'tool panel shows all five tools',
|
||||
{ tag: ['@smoke'] },
|
||||
async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
const toolPanel = dialog.locator('.maskEditor-ui-container')
|
||||
await expect(toolPanel).toBeVisible()
|
||||
|
||||
// The tool panel should contain exactly 5 tool entries
|
||||
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
|
||||
await expect(toolEntries).toHaveCount(5)
|
||||
|
||||
// First tool (MaskPen) should be selected by default
|
||||
const selectedTool = dialog.locator(
|
||||
'.maskEditor_toolPanelContainerSelected'
|
||||
)
|
||||
await expect(selectedTool).toHaveCount(1)
|
||||
}
|
||||
)
|
||||
|
||||
test('switching tools updates the selected indicator', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
|
||||
await expect(toolEntries).toHaveCount(5)
|
||||
|
||||
// Click the third tool (Eraser, index 2)
|
||||
await toolEntries.nth(2).click()
|
||||
|
||||
// The third tool should now be selected
|
||||
const selectedTool = dialog.locator(
|
||||
'.maskEditor_toolPanelContainerSelected'
|
||||
)
|
||||
await expect(selectedTool).toHaveCount(1)
|
||||
|
||||
// Verify it's the eraser (3rd entry)
|
||||
await expect(toolEntries.nth(2)).toHaveClass(/Selected/)
|
||||
})
|
||||
|
||||
test('brush settings panel is visible with thickness controls', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
// The side panel should show brush settings by default
|
||||
const thicknessLabel = dialog.getByText('Thickness')
|
||||
await expect(thicknessLabel).toBeVisible()
|
||||
|
||||
const opacityLabel = dialog.getByText('Opacity')
|
||||
await expect(opacityLabel).toBeVisible()
|
||||
|
||||
const hardnessLabel = dialog.getByText('Hardness')
|
||||
await expect(hardnessLabel).toBeVisible()
|
||||
})
|
||||
|
||||
test('save button triggers save and closes dialog', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
// Mock the upload endpoints so save doesn't fail
|
||||
await comfyPage.page.route('**/upload/mask', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
name: 'test-mask.png',
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
})
|
||||
})
|
||||
)
|
||||
await comfyPage.page.route('**/upload/image', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
name: 'test-image.png',
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await expect(saveButton).toBeVisible()
|
||||
await saveButton.click()
|
||||
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
|
||||
test(
|
||||
'eraser tool removes mask content',
|
||||
{ tag: ['@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
// Draw a stroke with the mask pen (default tool)
|
||||
await drawStrokeOnPointerZone(comfyPage.page, dialog)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await getMaskCanvasPixelData(comfyPage.page)
|
||||
return data?.nonTransparentPixels ?? 0
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
const pixelsAfterDraw = await getMaskCanvasPixelData(comfyPage.page)
|
||||
|
||||
// Switch to eraser tool (3rd tool, index 2)
|
||||
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
|
||||
await toolEntries.nth(2).click()
|
||||
|
||||
// Draw over the same area with the eraser
|
||||
await drawStrokeOnPointerZone(comfyPage.page, dialog)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await getMaskCanvasPixelData(comfyPage.page)
|
||||
return data?.nonTransparentPixels ?? 0
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
.toBeLessThan(pixelsAfterDraw!.nonTransparentPixels)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,38 +1,8 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
function hasCanvasContent(canvas: Locator): Promise<boolean> {
|
||||
return canvas.evaluate((el: HTMLCanvasElement) => {
|
||||
const ctx = el.getContext('2d')
|
||||
if (!ctx) return false
|
||||
const { data } = ctx.getImageData(0, 0, el.width, el.height)
|
||||
for (let i = 3; i < data.length; i += 4) {
|
||||
if (data[i] > 0) return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
async function clickMinimapAt(
|
||||
overlay: Locator,
|
||||
page: Page,
|
||||
relX: number,
|
||||
relY: number
|
||||
) {
|
||||
const box = await overlay.boundingBox()
|
||||
expect(box, 'Minimap interaction overlay not found').toBeTruthy()
|
||||
|
||||
// Click area — avoiding the settings button (top-left, 32×32px)
|
||||
// and close button (top-right, 32×32px)
|
||||
await page.mouse.click(
|
||||
box!.x + box!.width * relX,
|
||||
box!.y + box!.height * relY
|
||||
)
|
||||
}
|
||||
|
||||
test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
@@ -43,20 +13,14 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
})
|
||||
|
||||
test('Validate minimap is visible by default', async ({ comfyPage }) => {
|
||||
const minimapContainer = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
|
||||
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
|
||||
const minimapCanvas = minimapContainer.getByTestId(
|
||||
TestIds.canvas.minimapCanvas
|
||||
)
|
||||
const minimapCanvas = minimapContainer.locator('.minimap-canvas')
|
||||
await expect(minimapCanvas).toBeVisible()
|
||||
|
||||
const minimapViewport = minimapContainer.getByTestId(
|
||||
TestIds.canvas.minimapViewport
|
||||
)
|
||||
const minimapViewport = minimapContainer.locator('.minimap-viewport')
|
||||
await expect(minimapViewport).toBeVisible()
|
||||
|
||||
await expect(minimapContainer).toHaveCSS('position', 'relative')
|
||||
@@ -76,16 +40,12 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
|
||||
await expect(toggleButton).toBeVisible()
|
||||
|
||||
const minimapContainer = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
})
|
||||
|
||||
test('Validate minimap can be toggled off and on', async ({ comfyPage }) => {
|
||||
const minimapContainer = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
|
||||
const toggleButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.toggleMinimapButton
|
||||
)
|
||||
@@ -93,28 +53,34 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
|
||||
await toggleButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(minimapContainer).toBeHidden()
|
||||
|
||||
await toggleButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
})
|
||||
|
||||
test('Validate minimap keyboard shortcut Alt+M', async ({ comfyPage }) => {
|
||||
const minimapContainer = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
|
||||
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Alt+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(minimapContainer).toBeHidden()
|
||||
|
||||
await comfyPage.page.keyboard.press('Alt+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
})
|
||||
|
||||
test('Close button hides minimap', async ({ comfyPage }) => {
|
||||
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
|
||||
const minimap = comfyPage.page.locator('.litegraph-minimap')
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
await comfyPage.page.getByTestId(TestIds.canvas.closeMinimapButton).click()
|
||||
@@ -130,9 +96,7 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
'Panning canvas moves minimap viewport',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const minimap = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
const minimap = comfyPage.page.locator('.litegraph-minimap')
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
await expect(minimap).toHaveScreenshot('minimap-before-pan.png')
|
||||
@@ -144,156 +108,209 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
canvas.ds.offset[1] = -600
|
||||
canvas.setDirty(true, true)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await expect(minimap).toHaveScreenshot('minimap-after-pan.png')
|
||||
}
|
||||
)
|
||||
|
||||
test('Minimap canvas is non-empty for a workflow with nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const minimapCanvas = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapCanvas
|
||||
)
|
||||
await expect(minimapCanvas).toBeVisible()
|
||||
|
||||
await expect.poll(() => hasCanvasContent(minimapCanvas)).toBe(true)
|
||||
})
|
||||
|
||||
test('Minimap canvas is empty after all nodes are deleted', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const minimapCanvas = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapCanvas
|
||||
)
|
||||
await expect(minimapCanvas).toBeVisible()
|
||||
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.vueNodes.deleteSelected()
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
await expect.poll(() => hasCanvasContent(minimapCanvas)).toBe(false)
|
||||
})
|
||||
|
||||
test('Clicking minimap corner pans the main canvas', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
|
||||
const viewport = minimap.getByTestId(TestIds.canvas.minimapViewport)
|
||||
const overlay = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapInteractionOverlay
|
||||
)
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
const before = await comfyPage.page.evaluate(() => ({
|
||||
x: window.app!.canvas.ds.offset[0],
|
||||
y: window.app!.canvas.ds.offset[1]
|
||||
}))
|
||||
|
||||
const transformBefore = await viewport.evaluate(
|
||||
(el: HTMLElement) => el.style.transform
|
||||
)
|
||||
|
||||
await clickMinimapAt(overlay, comfyPage.page, 0.15, 0.85)
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => ({
|
||||
x: window.app!.canvas.ds.offset[0],
|
||||
y: window.app!.canvas.ds.offset[1]
|
||||
}))
|
||||
)
|
||||
.not.toStrictEqual(before)
|
||||
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((el: HTMLElement) => el.style.transform))
|
||||
.not.toBe(transformBefore)
|
||||
})
|
||||
|
||||
test('Clicking minimap center after FitView causes minimal canvas movement', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
|
||||
const overlay = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapInteractionOverlay
|
||||
)
|
||||
const viewport = minimap.getByTestId(TestIds.canvas.minimapViewport)
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
canvas.ds.offset[0] -= 1000
|
||||
canvas.setDirty(true, true)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const transformBefore = await viewport.evaluate(
|
||||
(el: HTMLElement) => el.style.transform
|
||||
)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.canvas.fitViewToSelectionAnimated({ duration: 1 })
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((el: HTMLElement) => el.style.transform), {
|
||||
timeout: 2000
|
||||
})
|
||||
.not.toBe(transformBefore)
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const before = await comfyPage.page.evaluate(() => ({
|
||||
x: window.app!.canvas.ds.offset[0],
|
||||
y: window.app!.canvas.ds.offset[1]
|
||||
}))
|
||||
|
||||
await clickMinimapAt(overlay, comfyPage.page, 0.5, 0.5)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const after = await comfyPage.page.evaluate(() => ({
|
||||
x: window.app!.canvas.ds.offset[0],
|
||||
y: window.app!.canvas.ds.offset[1]
|
||||
}))
|
||||
|
||||
// ~3px overlay error × ~15 canvas/minimap scale ≈ 45, rounded up
|
||||
const TOLERANCE = 50
|
||||
expect(
|
||||
Math.abs(after.x - before.x),
|
||||
`offset.x changed by more than ${TOLERANCE} after clicking minimap center post-FitView`
|
||||
).toBeLessThan(TOLERANCE)
|
||||
expect(
|
||||
Math.abs(after.y - before.y),
|
||||
`offset.y changed by more than ${TOLERANCE} after clicking minimap center post-FitView`
|
||||
).toBeLessThan(TOLERANCE)
|
||||
})
|
||||
|
||||
test(
|
||||
'Viewport rectangle is visible and positioned within minimap',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const minimap = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
const minimap = comfyPage.page.locator('.litegraph-minimap')
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
const viewport = minimap.getByTestId(TestIds.canvas.minimapViewport)
|
||||
const viewport = minimap.locator('.minimap-viewport')
|
||||
await expect(viewport).toBeVisible()
|
||||
|
||||
await expect(async () => {
|
||||
const vb = await viewport.boundingBox()
|
||||
const mb = await minimap.boundingBox()
|
||||
expect(vb).toBeTruthy()
|
||||
expect(mb).toBeTruthy()
|
||||
expect(vb!.width).toBeGreaterThan(0)
|
||||
expect(vb!.height).toBeGreaterThan(0)
|
||||
expect(vb!.x).toBeGreaterThanOrEqual(mb!.x)
|
||||
expect(vb!.y).toBeGreaterThanOrEqual(mb!.y)
|
||||
expect(vb!.x + vb!.width).toBeLessThanOrEqual(mb!.x + mb!.width)
|
||||
expect(vb!.y + vb!.height).toBeLessThanOrEqual(mb!.y + mb!.height)
|
||||
}).toPass({ timeout: 5000 })
|
||||
const minimapBox = await minimap.boundingBox()
|
||||
const viewportBox = await viewport.boundingBox()
|
||||
|
||||
expect(minimapBox).toBeTruthy()
|
||||
expect(viewportBox).toBeTruthy()
|
||||
expect(viewportBox!.width).toBeGreaterThan(0)
|
||||
expect(viewportBox!.height).toBeGreaterThan(0)
|
||||
|
||||
expect(viewportBox!.x + viewportBox!.width).toBeGreaterThan(minimapBox!.x)
|
||||
expect(viewportBox!.y + viewportBox!.height).toBeGreaterThan(
|
||||
minimapBox!.y
|
||||
)
|
||||
expect(viewportBox!.x).toBeLessThan(minimapBox!.x + minimapBox!.width)
|
||||
expect(viewportBox!.y).toBeLessThan(minimapBox!.y + minimapBox!.height)
|
||||
|
||||
await expect(minimap).toHaveScreenshot('minimap-with-viewport.png')
|
||||
}
|
||||
)
|
||||
|
||||
test('Clicking on minimap pans the canvas to that position', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const minimap = comfyPage.page.locator('.litegraph-minimap')
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
const offsetBefore = await comfyPage.page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return [ds.offset[0], ds.offset[1]]
|
||||
})
|
||||
|
||||
const minimapBox = await minimap.boundingBox()
|
||||
expect(minimapBox).toBeTruthy()
|
||||
|
||||
// Click the top-left quadrant of the minimap to pan canvas
|
||||
await comfyPage.page.mouse.click(
|
||||
minimapBox!.x + minimapBox!.width * 0.2,
|
||||
minimapBox!.y + minimapBox!.height * 0.2
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const offsetAfter = await comfyPage.page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return [ds.offset[0], ds.offset[1]]
|
||||
})
|
||||
|
||||
expect(
|
||||
offsetAfter[0] !== offsetBefore[0] || offsetAfter[1] !== offsetBefore[1]
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('Dragging on minimap continuously pans the canvas', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const minimap = comfyPage.page.locator('.litegraph-minimap')
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
const minimapBox = await minimap.boundingBox()
|
||||
expect(minimapBox).toBeTruthy()
|
||||
|
||||
const startX = minimapBox!.x + minimapBox!.width * 0.3
|
||||
const startY = minimapBox!.y + minimapBox!.height * 0.3
|
||||
const endX = minimapBox!.x + minimapBox!.width * 0.7
|
||||
const endY = minimapBox!.y + minimapBox!.height * 0.7
|
||||
|
||||
// Record offset before drag
|
||||
const offsetBefore = await comfyPage.page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return [ds.offset[0], ds.offset[1]]
|
||||
})
|
||||
|
||||
// Drag across the minimap
|
||||
await comfyPage.page.mouse.move(startX, startY)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(endX, endY, { steps: 10 })
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const offsetAfter = await comfyPage.page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return [ds.offset[0], ds.offset[1]]
|
||||
})
|
||||
|
||||
expect(
|
||||
offsetAfter[0] !== offsetBefore[0] || offsetAfter[1] !== offsetBefore[1]
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('Minimap viewport updates when canvas is zoomed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const minimap = comfyPage.page.locator('.litegraph-minimap')
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
const viewport = minimap.locator('.minimap-viewport')
|
||||
await expect(viewport).toBeVisible()
|
||||
|
||||
const viewportBefore = await viewport.boundingBox()
|
||||
expect(viewportBefore).toBeTruthy()
|
||||
|
||||
// Zoom in significantly
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
canvas.ds.scale = 3
|
||||
canvas.setDirty(true, true)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Viewport rectangle should shrink when zoomed in
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await viewport.boundingBox()
|
||||
return box?.width ?? 0
|
||||
})
|
||||
.toBeLessThan(viewportBefore!.width)
|
||||
})
|
||||
|
||||
test('Minimap reflects node count changes', async ({ comfyPage }) => {
|
||||
const minimap = comfyPage.page.locator('.litegraph-minimap')
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
const nodeCountBefore = await comfyPage.nodeOps.getGraphNodesCount()
|
||||
expect(nodeCountBefore).toBeGreaterThan(0)
|
||||
|
||||
// Remove all nodes
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
await comfyPage.canvas.press('Delete')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
// Minimap should still be visible and functional with no nodes
|
||||
await expect(minimap).toBeVisible()
|
||||
const viewport = minimap.locator('.minimap-viewport')
|
||||
await expect(viewport).toBeVisible()
|
||||
})
|
||||
|
||||
test('Minimap works after loading a different workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const minimap = comfyPage.page.locator('.litegraph-minimap')
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
// Load the large graph workflow
|
||||
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(minimap).toBeVisible()
|
||||
const viewport = minimap.locator('.minimap-viewport')
|
||||
await expect(viewport).toBeVisible()
|
||||
|
||||
// The viewport should have changed after loading a very different workflow
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await viewport.boundingBox()
|
||||
return box
|
||||
? { w: Math.round(box.width), h: Math.round(box.height) }
|
||||
: null
|
||||
})
|
||||
.not.toBeNull()
|
||||
})
|
||||
|
||||
test('Minimap viewport position reflects canvas pan state', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const minimap = comfyPage.page.locator('.litegraph-minimap')
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
const viewport = minimap.locator('.minimap-viewport')
|
||||
await expect(viewport).toBeVisible()
|
||||
|
||||
const positionBefore = await viewport.boundingBox()
|
||||
expect(positionBefore).toBeTruthy()
|
||||
|
||||
// Pan the canvas by a large amount to the right and down
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
canvas.ds.offset[0] -= 500
|
||||
canvas.ds.offset[1] -= 500
|
||||
canvas.setDirty(true, true)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The viewport indicator should have moved within the minimap
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await viewport.boundingBox()
|
||||
if (!box || !positionBefore) return false
|
||||
return box.x !== positionBefore.x || box.y !== positionBefore.y
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,17 +1,83 @@
|
||||
import type { UploadImageResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
drawStroke,
|
||||
hasCanvasContent,
|
||||
triggerSerialization
|
||||
} from '@e2e/helpers/painter'
|
||||
|
||||
test.describe('Painter', { tag: '@widget' }, () => {
|
||||
/**
|
||||
* Draw a horizontal stroke across the painter canvas.
|
||||
* Returns the canvas bounding box used for the stroke.
|
||||
*/
|
||||
async function drawStroke(
|
||||
page: Page,
|
||||
canvas: Locator,
|
||||
options?: {
|
||||
startX?: number
|
||||
startY?: number
|
||||
endX?: number
|
||||
endY?: number
|
||||
steps?: number
|
||||
}
|
||||
) {
|
||||
const box = await canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not found')
|
||||
|
||||
const {
|
||||
startX = 0.3,
|
||||
startY = 0.5,
|
||||
endX = 0.7,
|
||||
endY = 0.5,
|
||||
steps = 10
|
||||
} = options ?? {}
|
||||
|
||||
await page.mouse.move(box.x + box.width * startX, box.y + box.height * startY)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(box.x + box.width * endX, box.y + box.height * endY, {
|
||||
steps
|
||||
})
|
||||
await page.mouse.up()
|
||||
|
||||
return box
|
||||
}
|
||||
|
||||
/** Count the number of non-transparent pixels on the canvas. */
|
||||
function countOpaquePixels(canvas: Locator) {
|
||||
return canvas.evaluate((el) => {
|
||||
const ctx = (el as HTMLCanvasElement).getContext('2d')
|
||||
if (!ctx) return 0
|
||||
const data = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
(el as HTMLCanvasElement).width,
|
||||
(el as HTMLCanvasElement).height
|
||||
)
|
||||
let count = 0
|
||||
for (let i = 3; i < data.data.length; i += 4) {
|
||||
if (data.data[i] > 0) count++
|
||||
}
|
||||
return count
|
||||
})
|
||||
}
|
||||
|
||||
/** Check if the canvas has any non-transparent pixels. */
|
||||
function canvasHasContent(canvas: Locator) {
|
||||
return canvas.evaluate((el) => {
|
||||
const ctx = (el as HTMLCanvasElement).getContext('2d')
|
||||
if (!ctx) return false
|
||||
const data = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
(el as HTMLCanvasElement).width,
|
||||
(el as HTMLCanvasElement).height
|
||||
)
|
||||
for (let i = 3; i < data.data.length; i += 4) {
|
||||
if (data.data[i] > 0) return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Painter', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => window.app?.graph?.clear())
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('widgets/painter_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
@@ -28,15 +94,9 @@ test.describe('Painter', { tag: '@widget' }, () => {
|
||||
await expect(painterWidget).toBeVisible()
|
||||
|
||||
await expect(painterWidget.locator('canvas')).toBeVisible()
|
||||
await expect(
|
||||
painterWidget.getByRole('button', { name: 'Brush' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
painterWidget.getByRole('button', { name: 'Eraser' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
painterWidget.getByTestId('painter-clear-button')
|
||||
).toBeVisible()
|
||||
await expect(painterWidget.getByText('Brush')).toBeVisible()
|
||||
await expect(painterWidget.getByText('Eraser')).toBeVisible()
|
||||
await expect(painterWidget.getByText('Clear')).toBeVisible()
|
||||
await expect(
|
||||
painterWidget.locator('input[type="color"]').first()
|
||||
).toBeVisible()
|
||||
@@ -53,66 +113,19 @@ test.describe('Painter', { tag: '@widget' }, () => {
|
||||
const canvas = node.locator('.widget-expands canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
expect(await hasCanvasContent(canvas), 'canvas should start empty').toBe(
|
||||
false
|
||||
)
|
||||
const isEmptyBefore = await canvas.evaluate((el) => {
|
||||
const ctx = (el as HTMLCanvasElement).getContext('2d')
|
||||
if (!ctx) return true
|
||||
const data = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
(el as HTMLCanvasElement).width,
|
||||
(el as HTMLCanvasElement).height
|
||||
)
|
||||
return data.data.every((v, i) => (i % 4 === 3 ? v === 0 : true))
|
||||
})
|
||||
expect(isEmptyBefore).toBe(true)
|
||||
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
|
||||
await expect
|
||||
.poll(() => hasCanvasContent(canvas), {
|
||||
message: 'canvas should have content after stroke'
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
await expect(node).toHaveScreenshot('painter-after-stroke.png')
|
||||
}
|
||||
)
|
||||
|
||||
test.describe('Drawing', () => {
|
||||
test(
|
||||
'Eraser removes drawn content',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
const canvas = painterWidget.locator('canvas')
|
||||
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
|
||||
await expect
|
||||
.poll(() => hasCanvasContent(canvas), {
|
||||
message: 'canvas must have content before erasing'
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
canvas.evaluate((el: HTMLCanvasElement) => {
|
||||
const ctx = el.getContext('2d')
|
||||
if (!ctx) return false
|
||||
const cx = Math.floor(el.width / 2)
|
||||
const cy = Math.floor(el.height / 2)
|
||||
const { data } = ctx.getImageData(cx - 5, cy - 5, 10, 10)
|
||||
return data.every((v, i) => i % 4 !== 3 || v === 0)
|
||||
}),
|
||||
{ message: 'erased area should be transparent' }
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
test('Stroke ends cleanly when pointer up fires outside canvas', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
const canvas = painterWidget.locator('canvas')
|
||||
const box = await canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not found')
|
||||
|
||||
@@ -126,250 +139,392 @@ test.describe('Painter', { tag: '@widget' }, () => {
|
||||
box.y + box.height * 0.5,
|
||||
{ steps: 10 }
|
||||
)
|
||||
await comfyPage.page.mouse.move(box.x - 20, box.y + box.height * 0.5)
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => hasCanvasContent(canvas), {
|
||||
message:
|
||||
'canvas should have content after stroke with pointer up outside'
|
||||
await expect(async () => {
|
||||
const hasContent = await canvas.evaluate((el) => {
|
||||
const ctx = (el as HTMLCanvasElement).getContext('2d')
|
||||
if (!ctx) return false
|
||||
const data = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
(el as HTMLCanvasElement).width,
|
||||
(el as HTMLCanvasElement).height
|
||||
)
|
||||
for (let i = 3; i < data.data.length; i += 4) {
|
||||
if (data.data[i] > 0) return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
expect(hasContent).toBe(true)
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
await expect(node).toHaveScreenshot('painter-after-stroke.png')
|
||||
}
|
||||
)
|
||||
|
||||
test('Clear button removes all painted content', async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
const canvas = painterWidget.locator('canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => canvasHasContent(canvas)).toBe(true)
|
||||
|
||||
await painterWidget.getByText('Clear').click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => canvasHasContent(canvas)).toBe(false)
|
||||
})
|
||||
|
||||
test.describe('Tool selection', () => {
|
||||
test('Tool switching toggles brush-only controls', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
test('Eraser tool removes previously drawn content', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
const canvas = painterWidget.locator('canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
await expect(painterWidget.getByTestId('painter-color-row')).toBeVisible()
|
||||
await expect(
|
||||
painterWidget.getByTestId('painter-hardness-row')
|
||||
).toBeVisible()
|
||||
// Draw a brush stroke
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
|
||||
const pixelsAfterBrush = await countOpaquePixels(canvas)
|
||||
expect(pixelsAfterBrush).toBeGreaterThan(0)
|
||||
|
||||
await expect(
|
||||
painterWidget.getByTestId('painter-color-row'),
|
||||
'color row should be hidden in eraser mode'
|
||||
).toBeHidden()
|
||||
await expect(
|
||||
painterWidget.getByTestId('painter-hardness-row')
|
||||
).toBeHidden()
|
||||
// Switch to eraser
|
||||
await painterWidget.getByText('Eraser').click()
|
||||
|
||||
await painterWidget.getByRole('button', { name: 'Brush' }).click()
|
||||
// Erase over the same area
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(painterWidget.getByTestId('painter-color-row')).toBeVisible()
|
||||
await expect(
|
||||
painterWidget.getByTestId('painter-hardness-row')
|
||||
).toBeVisible()
|
||||
})
|
||||
await expect
|
||||
.poll(() => countOpaquePixels(canvas), { timeout: 3000 })
|
||||
.toBeLessThan(pixelsAfterBrush)
|
||||
})
|
||||
|
||||
test.describe('Brush settings', () => {
|
||||
test('Size slider updates the displayed value', async ({ comfyPage }) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
const sizeRow = painterWidget.getByTestId('painter-size-row')
|
||||
const sizeSlider = sizeRow.getByRole('slider')
|
||||
const sizeDisplay = sizeRow.getByTestId('painter-size-value')
|
||||
test('Switching to eraser hides color and hardness controls', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
|
||||
await expect(sizeDisplay).toHaveText('20')
|
||||
// In brush mode, color picker and hardness should be visible
|
||||
await expect(painterWidget.getByText('Color Picker')).toBeVisible()
|
||||
await expect(painterWidget.getByText('Hardness')).toBeVisible()
|
||||
|
||||
await sizeSlider.focus()
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await sizeSlider.press('ArrowRight')
|
||||
}
|
||||
// Switch to eraser
|
||||
await painterWidget.getByText('Eraser').click()
|
||||
|
||||
await expect(sizeDisplay).toHaveText('30')
|
||||
})
|
||||
|
||||
test('Opacity input clamps out-of-range values', async ({ comfyPage }) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
const opacityInput = painterWidget
|
||||
.getByTestId('painter-color-row')
|
||||
.locator('input[type="number"]')
|
||||
|
||||
await opacityInput.fill('150')
|
||||
await opacityInput.press('Tab')
|
||||
await expect(opacityInput).toHaveValue('100')
|
||||
|
||||
await opacityInput.fill('-10')
|
||||
await opacityInput.press('Tab')
|
||||
await expect(opacityInput).toHaveValue('0')
|
||||
})
|
||||
// Color and hardness controls should be hidden
|
||||
await expect(painterWidget.getByText('Color Picker')).toBeHidden()
|
||||
await expect(painterWidget.getByText('Hardness')).toBeHidden()
|
||||
})
|
||||
|
||||
test.describe('Canvas size controls', () => {
|
||||
test('Width and height sliders visible without connected input', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
test('Switching back to brush re-shows color and hardness controls', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
|
||||
await expect(painterWidget.getByTestId('painter-width-row')).toBeVisible()
|
||||
await expect(
|
||||
painterWidget.getByTestId('painter-height-row')
|
||||
).toBeVisible()
|
||||
await painterWidget.getByText('Eraser').click()
|
||||
await expect(painterWidget.getByText('Color Picker')).toBeHidden()
|
||||
|
||||
await expect(
|
||||
painterWidget.getByTestId('painter-dimension-text')
|
||||
).toBeHidden()
|
||||
})
|
||||
await painterWidget.getByText('Brush').click()
|
||||
await expect(painterWidget.getByText('Color Picker')).toBeVisible()
|
||||
await expect(painterWidget.getByText('Hardness')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Width slider resizes the canvas element', async ({ comfyPage }) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
const canvas = painterWidget.locator('canvas')
|
||||
const widthSlider = painterWidget
|
||||
.getByTestId('painter-width-row')
|
||||
.getByRole('slider')
|
||||
test('Brush size slider updates the displayed value', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
|
||||
const initialWidth = await canvas.evaluate(
|
||||
(el: HTMLCanvasElement) => el.width
|
||||
)
|
||||
expect(initialWidth, 'canvas should start at default width').toBe(512)
|
||||
// The default brush size is 20; find the size display
|
||||
const sizeLabel = painterWidget.getByText('Cursor Size')
|
||||
await expect(sizeLabel).toBeVisible()
|
||||
|
||||
await widthSlider.focus()
|
||||
await widthSlider.press('ArrowRight')
|
||||
// The size row contains a slider and a numeric display
|
||||
const sizeRow = sizeLabel.locator('~ div').first()
|
||||
const sizeDisplay = sizeRow.locator('span').last()
|
||||
const initialSize = await sizeDisplay.textContent()
|
||||
expect(initialSize?.trim()).toBe('20')
|
||||
|
||||
await expect
|
||||
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width))
|
||||
.toBe(576)
|
||||
})
|
||||
// Drag the slider thumb to the right to increase size
|
||||
const slider = sizeRow.getByRole("slider")
|
||||
const sliderBox = await slider.boundingBox()
|
||||
if (!sliderBox) throw new Error('Slider thumb not found')
|
||||
|
||||
test(
|
||||
'Resize preserves existing drawing',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
const canvas = painterWidget.locator('canvas')
|
||||
const widthSlider = painterWidget
|
||||
.getByTestId('painter-width-row')
|
||||
.getByRole('slider')
|
||||
const sliderTrack = sizeRow.locator('span').first()
|
||||
const trackBox = await sliderTrack.boundingBox()
|
||||
if (!trackBox) throw new Error('Slider track not found')
|
||||
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
await expect
|
||||
.poll(() => hasCanvasContent(canvas), {
|
||||
message: 'canvas must have content before resize'
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
await widthSlider.focus()
|
||||
await widthSlider.press('ArrowRight')
|
||||
|
||||
await expect
|
||||
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width))
|
||||
.toBe(576)
|
||||
|
||||
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
|
||||
await expect(node).toHaveScreenshot('painter-after-resize.png')
|
||||
}
|
||||
await slider.dispatchEvent('pointerdown', { bubbles: true })
|
||||
await comfyPage.page.mouse.move(
|
||||
sliderBox.x + sliderBox.width / 2,
|
||||
sliderBox.y + sliderBox.height / 2
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Clear', () => {
|
||||
test(
|
||||
'Clear removes all drawn content',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
const canvas = painterWidget.locator('canvas')
|
||||
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
await expect
|
||||
.poll(() => hasCanvasContent(canvas), {
|
||||
message: 'canvas must have content before clear'
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
const clearButton = painterWidget.getByTestId('painter-clear-button')
|
||||
await clearButton.dispatchEvent('click')
|
||||
|
||||
await expect
|
||||
.poll(() => hasCanvasContent(canvas), {
|
||||
message: 'canvas should be clear after click'
|
||||
})
|
||||
.toBe(false)
|
||||
}
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(
|
||||
trackBox.x + trackBox.width * 0.8,
|
||||
sliderBox.y + sliderBox.height / 2,
|
||||
{ steps: 5 }
|
||||
)
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
// The displayed value should have increased
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const text = await sizeDisplay.textContent()
|
||||
return Number(text?.trim())
|
||||
})
|
||||
.toBeGreaterThan(20)
|
||||
})
|
||||
|
||||
test.describe('Serialization', () => {
|
||||
test('Drawing triggers upload on serialization', async ({ comfyPage }) => {
|
||||
const mockUploadResponse: UploadImageResponse = {
|
||||
name: 'painter-test.png'
|
||||
}
|
||||
let uploadCount = 0
|
||||
test('Drawing with different brush sizes produces different stroke widths', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
const canvas = painterWidget.locator('canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
await comfyPage.page.route('**/upload/image', async (route) => {
|
||||
uploadCount++
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockUploadResponse)
|
||||
})
|
||||
})
|
||||
|
||||
const canvas = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands canvas')
|
||||
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
|
||||
await triggerSerialization(comfyPage.page)
|
||||
|
||||
expect(uploadCount, 'should upload exactly once').toBe(1)
|
||||
// Draw with the default brush size (20)
|
||||
await drawStroke(comfyPage.page, canvas, {
|
||||
startX: 0.2,
|
||||
startY: 0.3,
|
||||
endX: 0.8,
|
||||
endY: 0.3
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
test('Empty canvas does not upload on serialization', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
let uploadCount = 0
|
||||
const smallBrushPixels = await countOpaquePixels(canvas)
|
||||
|
||||
await comfyPage.page.route('**/upload/image', async (route) => {
|
||||
uploadCount++
|
||||
const mockResponse: UploadImageResponse = { name: 'painter-test.png' }
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockResponse)
|
||||
})
|
||||
})
|
||||
// Clear the canvas
|
||||
await painterWidget.getByText('Clear').click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await triggerSerialization(comfyPage.page)
|
||||
// Set brush size to a larger value by dragging the slider far right
|
||||
const sizeLabel = painterWidget.getByText('Cursor Size')
|
||||
const sizeRow = sizeLabel.locator('~ div').first()
|
||||
const slider = sizeRow.getByRole("slider")
|
||||
const sliderBox = await slider.boundingBox()
|
||||
if (!sliderBox) throw new Error('Slider thumb not found')
|
||||
|
||||
expect(uploadCount, 'empty canvas should not upload').toBe(0)
|
||||
const trackBox = await sizeRow.boundingBox()
|
||||
if (!trackBox) throw new Error('Size row not found')
|
||||
|
||||
await comfyPage.page.mouse.move(
|
||||
sliderBox.x + sliderBox.width / 2,
|
||||
sliderBox.y + sliderBox.height / 2
|
||||
)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(
|
||||
trackBox.x + trackBox.width * 0.95,
|
||||
sliderBox.y + sliderBox.height / 2,
|
||||
{ steps: 5 }
|
||||
)
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
// Draw with the larger brush size in the same area
|
||||
await drawStroke(comfyPage.page, canvas, {
|
||||
startX: 0.2,
|
||||
startY: 0.3,
|
||||
endX: 0.8,
|
||||
endY: 0.3
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
test('Upload failure shows error toast', async ({ comfyPage }) => {
|
||||
await comfyPage.page.route('**/upload/image', async (route) => {
|
||||
await route.fulfill({ status: 500 })
|
||||
})
|
||||
await expect
|
||||
.poll(() => countOpaquePixels(canvas))
|
||||
.toBeGreaterThan(smallBrushPixels)
|
||||
})
|
||||
|
||||
const canvas = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands canvas')
|
||||
test('Canvas width and height controls are shown and adjustable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
// Width and Height labels should be visible (no image input connected)
|
||||
await expect(painterWidget.getByText('Width')).toBeVisible()
|
||||
await expect(painterWidget.getByText('Height')).toBeVisible()
|
||||
|
||||
await expect(triggerSerialization(comfyPage.page)).rejects.toThrow()
|
||||
// Default canvas size is 512x512 — displayed in the width/height rows
|
||||
const widthRow = painterWidget.getByText('Width').locator('~ div').first()
|
||||
const widthDisplay = widthRow.locator('span').last()
|
||||
await expect(widthDisplay).toHaveText('512')
|
||||
|
||||
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible()
|
||||
const heightRow = painterWidget.getByText('Height').locator('~ div').first()
|
||||
const heightDisplay = heightRow.locator('span').last()
|
||||
await expect(heightDisplay).toHaveText('512')
|
||||
})
|
||||
|
||||
test('Background color control is visible when no image input is connected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
|
||||
await expect(painterWidget.getByText('Background')).toBeVisible()
|
||||
|
||||
// There should be two color inputs: brush color and background color
|
||||
const colorInputs = painterWidget.locator('input[type="color"]')
|
||||
await expect(colorInputs).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('Drawing a stroke then clearing and redrawing works correctly', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
const canvas = painterWidget.locator('canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
// Draw first stroke
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => canvasHasContent(canvas)).toBe(true)
|
||||
|
||||
// Clear
|
||||
await painterWidget.getByText('Clear').click()
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => canvasHasContent(canvas)).toBe(false)
|
||||
|
||||
// Draw second stroke in a different position
|
||||
await drawStroke(comfyPage.page, canvas, {
|
||||
startX: 0.2,
|
||||
startY: 0.2,
|
||||
endX: 0.8,
|
||||
endY: 0.8
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => canvasHasContent(canvas)).toBe(true)
|
||||
})
|
||||
|
||||
test('Multiple strokes accumulate on the canvas', async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const canvas = node.locator('.widget-expands canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
// First stroke (horizontal)
|
||||
await drawStroke(comfyPage.page, canvas, {
|
||||
startX: 0.2,
|
||||
startY: 0.3,
|
||||
endX: 0.8,
|
||||
endY: 0.3
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
const pixelsAfterFirst = await countOpaquePixels(canvas)
|
||||
|
||||
// Second stroke (vertical, different area)
|
||||
await drawStroke(comfyPage.page, canvas, {
|
||||
startX: 0.5,
|
||||
startY: 0.1,
|
||||
endX: 0.5,
|
||||
endY: 0.9
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => countOpaquePixels(canvas))
|
||||
.toBeGreaterThan(pixelsAfterFirst)
|
||||
})
|
||||
|
||||
test('Eraser does not add visible content to an empty canvas', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
const canvas = painterWidget.locator('canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
// Switch to eraser on empty canvas
|
||||
await painterWidget.getByText('Eraser').click()
|
||||
|
||||
// Draw with eraser on empty canvas
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Canvas should still be empty since eraser uses destination-out
|
||||
await expect.poll(() => canvasHasContent(canvas)).toBe(false)
|
||||
})
|
||||
|
||||
test(
|
||||
'Brush color picker is a color input',
|
||||
{ tag: ['@smoke'] },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
|
||||
// The first color input is the brush color
|
||||
const brushColorInput = painterWidget
|
||||
.locator('input[type="color"]')
|
||||
.first()
|
||||
await expect(brushColorInput).toBeVisible()
|
||||
|
||||
// Default brush color is white (#ffffff)
|
||||
await expect(brushColorInput).toHaveValue('#ffffff')
|
||||
}
|
||||
)
|
||||
|
||||
test('Opacity input accepts percentage values', async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
|
||||
// The opacity input is a number input inside the color row
|
||||
const opacityInput = painterWidget.locator('input[type="number"]').first()
|
||||
await expect(opacityInput).toBeVisible()
|
||||
|
||||
// Default opacity is 100%
|
||||
await expect(opacityInput).toHaveValue('100')
|
||||
})
|
||||
|
||||
test('Partial erasing leaves some content behind', async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
const canvas = painterWidget.locator('canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
// Draw two separate horizontal strokes
|
||||
await drawStroke(comfyPage.page, canvas, {
|
||||
startX: 0.1,
|
||||
startY: 0.3,
|
||||
endX: 0.9,
|
||||
endY: 0.3
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await drawStroke(comfyPage.page, canvas, {
|
||||
startX: 0.1,
|
||||
startY: 0.7,
|
||||
endX: 0.9,
|
||||
endY: 0.7
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const pixelsBeforeErase = await countOpaquePixels(canvas)
|
||||
|
||||
// Switch to eraser and erase only the top stroke area
|
||||
await painterWidget.getByText('Eraser').click()
|
||||
await drawStroke(comfyPage.page, canvas, {
|
||||
startX: 0.1,
|
||||
startY: 0.3,
|
||||
endX: 0.9,
|
||||
endY: 0.3
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Some content should remain (the bottom stroke), but less than before
|
||||
await expect(async () => {
|
||||
const remaining = await countOpaquePixels(canvas)
|
||||
expect(remaining).toBeGreaterThan(0)
|
||||
expect(remaining).toBeLessThan(pixelsBeforeErase)
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user