mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-11 01:28:03 +00:00
## Summary
Fixes FE-1014 by making legacy LiteGraph honor backend-provided hidden
widget metadata.
This PR adds a regression test for the Painter node and updates
LiteGraph widget construction so that a backend input spec with `hidden:
true` is mirrored onto the top-level `widget.hidden` property that the
legacy canvas renderer actually reads.
## Problem
Backend node definitions can mark inputs as hidden, for example with
`extra_dict={"hidden": True}`. That metadata already flows into the
frontend widget options as `widget.options.hidden`, which is why Vue
nodes correctly hide those fields.
Legacy LiteGraph, however, does not use `widget.options.hidden` for
canvas visibility. Its rendering, layout, and hit-testing paths check
top-level `widget.hidden` instead. As a result, a field could be hidden
in Vue nodes while still appearing as an editable control in the legacy
LiteGraph canvas.
For affected nodes, this exposes fields that are intended to be
implementation details, schema/version values, or other non-user-facing
inputs.
## Root Cause
The frontend widget construction path copied backend display metadata
into `widget.options`, including:
- `advanced`
- `hidden`
But it did not mirror backend `hidden` metadata into `widget.hidden`.
That created a renderer split:
- Vue nodes and the right panel use `widget.options.hidden`.
- Legacy LiteGraph uses top-level `widget.hidden`.
So backend-hidden widgets were hidden in Vue mode but still visible and
clickable in legacy LiteGraph mode.
## Implementation
The production change is intentionally small and scoped to
backend-provided hidden metadata:
- Continue assigning `inputSpec.hidden` to `widget.options.hidden` as
before.
- When `inputSpec.hidden` is explicitly defined, also assign it to
top-level `widget.hidden`.
This keeps Vue behavior unchanged while making the legacy LiteGraph
renderer receive the same backend hidden signal through the field it
already uses for visibility.
The fix deliberately does not mirror `advanced` into top-level
`widget.advanced`. While investigating this area, I found that many
backend inputs define `advanced`, and changing legacy advanced-widget
behavior would be a much broader behavioral change than FE-1014
requires. This PR only addresses hidden metadata.
## Test Coverage
This PR adds and tightens Painter regression coverage because Painter
currently provides a concrete backend-hidden widget case:
- In Vue mode, the test verifies hidden Painter widgets are not rendered
to the user.
- In legacy LiteGraph mode, the test disables Vue nodes, loads the
Painter workflow, clicks the rows where backend-hidden number widgets
used to be exposed, and verifies the legacy graph editor dialog does not
open.
The legacy test specifically covers the backend-hidden number widgets
`width` and `height`. It uses user-observable behavior rather than
asserting internal widget flags directly.
A follow-up discussion is ongoing about the broader contract between
`widget.options.hidden` and top-level `widget.hidden`, especially for
frontend-extension-only hiding such as Painter `bg_color`. This PR
intentionally keeps that broader renderer-contract question out of scope
and focuses on backend `hidden` metadata from FE-1014.
## Validation
Validated locally with targeted Playwright coverage:
```bash
PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm exec playwright test browser_tests/tests/painter.spec.ts --project=chromium -g "Does not render hidden standard widgets|Does not open editors for backend-hidden number widget rows"
```
Result:
```text
2 passed
```
Also validated with linting:
```bash
pnpm eslint src/services/litegraphService.ts browser_tests/tests/painter.spec.ts
pnpm eslint browser_tests/tests/painter.spec.ts
```
The commit hooks also passed:
- `oxfmt`
- `oxlint`
- `eslint`
- `pnpm typecheck`
- `pnpm typecheck:browser`
## Notes
The new legacy test was confirmed red before the production fix and
green after the production fix, so it is not a vacuous assertion. The
final cleanup commit only tightens test naming and coordinate handling
while preserving the same regression intent.
988 lines
31 KiB
TypeScript
988 lines
31 KiB
TypeScript
import type { UploadImageResponse } from '@comfyorg/ingest-types'
|
|
|
|
import { expect } from '@playwright/test'
|
|
|
|
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
|
import {
|
|
drawStroke,
|
|
hasCanvasContent,
|
|
triggerSerialization
|
|
} from '@e2e/fixtures/utils/painter'
|
|
import type { TestGraphAccess } from '@e2e/types/globals'
|
|
|
|
const HIDDEN_PAINTER_WIDGET_NAMES = ['width', 'height', 'bg_color'] as const
|
|
const HIDDEN_PAINTER_NUMBER_WIDGET_NAMES = ['width', 'height'] as const
|
|
|
|
test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
|
test.beforeEach(async ({ comfyPage }) => {
|
|
await comfyPage.page.evaluate(() => window.app?.graph?.clear())
|
|
await comfyPage.workflow.loadWorkflow('widgets/painter_widget')
|
|
})
|
|
|
|
test.describe('Widget rendering', () => {
|
|
test('Node enforces minimum size', async ({ comfyPage }) => {
|
|
const size = await comfyPage.page.evaluate(() => {
|
|
const graph = window.graph as TestGraphAccess | undefined
|
|
const node = graph?._nodes_by_id?.['1']
|
|
return node?.size as [number, number] | undefined
|
|
})
|
|
expect(size).toBeDefined()
|
|
expect(size![0]).toBeGreaterThanOrEqual(450)
|
|
expect(size![1]).toBeGreaterThanOrEqual(550)
|
|
})
|
|
|
|
test('Does not render hidden standard widgets in Vue mode', async ({
|
|
comfyPage
|
|
}) => {
|
|
const node = comfyPage.vueNodes.getNodeLocator('1')
|
|
await expect(node).toBeVisible()
|
|
|
|
for (const widgetName of HIDDEN_PAINTER_WIDGET_NAMES) {
|
|
await expect(node.getByLabel(widgetName, { exact: true })).toBeHidden()
|
|
}
|
|
})
|
|
})
|
|
|
|
test(
|
|
'Renders canvas and controls',
|
|
{ tag: ['@smoke', '@screenshot'] },
|
|
async ({ comfyPage }) => {
|
|
const node = comfyPage.vueNodes.getNodeLocator('1')
|
|
await expect(node).toBeVisible()
|
|
|
|
const painterWidget = node.locator('.widget-expands')
|
|
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.locator('input[type="color"]').first()
|
|
).toBeVisible()
|
|
|
|
await expect(node).toHaveScreenshot('painter-default-state.png')
|
|
}
|
|
)
|
|
|
|
test(
|
|
'Drawing a stroke changes the canvas',
|
|
{ tag: ['@smoke', '@screenshot'] },
|
|
async ({ comfyPage }) => {
|
|
const node = comfyPage.vueNodes.getNodeLocator('1')
|
|
const canvas = node.locator('.widget-expands canvas')
|
|
await expect(canvas).toBeVisible()
|
|
|
|
expect(await hasCanvasContent(canvas), 'canvas should start empty').toBe(
|
|
false
|
|
)
|
|
|
|
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('Custom brush cursor follows mouse and hides on leave', async ({
|
|
comfyPage
|
|
}) => {
|
|
const painterWidget = comfyPage.vueNodes
|
|
.getNodeLocator('1')
|
|
.locator('.widget-expands')
|
|
const canvas = painterWidget.locator('canvas')
|
|
const cursor = painterWidget.getByTestId('painter-cursor')
|
|
|
|
await expect(cursor).toBeHidden()
|
|
|
|
const box = await canvas.boundingBox()
|
|
if (!box) throw new Error('Canvas bounding box not found')
|
|
|
|
await comfyPage.page.mouse.move(
|
|
box.x + box.width * 0.3,
|
|
box.y + box.height * 0.5
|
|
)
|
|
await expect(cursor).toBeVisible()
|
|
|
|
const transform1 = await cursor.evaluate(
|
|
(el: HTMLElement) => el.style.transform
|
|
)
|
|
|
|
await comfyPage.page.mouse.move(
|
|
box.x + box.width * 0.7,
|
|
box.y + box.height * 0.5
|
|
)
|
|
await expect
|
|
.poll(() => cursor.evaluate((el: HTMLElement) => el.style.transform))
|
|
.not.toBe(transform1)
|
|
|
|
await comfyPage.page.mouse.move(box.x + box.width + 50, box.y)
|
|
await expect(cursor).toBeHidden()
|
|
})
|
|
|
|
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')
|
|
|
|
await comfyPage.page.mouse.move(
|
|
box.x + box.width * 0.3,
|
|
box.y + box.height * 0.5
|
|
)
|
|
await comfyPage.page.mouse.down()
|
|
await comfyPage.page.mouse.move(
|
|
box.x + box.width * 0.7,
|
|
box.y + box.height * 0.5,
|
|
{ steps: 10 }
|
|
)
|
|
await comfyPage.page.mouse.move(
|
|
box.x + box.width + 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'
|
|
})
|
|
.toBe(true)
|
|
})
|
|
})
|
|
|
|
test.describe('Tool selection', () => {
|
|
test('Tool switching toggles brush-only controls', async ({
|
|
comfyPage
|
|
}) => {
|
|
const painterWidget = comfyPage.vueNodes
|
|
.getNodeLocator('1')
|
|
.locator('.widget-expands')
|
|
|
|
await expect(painterWidget.getByTestId('painter-color-row')).toBeVisible()
|
|
await expect(
|
|
painterWidget.getByTestId('painter-hardness-row')
|
|
).toBeVisible()
|
|
|
|
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
|
|
|
|
await expect(
|
|
painterWidget.getByTestId('painter-color-row'),
|
|
'color row should be hidden in eraser mode'
|
|
).toBeHidden()
|
|
await expect(
|
|
painterWidget.getByTestId('painter-hardness-row')
|
|
).toBeHidden()
|
|
|
|
await painterWidget.getByRole('button', { name: 'Brush' }).click()
|
|
|
|
await expect(painterWidget.getByTestId('painter-color-row')).toBeVisible()
|
|
await expect(
|
|
painterWidget.getByTestId('painter-hardness-row')
|
|
).toBeVisible()
|
|
})
|
|
})
|
|
|
|
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')
|
|
|
|
await expect(sizeDisplay).toHaveText('20')
|
|
|
|
await sizeSlider.focus()
|
|
for (let i = 0; i < 10; i++) {
|
|
await sizeSlider.press('ArrowRight')
|
|
}
|
|
|
|
await expect(sizeDisplay).toHaveText('30')
|
|
})
|
|
|
|
test('Hardness slider updates the displayed value', async ({
|
|
comfyPage
|
|
}) => {
|
|
const painterWidget = comfyPage.vueNodes
|
|
.getNodeLocator('1')
|
|
.locator('.widget-expands')
|
|
const hardnessRow = painterWidget.getByTestId('painter-hardness-row')
|
|
const hardnessSlider = hardnessRow.getByRole('slider')
|
|
|
|
const hardnessDisplay = hardnessRow.getByTestId('painter-hardness-value')
|
|
await expect(hardnessDisplay).toHaveText('100%')
|
|
|
|
await hardnessSlider.focus()
|
|
for (let i = 0; i < 10; i++) {
|
|
await hardnessSlider.press('ArrowLeft')
|
|
}
|
|
|
|
await expect(hardnessDisplay).toHaveText('90%')
|
|
})
|
|
|
|
test('Color picker changes the color of drawn strokes', async ({
|
|
comfyPage
|
|
}) => {
|
|
const painterWidget = comfyPage.vueNodes
|
|
.getNodeLocator('1')
|
|
.locator('.widget-expands')
|
|
const canvas = painterWidget.locator('canvas')
|
|
const colorInput = painterWidget
|
|
.getByTestId('painter-color-row')
|
|
.locator('input[type="color"]')
|
|
|
|
await colorInput.evaluate((el: HTMLInputElement) => {
|
|
el.value = '#ff0000'
|
|
el.dispatchEvent(new Event('input', { bubbles: true }))
|
|
})
|
|
|
|
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 - 20, cy - 20, 40, 40)
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
if (
|
|
data[i] > 200 &&
|
|
data[i + 1] < 50 &&
|
|
data[i + 2] < 50 &&
|
|
data[i + 3] > 0
|
|
)
|
|
return true
|
|
}
|
|
return false
|
|
}),
|
|
{ message: 'stroke should have red pixels' }
|
|
)
|
|
.toBe(true)
|
|
})
|
|
|
|
test('Opacity setting produces semi-transparent strokes', async ({
|
|
comfyPage
|
|
}) => {
|
|
const painterWidget = comfyPage.vueNodes
|
|
.getNodeLocator('1')
|
|
.locator('.widget-expands')
|
|
const canvas = painterWidget.locator('canvas')
|
|
const opacityInput = painterWidget
|
|
.getByTestId('painter-color-row')
|
|
.locator('input[type="number"]')
|
|
|
|
await opacityInput.fill('50')
|
|
await opacityInput.press('Tab')
|
|
await expect(opacityInput).toHaveValue('50')
|
|
|
|
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 - 20, cy - 20, 40, 40)
|
|
for (let i = 3; i < data.length; i += 4) {
|
|
if (data[i] > 50 && data[i] < 230) return true
|
|
}
|
|
return false
|
|
}),
|
|
{
|
|
message: 'stroke should have semi-transparent pixels at 50% opacity'
|
|
}
|
|
)
|
|
.toBe(true)
|
|
})
|
|
|
|
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')
|
|
})
|
|
})
|
|
|
|
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')
|
|
|
|
await expect(painterWidget.getByTestId('painter-width-row')).toBeVisible()
|
|
await expect(
|
|
painterWidget.getByTestId('painter-height-row')
|
|
).toBeVisible()
|
|
|
|
await expect(
|
|
painterWidget.getByTestId('painter-dimension-text')
|
|
).toBeHidden()
|
|
})
|
|
|
|
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')
|
|
|
|
const initialWidth = await canvas.evaluate(
|
|
(el: HTMLCanvasElement) => el.width
|
|
)
|
|
expect(initialWidth, 'canvas should start at default width').toBe(512)
|
|
|
|
await widthSlider.focus()
|
|
await widthSlider.press('ArrowRight')
|
|
|
|
await expect
|
|
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width))
|
|
// default 512 + slider step 64 = 576
|
|
.toBe(576)
|
|
})
|
|
|
|
test('Background color picker updates the canvas container', async ({
|
|
comfyPage
|
|
}) => {
|
|
const painterWidget = comfyPage.vueNodes
|
|
.getNodeLocator('1')
|
|
.locator('.widget-expands')
|
|
const bgColorInput = painterWidget
|
|
.getByTestId('painter-bg-color-row')
|
|
.locator('input[type="color"]')
|
|
const canvasContainer = painterWidget.getByTestId(
|
|
'painter-canvas-container'
|
|
)
|
|
|
|
await bgColorInput.evaluate((el: HTMLInputElement) => {
|
|
el.value = '#ff0000'
|
|
el.dispatchEvent(new Event('input', { bubbles: true }))
|
|
})
|
|
|
|
await expect(canvasContainer).toHaveCSS(
|
|
'background-color',
|
|
'rgb(255, 0, 0)'
|
|
)
|
|
})
|
|
|
|
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')
|
|
|
|
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')
|
|
}
|
|
)
|
|
})
|
|
|
|
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)
|
|
}
|
|
)
|
|
|
|
test('Clear on empty canvas is harmless', async ({ comfyPage }) => {
|
|
const painterWidget = comfyPage.vueNodes
|
|
.getNodeLocator('1')
|
|
.locator('.widget-expands')
|
|
const canvas = painterWidget.locator('canvas')
|
|
|
|
await expect
|
|
.poll(() => hasCanvasContent(canvas), {
|
|
message: 'canvas should start empty'
|
|
})
|
|
.toBe(false)
|
|
|
|
await painterWidget
|
|
.getByTestId('painter-clear-button')
|
|
.dispatchEvent('click')
|
|
|
|
await expect
|
|
.poll(() => hasCanvasContent(canvas), {
|
|
message: 'canvas should still be empty after clearing empty canvas'
|
|
})
|
|
.toBe(false)
|
|
})
|
|
})
|
|
|
|
test.describe('Serialization', () => {
|
|
test('Drawing triggers upload on serialization', async ({ comfyPage }) => {
|
|
const mockUploadResponse: UploadImageResponse = {
|
|
name: 'painter-test.png'
|
|
}
|
|
let uploadCount = 0
|
|
|
|
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)
|
|
})
|
|
|
|
test('Empty canvas uploads a transparent placeholder on serialization', async ({
|
|
comfyPage
|
|
}) => {
|
|
let uploadCount = 0
|
|
|
|
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)
|
|
})
|
|
})
|
|
|
|
await triggerSerialization(comfyPage.page)
|
|
|
|
expect(
|
|
uploadCount,
|
|
'empty canvas should upload a transparent PNG so the backend receives a valid asset reference (Painter.execute treats painter_alpha=0 as no-mask)'
|
|
).toBe(1)
|
|
})
|
|
|
|
test('Upload failure shows error toast', async ({ comfyPage }) => {
|
|
await comfyPage.page.route('**/upload/image', async (route) => {
|
|
await route.fulfill({ status: 500 })
|
|
})
|
|
|
|
const canvas = comfyPage.vueNodes
|
|
.getNodeLocator('1')
|
|
.locator('.widget-expands canvas')
|
|
|
|
await drawStroke(comfyPage.page, canvas)
|
|
|
|
await expect(triggerSerialization(comfyPage.page)).rejects.toThrow()
|
|
|
|
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible()
|
|
})
|
|
})
|
|
|
|
test.describe('Eraser', () => {
|
|
test('Eraser on empty canvas adds no 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 painterWidget.getByRole('button', { name: 'Eraser' }).click()
|
|
await drawStroke(comfyPage.page, canvas)
|
|
await comfyPage.nextFrame()
|
|
|
|
await expect.poll(() => hasCanvasContent(canvas)).toBe(false)
|
|
})
|
|
})
|
|
|
|
test.describe('Serialization — unchanged canvas', () => {
|
|
test(
|
|
'Unchanged canvas does not re-upload on second serialization',
|
|
{ tag: '@slow' },
|
|
async ({ comfyPage }) => {
|
|
let uploadCount = 0
|
|
|
|
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)
|
|
})
|
|
})
|
|
|
|
const canvas = comfyPage.vueNodes
|
|
.getNodeLocator('1')
|
|
.locator('.widget-expands canvas')
|
|
|
|
await drawStroke(comfyPage.page, canvas)
|
|
await triggerSerialization(comfyPage.page)
|
|
expect(uploadCount, 'first serialization should upload once').toBe(1)
|
|
|
|
await triggerSerialization(comfyPage.page)
|
|
expect(
|
|
uploadCount,
|
|
'second serialization without new drawing should not re-upload'
|
|
).toBe(1)
|
|
}
|
|
)
|
|
})
|
|
|
|
test.describe('Settings persistence', () => {
|
|
test('Tool selection is saved to node properties', async ({
|
|
comfyPage
|
|
}) => {
|
|
const painterWidget = comfyPage.vueNodes
|
|
.getNodeLocator('1')
|
|
.locator('.widget-expands')
|
|
|
|
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
|
|
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
comfyPage.page.evaluate(() => {
|
|
const graph = window.graph as TestGraphAccess | undefined
|
|
return graph?._nodes_by_id?.['1']?.properties?.painterTool as
|
|
| string
|
|
| undefined
|
|
}),
|
|
{ message: 'painterTool property should update to eraser' }
|
|
)
|
|
.toBe('eraser')
|
|
})
|
|
|
|
test('Brush size change is saved to node properties', async ({
|
|
comfyPage
|
|
}) => {
|
|
const sizeRow = comfyPage.vueNodes
|
|
.getNodeLocator('1')
|
|
.locator('.widget-expands')
|
|
.getByTestId('painter-size-row')
|
|
const sizeSlider = sizeRow.getByRole('slider')
|
|
|
|
await expect(
|
|
sizeRow.getByTestId('painter-size-value'),
|
|
'brush size should start at default 20'
|
|
).toHaveText('20')
|
|
|
|
await sizeSlider.focus()
|
|
for (let i = 0; i < 10; i++) {
|
|
await sizeSlider.press('ArrowRight')
|
|
}
|
|
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
comfyPage.page.evaluate(() => {
|
|
const graph = window.graph as TestGraphAccess | undefined
|
|
return graph?._nodes_by_id?.['1']?.properties
|
|
?.painterBrushSize as number | undefined
|
|
}),
|
|
{ message: 'painterBrushSize property should update to 30' }
|
|
)
|
|
.toBe(30)
|
|
})
|
|
})
|
|
|
|
test('Controls stack label above widget in compact mode', async ({
|
|
comfyPage
|
|
}) => {
|
|
const painterWidget = comfyPage.vueNodes
|
|
.getNodeLocator('1')
|
|
.locator('.widget-expands')
|
|
const toolLabel = painterWidget.getByText('Tool', { exact: true })
|
|
const brushButton = painterWidget.getByText('Brush', { exact: true })
|
|
|
|
await expect(
|
|
toolLabel,
|
|
'tool label should be visible in wide layout'
|
|
).toBeVisible()
|
|
|
|
const wideLabelBox = await toolLabel.boundingBox()
|
|
const wideBrushBox = await brushButton.boundingBox()
|
|
expect(
|
|
wideLabelBox && wideBrushBox && wideLabelBox.x < wideBrushBox.x,
|
|
'label should sit to the left of the brush button in wide layout'
|
|
).toBe(true)
|
|
|
|
await comfyPage.page.evaluate(() => {
|
|
const graph = window.graph as TestGraphAccess | undefined
|
|
const node = graph?._nodes_by_id?.['1']
|
|
if (node) {
|
|
node.size = [200, 400]
|
|
window.app!.canvas.setDirty(true, true)
|
|
}
|
|
})
|
|
|
|
await expect(
|
|
toolLabel,
|
|
'tool label should remain visible in compact layout'
|
|
).toBeVisible()
|
|
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const labelBox = await toolLabel.boundingBox()
|
|
const brushBox = await brushButton.boundingBox()
|
|
if (!labelBox || !brushBox) return false
|
|
return labelBox.y + labelBox.height <= brushBox.y
|
|
},
|
|
{
|
|
message: 'label should stack above the brush button in compact layout'
|
|
}
|
|
)
|
|
.toBe(true)
|
|
})
|
|
|
|
test('Multiple sequential strokes at different positions all accumulate', async ({
|
|
comfyPage
|
|
}) => {
|
|
const canvas = comfyPage.vueNodes
|
|
.getNodeLocator('1')
|
|
.locator('.widget-expands canvas')
|
|
await expect(canvas).toBeVisible()
|
|
|
|
await drawStroke(comfyPage.page, canvas, { yPct: 0.25 })
|
|
await drawStroke(comfyPage.page, canvas, { yPct: 0.5 })
|
|
await drawStroke(comfyPage.page, canvas, { yPct: 0.75 })
|
|
await comfyPage.nextFrame()
|
|
|
|
const hasContentAtRow = (yFraction: number) =>
|
|
canvas.evaluate((el: HTMLCanvasElement, y: number) => {
|
|
const ctx = el.getContext('2d')
|
|
if (!ctx) return false
|
|
const cy = Math.floor(el.height * y)
|
|
const { data } = ctx.getImageData(0, cy - 5, el.width, 10)
|
|
for (let i = 3; i < data.length; i += 4) {
|
|
if (data[i] > 0) return true
|
|
}
|
|
return false
|
|
}, yFraction)
|
|
|
|
await expect
|
|
.poll(() => hasContentAtRow(0.25), {
|
|
message: 'top stroke should be present'
|
|
})
|
|
.toBe(true)
|
|
await expect
|
|
.poll(() => hasContentAtRow(0.5), {
|
|
message: 'middle stroke should be present'
|
|
})
|
|
.toBe(true)
|
|
await expect
|
|
.poll(() => hasContentAtRow(0.75), {
|
|
message: 'bottom stroke should be present'
|
|
})
|
|
.toBe(true)
|
|
})
|
|
})
|
|
|
|
test.describe(
|
|
'Painter legacy LiteGraph rendering',
|
|
{ tag: ['@widget', '@canvas'] },
|
|
() => {
|
|
test.beforeEach(async ({ comfyPage }) => {
|
|
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
|
await comfyPage.page.evaluate(() => window.app?.graph?.clear())
|
|
await comfyPage.workflow.loadWorkflow('widgets/painter_widget')
|
|
})
|
|
|
|
test('Does not open editors for backend-hidden number widget rows in legacy LiteGraph', async ({
|
|
comfyPage
|
|
}) => {
|
|
const painterNodes = await comfyPage.nodeOps.getNodeRefsByType('Painter')
|
|
expect(painterNodes).toHaveLength(1)
|
|
const painterNode = painterNodes[0]!
|
|
const maskWidget = await painterNode.getWidgetByName('mask')
|
|
const maskWidgetClientPosition = await maskWidget.getPosition()
|
|
const widgetRowClientHeight = await comfyPage.page.evaluate(
|
|
() =>
|
|
(window.LiteGraph!.NODE_WIDGET_HEIGHT + 4) *
|
|
window.app!.canvas.ds.scale
|
|
)
|
|
const legacyPrompt = comfyPage.page.locator('.graphdialog')
|
|
await expect(legacyPrompt).toBeHidden()
|
|
|
|
for (const [
|
|
index,
|
|
widgetName
|
|
] of HIDDEN_PAINTER_NUMBER_WIDGET_NAMES.entries()) {
|
|
await test.step(`Click ${widgetName} row`, async () => {
|
|
await comfyPage.page.mouse.click(
|
|
maskWidgetClientPosition.x,
|
|
maskWidgetClientPosition.y + widgetRowClientHeight * (index + 1)
|
|
)
|
|
await comfyPage.nextFrame()
|
|
await expect(legacyPrompt).toBeHidden()
|
|
})
|
|
}
|
|
})
|
|
}
|
|
)
|
|
|
|
test.describe(
|
|
'Painter — input image connection',
|
|
{ tag: ['@widget', '@vue-nodes', '@slow'] },
|
|
() => {
|
|
test.setTimeout(60_000)
|
|
|
|
test.beforeEach(async ({ comfyPage }) => {
|
|
await comfyPage.page.evaluate(() => window.app?.graph?.clear())
|
|
await comfyPage.workflow.loadWorkflow('widgets/painter_with_input')
|
|
})
|
|
|
|
test('Width, height, and bg_color controls hide when input is connected', async ({
|
|
comfyPage
|
|
}) => {
|
|
const painterWidget = comfyPage.vueNodes
|
|
.getNodeLocator('1')
|
|
.locator('.widget-expands')
|
|
|
|
await expect(
|
|
painterWidget.getByTestId('painter-width-row'),
|
|
'width row should be hidden when input is connected'
|
|
).toBeHidden()
|
|
await expect(
|
|
painterWidget.getByTestId('painter-height-row'),
|
|
'height row should be hidden when input is connected'
|
|
).toBeHidden()
|
|
await expect(
|
|
painterWidget.getByTestId('painter-bg-color-row'),
|
|
'background color row should be hidden when input is connected'
|
|
).toBeHidden()
|
|
await expect(
|
|
painterWidget.getByTestId('painter-dimension-text'),
|
|
'dimension text should be visible when input is connected'
|
|
).toBeVisible()
|
|
})
|
|
|
|
test('Canvas resizes to match input image dimensions after execution', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.runButton.click()
|
|
|
|
const node = comfyPage.vueNodes.getNodeLocator('1')
|
|
const img = node.locator('.widget-expands img')
|
|
await expect(
|
|
img,
|
|
'input image should appear after execution'
|
|
).toBeVisible({
|
|
timeout: 30_000
|
|
})
|
|
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
img.evaluate(
|
|
(el: HTMLImageElement) => el.complete && el.naturalWidth > 0
|
|
),
|
|
{
|
|
message: 'input image should be fully decoded',
|
|
timeout: 30_000
|
|
}
|
|
)
|
|
.toBe(true)
|
|
|
|
const { nw, nh } = await img.evaluate((el: HTMLImageElement) => ({
|
|
nw: el.naturalWidth,
|
|
nh: el.naturalHeight
|
|
}))
|
|
|
|
const canvas = node.locator('.widget-expands canvas')
|
|
await expect
|
|
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width), {
|
|
message: 'canvas width should match input image natural width'
|
|
})
|
|
.toBe(nw)
|
|
await expect
|
|
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.height), {
|
|
message: 'canvas height should match input image natural height'
|
|
})
|
|
.toBe(nh)
|
|
})
|
|
|
|
test('Drawing over input image produces content on canvas', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.runButton.click()
|
|
|
|
const node = comfyPage.vueNodes.getNodeLocator('1')
|
|
const img = node.locator('.widget-expands img')
|
|
await expect(
|
|
img,
|
|
'input image should appear after execution'
|
|
).toBeVisible({
|
|
timeout: 30_000
|
|
})
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
img.evaluate(
|
|
(el: HTMLImageElement) => el.complete && el.naturalWidth > 0
|
|
),
|
|
{ message: 'input image should be fully decoded', timeout: 30_000 }
|
|
)
|
|
.toBe(true)
|
|
|
|
const nw = await img.evaluate((el: HTMLImageElement) => el.naturalWidth)
|
|
const canvas = node.locator('.widget-expands canvas')
|
|
await expect
|
|
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width), {
|
|
message: 'canvas should resize to match input image width',
|
|
timeout: 15_000
|
|
})
|
|
.toBe(nw)
|
|
|
|
// Use dispatchEvent to bypass the LiteGraph canvas z-index overlay that
|
|
// intercepts coordinate-based hit testing from page.mouse
|
|
const box = await canvas.boundingBox()
|
|
if (!box) throw new Error('Canvas bounding box not found')
|
|
const startX = box.x + box.width * 0.3
|
|
const endX = box.x + box.width * 0.7
|
|
const midY = box.y + box.height * 0.5
|
|
const pointerOpts = {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
pointerId: 1,
|
|
button: 0,
|
|
isPrimary: true
|
|
}
|
|
await canvas.dispatchEvent('pointerdown', {
|
|
...pointerOpts,
|
|
clientX: startX,
|
|
clientY: midY
|
|
})
|
|
for (let i = 1; i <= 10; i++) {
|
|
await canvas.dispatchEvent('pointermove', {
|
|
...pointerOpts,
|
|
clientX: startX + (endX - startX) * (i / 10),
|
|
clientY: midY
|
|
})
|
|
}
|
|
await canvas.dispatchEvent('pointerup', {
|
|
...pointerOpts,
|
|
clientX: endX,
|
|
clientY: midY
|
|
})
|
|
|
|
await expect
|
|
.poll(() => hasCanvasContent(canvas), {
|
|
message: 'drawing over input image should produce canvas content'
|
|
})
|
|
.toBe(true)
|
|
})
|
|
}
|
|
)
|