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:
dante01yoon
2026-04-13 22:11:55 +09:00
parent 521019d173
commit 2658d78b4e
7 changed files with 1389 additions and 476 deletions

View 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
}

View File

@@ -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": [],

View 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)
}
)
})

View 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')
}
)
})

View File

@@ -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)
}
)
})

View File

@@ -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)
})
})

View File

@@ -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 })
})
})