mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-16 20:51:04 +00:00
Compare commits
8 Commits
test/curve
...
codex/clou
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
047d1a690e | ||
|
|
104936d0b0 | ||
|
|
59fceb3098 | ||
|
|
3ae9a83c4c | ||
|
|
bd82c855e0 | ||
|
|
5b7ef3fe21 | ||
|
|
85de833776 | ||
|
|
cab46567c0 |
50
browser_tests/assets/widgets/image_crop_widget.json
Normal file
50
browser_tests/assets/widgets/image_crop_widget.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "ImageCropV2",
|
||||
"pos": [50, 50],
|
||||
"size": [400, 500],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageCropV2"
|
||||
},
|
||||
"widgets_values": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 512,
|
||||
"height": 512
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
100
browser_tests/assets/widgets/image_crop_with_source.json
Normal file
100
browser_tests/assets/widgets/image_crop_with_source.json
Normal file
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 50],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [1]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["example.png", "image"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "ImageCropV2",
|
||||
"pos": [450, 50],
|
||||
"size": [400, 500],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageCropV2"
|
||||
},
|
||||
"widgets_values": [
|
||||
{
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
"width": 100,
|
||||
"height": 100
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "PreviewImage",
|
||||
"pos": [900, 50],
|
||||
"size": [315, 270],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewImage"
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 0, 2, 0, "IMAGE"],
|
||||
[2, 2, 0, 3, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -21,6 +21,10 @@ export const TestIds = {
|
||||
contextMenu: 'canvas-context-menu',
|
||||
toggleMinimapButton: 'toggle-minimap-button',
|
||||
closeMinimapButton: 'close-minimap-button',
|
||||
minimapContainer: 'minimap-container',
|
||||
minimapCanvas: 'minimap-canvas',
|
||||
minimapViewport: 'minimap-viewport',
|
||||
minimapInteractionOverlay: 'minimap-interaction-overlay',
|
||||
toggleLinkVisibilityButton: 'toggle-link-visibility-button',
|
||||
zoomControlsButton: 'zoom-controls-button',
|
||||
zoomInAction: 'zoom-in-action',
|
||||
|
||||
66
browser_tests/helpers/painter.ts
Normal file
66
browser_tests/helpers/painter.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import type { TestGraphAccess } from '@e2e/types/globals'
|
||||
|
||||
export async function drawStroke(
|
||||
page: Page,
|
||||
canvas: Locator,
|
||||
opts: { startXPct?: number; endXPct?: number; yPct?: number } = {}
|
||||
): Promise<void> {
|
||||
const { startXPct = 0.3, endXPct = 0.7, yPct = 0.5 } = opts
|
||||
const box = await canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not found')
|
||||
await page.mouse.move(
|
||||
box.x + box.width * startXPct,
|
||||
box.y + box.height * yPct
|
||||
)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(
|
||||
box.x + box.width * endXPct,
|
||||
box.y + box.height * yPct,
|
||||
{ steps: 10 }
|
||||
)
|
||||
await page.mouse.up()
|
||||
}
|
||||
|
||||
export async 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
|
||||
})
|
||||
}
|
||||
|
||||
export async function triggerSerialization(page: Page): Promise<void> {
|
||||
await page.evaluate(async () => {
|
||||
const graph = window.graph as TestGraphAccess | undefined
|
||||
if (!graph) {
|
||||
throw new Error(
|
||||
'Global window.graph is absent. Ensure workflow fixture is loaded.'
|
||||
)
|
||||
}
|
||||
|
||||
const node = graph._nodes_by_id?.['1']
|
||||
if (!node) {
|
||||
throw new Error(
|
||||
'Target node with ID "1" not found in graph._nodes_by_id.'
|
||||
)
|
||||
}
|
||||
|
||||
const widget = node.widgets?.find((w) => w.name === 'mask')
|
||||
if (!widget) {
|
||||
throw new Error('Widget "mask" not found on target node 1.')
|
||||
}
|
||||
|
||||
if (typeof widget.serializeValue !== 'function') {
|
||||
throw new Error(
|
||||
'mask widget on node 1 does not have a serializeValue function.'
|
||||
)
|
||||
}
|
||||
|
||||
await widget.serializeValue(node, 0)
|
||||
})
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Image Compare', () => {
|
||||
test.describe('Image Compare', { tag: '@widget' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('widgets/image_compare_widget')
|
||||
@@ -21,7 +22,12 @@ test.describe('Image Compare', () => {
|
||||
|
||||
async function setImageCompareValue(
|
||||
comfyPage: ComfyPage,
|
||||
value: { beforeImages: string[]; afterImages: string[] }
|
||||
value: {
|
||||
beforeImages: string[]
|
||||
afterImages: string[]
|
||||
beforeAlt?: string
|
||||
afterAlt?: string
|
||||
}
|
||||
) {
|
||||
await comfyPage.page.evaluate(
|
||||
({ value }) => {
|
||||
@@ -37,6 +43,48 @@ test.describe('Image Compare', () => {
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async function moveToPercentage(
|
||||
page: Page,
|
||||
containerLocator: Locator,
|
||||
percentage: number
|
||||
) {
|
||||
const box = await containerLocator.boundingBox()
|
||||
if (!box) throw new Error('Container not found')
|
||||
await page.mouse.move(
|
||||
box.x + box.width * (percentage / 100),
|
||||
box.y + box.height / 2
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForImagesLoaded(node: Locator) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
node.evaluate((el) => {
|
||||
const imgs = el.querySelectorAll('img')
|
||||
return (
|
||||
imgs.length > 0 &&
|
||||
Array.from(imgs).every(
|
||||
(img) => img.complete && img.naturalWidth > 0
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function getClipPathInsetRightPercent(imgLocator: Locator) {
|
||||
return imgLocator.evaluate((el) => {
|
||||
// Accessing raw style avoids cross-browser getComputedStyle normalization issues
|
||||
// Format is uniformly "inset(0 60% 0 0)" per Vue runtime inline style bindings
|
||||
const parts = (el as HTMLElement).style.clipPath.split(' ')
|
||||
return parts.length > 1 ? parseFloat(parts[1]) : -1
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test(
|
||||
'Shows empty state when no images are set',
|
||||
{ tag: '@smoke' },
|
||||
@@ -50,6 +98,10 @@ test.describe('Image Compare', () => {
|
||||
}
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slider defaults
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test(
|
||||
'Slider defaults to 50% with both images set',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
@@ -71,11 +123,440 @@ test.describe('Image Compare', () => {
|
||||
await expect(handle).toBeVisible()
|
||||
|
||||
expect(
|
||||
await handle.evaluate((el) => (el as HTMLElement).style.left)
|
||||
await handle.evaluate((el) => (el as HTMLElement).style.left),
|
||||
'Slider should default to 50% before screenshot'
|
||||
).toBe('50%')
|
||||
await expect(beforeImg).toHaveCSS('clip-path', /50%/)
|
||||
await expect
|
||||
.poll(() => getClipPathInsetRightPercent(beforeImg))
|
||||
.toBeCloseTo(50, 0)
|
||||
|
||||
await waitForImagesLoaded(node)
|
||||
await comfyPage.page.mouse.move(0, 0)
|
||||
await expect(node).toHaveScreenshot('image-compare-default-50.png')
|
||||
}
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slider interaction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test(
|
||||
'Mouse hover moves slider position',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const beforeUrl = createTestImageDataUrl('Before', '#c00')
|
||||
const afterUrl = createTestImageDataUrl('After', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [beforeUrl],
|
||||
afterImages: [afterUrl]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const handle = node.getByRole('presentation')
|
||||
const beforeImg = node.locator('img[alt="Before image"]')
|
||||
const afterImg = node.locator('img[alt="After image"]')
|
||||
await expect(afterImg).toBeVisible()
|
||||
|
||||
// Left edge: sliderPosition ≈ 5 → clip-path inset right ≈ 95%
|
||||
await moveToPercentage(comfyPage.page, afterImg, 5)
|
||||
await expect
|
||||
.poll(() => getClipPathInsetRightPercent(beforeImg))
|
||||
.toBeGreaterThan(90)
|
||||
await expect
|
||||
.poll(() =>
|
||||
handle.evaluate((el) => parseFloat((el as HTMLElement).style.left))
|
||||
)
|
||||
.toBeLessThan(10)
|
||||
|
||||
// Right edge: sliderPosition ≈ 95 → clip-path inset right ≈ 5%
|
||||
await moveToPercentage(comfyPage.page, afterImg, 95)
|
||||
await expect
|
||||
.poll(() => getClipPathInsetRightPercent(beforeImg))
|
||||
.toBeLessThan(10)
|
||||
await expect
|
||||
.poll(() =>
|
||||
handle.evaluate((el) => parseFloat((el as HTMLElement).style.left))
|
||||
)
|
||||
.toBeGreaterThan(90)
|
||||
}
|
||||
)
|
||||
|
||||
test('Slider preserves last position when mouse leaves widget', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const beforeUrl = createTestImageDataUrl('Before', '#c00')
|
||||
const afterUrl = createTestImageDataUrl('After', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [beforeUrl],
|
||||
afterImages: [afterUrl]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const handle = node.getByRole('presentation')
|
||||
const afterImg = node.locator('img[alt="After image"]')
|
||||
await expect(afterImg).toBeVisible()
|
||||
|
||||
await moveToPercentage(comfyPage.page, afterImg, 30)
|
||||
// Wait for Vue to commit the slider update
|
||||
await expect
|
||||
.poll(() =>
|
||||
handle.evaluate((el) => parseFloat((el as HTMLElement).style.left))
|
||||
)
|
||||
.toBeCloseTo(30, 0)
|
||||
const positionWhileInside = parseFloat(
|
||||
await handle.evaluate((el) => (el as HTMLElement).style.left)
|
||||
)
|
||||
|
||||
await comfyPage.page.mouse.move(0, 0)
|
||||
|
||||
// Position must not reset to default 50%
|
||||
await expect
|
||||
.poll(() =>
|
||||
handle.evaluate((el) => parseFloat((el as HTMLElement).style.left))
|
||||
)
|
||||
.toBeCloseTo(positionWhileInside, 0)
|
||||
})
|
||||
|
||||
test('Slider clamps to 0% at left edge of container', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const beforeUrl = createTestImageDataUrl('Before', '#c00')
|
||||
const afterUrl = createTestImageDataUrl('After', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [beforeUrl],
|
||||
afterImages: [afterUrl]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const handle = node.getByRole('presentation')
|
||||
const afterImg = node.locator('img[alt="After image"]')
|
||||
await expect(afterImg).toBeVisible()
|
||||
|
||||
const box = await afterImg.boundingBox()
|
||||
if (!box) throw new Error('Container not found')
|
||||
|
||||
// Move to the leftmost pixel (elementX = 0 → sliderPosition = 0)
|
||||
await comfyPage.page.mouse.move(box.x, box.y + box.height / 2)
|
||||
await expect
|
||||
.poll(() => handle.evaluate((el) => (el as HTMLElement).style.left))
|
||||
.toBe('0%')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single image modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('Only before image shows without slider when afterImages is empty', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const url = createTestImageDataUrl('Before', '#c00')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [url],
|
||||
afterImages: []
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node.locator('img')).toHaveCount(1)
|
||||
await expect(node.getByRole('presentation')).toBeHidden()
|
||||
})
|
||||
|
||||
test('Only after image shows without slider when beforeImages is empty', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const url = createTestImageDataUrl('After', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [],
|
||||
afterImages: [url]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node.locator('img')).toHaveCount(1)
|
||||
await expect(node.getByRole('presentation')).toBeHidden()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test(
|
||||
'Batch navigation appears when before side has multiple images',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const url1 = createTestImageDataUrl('A1', '#c00')
|
||||
const url2 = createTestImageDataUrl('A2', '#0c0')
|
||||
const url3 = createTestImageDataUrl('A3', '#00c')
|
||||
const afterUrl = createTestImageDataUrl('B1', '#888')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [url1, url2, url3],
|
||||
afterImages: [afterUrl]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeBatch = node.getByTestId('before-batch')
|
||||
|
||||
await expect(node.getByTestId('batch-nav')).toBeVisible()
|
||||
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('1 / 3')
|
||||
// after-batch renders only when afterBatchCount > 1
|
||||
await expect(node.getByTestId('after-batch')).toBeHidden()
|
||||
await expect(beforeBatch.getByTestId('batch-prev')).toBeDisabled()
|
||||
}
|
||||
)
|
||||
|
||||
test('Batch navigation is hidden when both sides have single images', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const url = createTestImageDataUrl('Image', '#c00')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [url],
|
||||
afterImages: [url]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node.getByTestId('batch-nav')).toBeHidden()
|
||||
})
|
||||
|
||||
test(
|
||||
'Navigate forward through before images',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const url1 = createTestImageDataUrl('A1', '#c00')
|
||||
const url2 = createTestImageDataUrl('A2', '#0c0')
|
||||
const url3 = createTestImageDataUrl('A3', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [url1, url2, url3],
|
||||
afterImages: [createTestImageDataUrl('B1', '#888')]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeBatch = node.getByTestId('before-batch')
|
||||
const counter = beforeBatch.getByTestId('batch-counter')
|
||||
const nextBtn = beforeBatch.getByTestId('batch-next')
|
||||
const prevBtn = beforeBatch.getByTestId('batch-prev')
|
||||
|
||||
await nextBtn.click()
|
||||
await expect(counter).toHaveText('2 / 3')
|
||||
await expect(node.locator('img[alt="Before image"]')).toHaveAttribute(
|
||||
'src',
|
||||
url2
|
||||
)
|
||||
await expect(prevBtn).toBeEnabled()
|
||||
|
||||
await nextBtn.click()
|
||||
await expect(counter).toHaveText('3 / 3')
|
||||
await expect(nextBtn).toBeDisabled()
|
||||
}
|
||||
)
|
||||
|
||||
test('Navigate backward through before images', async ({ comfyPage }) => {
|
||||
const url1 = createTestImageDataUrl('A1', '#c00')
|
||||
const url2 = createTestImageDataUrl('A2', '#0c0')
|
||||
const url3 = createTestImageDataUrl('A3', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [url1, url2, url3],
|
||||
afterImages: [createTestImageDataUrl('B1', '#888')]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeBatch = node.getByTestId('before-batch')
|
||||
const counter = beforeBatch.getByTestId('batch-counter')
|
||||
const nextBtn = beforeBatch.getByTestId('batch-next')
|
||||
const prevBtn = beforeBatch.getByTestId('batch-prev')
|
||||
|
||||
await nextBtn.click()
|
||||
await nextBtn.click()
|
||||
await expect(counter).toHaveText('3 / 3')
|
||||
|
||||
await prevBtn.click()
|
||||
await expect(counter).toHaveText('2 / 3')
|
||||
await expect(prevBtn).toBeEnabled()
|
||||
await expect(nextBtn).toBeEnabled()
|
||||
})
|
||||
|
||||
test('Before and after batch navigation are independent', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const url1 = createTestImageDataUrl('A1', '#c00')
|
||||
const url2 = createTestImageDataUrl('A2', '#0c0')
|
||||
const url3 = createTestImageDataUrl('A3', '#00c')
|
||||
const urlA = createTestImageDataUrl('B1', '#880')
|
||||
const urlB = createTestImageDataUrl('B2', '#008')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [url1, url2, url3],
|
||||
afterImages: [urlA, urlB]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeBatch = node.getByTestId('before-batch')
|
||||
const afterBatch = node.getByTestId('after-batch')
|
||||
|
||||
await beforeBatch.getByTestId('batch-next').click()
|
||||
await afterBatch.getByTestId('batch-next').click()
|
||||
|
||||
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('2 / 3')
|
||||
await expect(afterBatch.getByTestId('batch-counter')).toHaveText('2 / 2')
|
||||
await expect(node.locator('img[alt="Before image"]')).toHaveAttribute(
|
||||
'src',
|
||||
url2
|
||||
)
|
||||
await expect(node.locator('img[alt="After image"]')).toHaveAttribute(
|
||||
'src',
|
||||
urlB
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Visual regression screenshots
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
for (const { pct, expectedClipMin, expectedClipMax } of [
|
||||
{ pct: 25, expectedClipMin: 70, expectedClipMax: 80 },
|
||||
{ pct: 75, expectedClipMin: 20, expectedClipMax: 30 }
|
||||
]) {
|
||||
test(
|
||||
`Screenshot at ${pct}% slider position`,
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const beforeUrl = createTestImageDataUrl('Before', '#c00')
|
||||
const afterUrl = createTestImageDataUrl('After', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [beforeUrl],
|
||||
afterImages: [afterUrl]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeImg = node.locator('img[alt="Before image"]')
|
||||
const afterImg = node.locator('img[alt="After image"]')
|
||||
await waitForImagesLoaded(node)
|
||||
await moveToPercentage(comfyPage.page, afterImg, pct)
|
||||
await expect
|
||||
.poll(() => getClipPathInsetRightPercent(beforeImg))
|
||||
.toBeGreaterThan(expectedClipMin)
|
||||
await expect
|
||||
.poll(() => getClipPathInsetRightPercent(beforeImg))
|
||||
.toBeLessThan(expectedClipMax)
|
||||
|
||||
await expect(node).toHaveScreenshot(`image-compare-slider-${pct}.png`)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('Widget remains stable with broken image URLs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: ['https://example.invalid/broken.png'],
|
||||
afterImages: ['https://example.invalid/broken2.png']
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node.locator('img')).toHaveCount(2)
|
||||
await expect(node.getByRole('presentation')).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
node.evaluate((el) => {
|
||||
const imgs = el.querySelectorAll('img')
|
||||
let errors = 0
|
||||
imgs.forEach((img) => {
|
||||
if (img.complete && img.naturalWidth === 0 && img.src) errors++
|
||||
})
|
||||
return errors
|
||||
})
|
||||
)
|
||||
.toBe(2)
|
||||
})
|
||||
|
||||
test('Rapid value updates show latest images and reset batch index', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const redUrl = createTestImageDataUrl('Red', '#c00')
|
||||
const green1Url = createTestImageDataUrl('G1', '#0c0')
|
||||
const green2Url = createTestImageDataUrl('G2', '#090')
|
||||
const blueUrl = createTestImageDataUrl('Blue', '#00c')
|
||||
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [redUrl, green1Url],
|
||||
afterImages: [blueUrl]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await node.getByTestId('before-batch').getByTestId('batch-next').click()
|
||||
await expect(
|
||||
node.getByTestId('before-batch').getByTestId('batch-counter')
|
||||
).toHaveText('2 / 2')
|
||||
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [green1Url, green2Url],
|
||||
afterImages: [blueUrl]
|
||||
})
|
||||
|
||||
await expect(node.locator('img[alt="Before image"]')).toHaveAttribute(
|
||||
'src',
|
||||
green1Url
|
||||
)
|
||||
await expect(
|
||||
node.getByTestId('before-batch').getByTestId('batch-counter')
|
||||
).toHaveText('1 / 2')
|
||||
})
|
||||
|
||||
test('Legacy string value shows single image without slider', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const url = createTestImageDataUrl('Legacy', '#c00')
|
||||
await comfyPage.page.evaluate(
|
||||
({ url }) => {
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
const widget = node?.widgets?.find((w) => w.type === 'imagecompare')
|
||||
if (widget) {
|
||||
widget.value = url
|
||||
widget.callback?.(url)
|
||||
}
|
||||
},
|
||||
{ url }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node.locator('img')).toHaveCount(1)
|
||||
await expect(node.getByRole('presentation')).toBeHidden()
|
||||
})
|
||||
|
||||
test('Custom beforeAlt and afterAlt are used as img alt text', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const beforeUrl = createTestImageDataUrl('Before', '#c00')
|
||||
const afterUrl = createTestImageDataUrl('After', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [beforeUrl],
|
||||
afterImages: [afterUrl],
|
||||
beforeAlt: 'Custom before',
|
||||
afterAlt: 'Custom after'
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node.locator('img[alt="Custom before"]')).toBeVisible()
|
||||
await expect(node.locator('img[alt="Custom after"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Large batch sizes show correct counter', async ({ comfyPage }) => {
|
||||
const images = Array.from({ length: 20 }, (_, i) =>
|
||||
createTestImageDataUrl(String(i + 1), '#c00')
|
||||
)
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: images,
|
||||
afterImages: images
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(
|
||||
node.getByTestId('before-batch').getByTestId('batch-counter')
|
||||
).toHaveText('1 / 20')
|
||||
await expect(
|
||||
node.getByTestId('after-batch').getByTestId('batch-counter')
|
||||
).toHaveText('1 / 20')
|
||||
})
|
||||
})
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -1,8 +1,38 @@
|
||||
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')
|
||||
@@ -13,14 +43,20 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
})
|
||||
|
||||
test('Validate minimap is visible by default', async ({ comfyPage }) => {
|
||||
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
|
||||
const minimapContainer = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
|
||||
const minimapCanvas = minimapContainer.locator('.minimap-canvas')
|
||||
const minimapCanvas = minimapContainer.getByTestId(
|
||||
TestIds.canvas.minimapCanvas
|
||||
)
|
||||
await expect(minimapCanvas).toBeVisible()
|
||||
|
||||
const minimapViewport = minimapContainer.locator('.minimap-viewport')
|
||||
const minimapViewport = minimapContainer.getByTestId(
|
||||
TestIds.canvas.minimapViewport
|
||||
)
|
||||
await expect(minimapViewport).toBeVisible()
|
||||
|
||||
await expect(minimapContainer).toHaveCSS('position', 'relative')
|
||||
@@ -40,12 +76,16 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
|
||||
await expect(toggleButton).toBeVisible()
|
||||
|
||||
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
|
||||
const minimapContainer = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
})
|
||||
|
||||
test('Validate minimap can be toggled off and on', async ({ comfyPage }) => {
|
||||
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
|
||||
const minimapContainer = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
const toggleButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.toggleMinimapButton
|
||||
)
|
||||
@@ -60,7 +100,9 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
})
|
||||
|
||||
test('Validate minimap keyboard shortcut Alt+M', async ({ comfyPage }) => {
|
||||
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
|
||||
const minimapContainer = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
|
||||
@@ -72,7 +114,7 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
})
|
||||
|
||||
test('Close button hides minimap', async ({ comfyPage }) => {
|
||||
const minimap = comfyPage.page.locator('.litegraph-minimap')
|
||||
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
await comfyPage.page.getByTestId(TestIds.canvas.closeMinimapButton).click()
|
||||
@@ -88,7 +130,9 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
'Panning canvas moves minimap viewport',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const minimap = comfyPage.page.locator('.litegraph-minimap')
|
||||
const minimap = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
await expect(minimap).toHaveScreenshot('minimap-before-pan.png')
|
||||
@@ -105,14 +149,135 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
}
|
||||
)
|
||||
|
||||
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.locator('.litegraph-minimap')
|
||||
const minimap = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
const viewport = minimap.locator('.minimap-viewport')
|
||||
const viewport = minimap.getByTestId(TestIds.canvas.minimapViewport)
|
||||
await expect(viewport).toBeVisible()
|
||||
|
||||
await expect(async () => {
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
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/helpers/painter'
|
||||
|
||||
test.describe('Painter', () => {
|
||||
test.describe('Painter', { tag: '@widget' }, () => {
|
||||
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()
|
||||
@@ -20,9 +28,15 @@ test.describe('Painter', () => {
|
||||
await expect(painterWidget).toBeVisible()
|
||||
|
||||
await expect(painterWidget.locator('canvas')).toBeVisible()
|
||||
await expect(painterWidget.getByText('Brush')).toBeVisible()
|
||||
await expect(painterWidget.getByText('Eraser')).toBeVisible()
|
||||
await expect(painterWidget.getByText('Clear')).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()
|
||||
@@ -39,22 +53,66 @@ test.describe('Painter', () => {
|
||||
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(async () =>
|
||||
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))
|
||||
})
|
||||
)
|
||||
.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')
|
||||
|
||||
@@ -68,29 +126,250 @@ test.describe('Painter', () => {
|
||||
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(async () =>
|
||||
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
|
||||
})
|
||||
)
|
||||
.poll(() => hasCanvasContent(canvas), {
|
||||
message:
|
||||
'canvas should have content after stroke with pointer up outside'
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
await expect(node).toHaveScreenshot('painter-after-stroke.png')
|
||||
}
|
||||
)
|
||||
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('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))
|
||||
.toBe(576)
|
||||
})
|
||||
|
||||
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.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 does not upload 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 not upload').toBe(0)
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
162
browser_tests/tests/vueNodes/widgets/imageCrop.spec.ts
Normal file
162
browser_tests/tests/vueNodes/widgets/imageCrop.spec.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
type CropValue = { x: number; y: number; width: number; height: number } | null
|
||||
|
||||
test.describe('Image Crop', { tag: '@widget' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test.describe('without source image', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
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.soft(node.getByTestId('crop-empty-icon')).toBeVisible()
|
||||
await expect.soft(node).toContainText('No input image connected')
|
||||
await expect.soft(node.getByTestId('crop-overlay')).toHaveCount(0)
|
||||
await expect.soft(node.locator('img')).toHaveCount(0)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Renders controls in default state',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
await expect(node.getByText('Ratio')).toBeVisible()
|
||||
await expect(
|
||||
node.locator('button:has(.icon-\\[lucide--lock-open\\])')
|
||||
).toBeVisible()
|
||||
await expect(node.locator('input')).toHaveCount(4)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe(
|
||||
'with source image after execution',
|
||||
{ tag: ['@widget', '@slow'] },
|
||||
() => {
|
||||
async function getCropValue(comfyPage: ComfyPage): Promise<CropValue> {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const n = window.app!.graph.getNodeById(2)
|
||||
const w = n?.widgets?.find((w) => w.type === 'imagecrop')
|
||||
const v = w?.value
|
||||
if (v && typeof v === 'object' && 'x' in v) {
|
||||
const crop = v as {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
return {
|
||||
x: crop.x,
|
||||
y: crop.y,
|
||||
width: crop.width,
|
||||
height: crop.height
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/image_crop_with_source')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.runButton.click()
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeLocator('2').locator('img')
|
||||
).toBeVisible({ timeout: 30_000 })
|
||||
})
|
||||
|
||||
test(
|
||||
'Displays source image with crop overlay after execution',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const img = node.locator('img')
|
||||
|
||||
await expect
|
||||
.poll(() => img.evaluate((el: HTMLImageElement) => el.naturalWidth))
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
await expect(node.getByTestId('crop-overlay')).toBeVisible()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(node).toHaveScreenshot('image-crop-with-source.png', {
|
||||
maxDiffPixelRatio: 0.05
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Drag crop box updates crop position',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const cropBox = node.getByTestId('crop-overlay')
|
||||
const box = await cropBox.boundingBox()
|
||||
if (!box) throw new Error('Crop box not found')
|
||||
|
||||
const valueBefore = await getCropValue(comfyPage)
|
||||
if (!valueBefore)
|
||||
throw new Error('Widget value missing — check fixture setup')
|
||||
|
||||
const startX = box.x + box.width / 2
|
||||
const startY = box.y + box.height / 2
|
||||
|
||||
const pointerOpts = { bubbles: true, cancelable: true, pointerId: 1 }
|
||||
await cropBox.dispatchEvent('pointerdown', {
|
||||
...pointerOpts,
|
||||
clientX: startX,
|
||||
clientY: startY
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await cropBox.dispatchEvent('pointermove', {
|
||||
...pointerOpts,
|
||||
clientX: startX + 15,
|
||||
clientY: startY + 10
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await cropBox.dispatchEvent('pointermove', {
|
||||
...pointerOpts,
|
||||
clientX: startX + 30,
|
||||
clientY: startY + 20
|
||||
})
|
||||
await cropBox.dispatchEvent('pointerup', {
|
||||
...pointerOpts,
|
||||
clientX: startX + 30,
|
||||
clientY: startY + 20
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(async () => {
|
||||
const valueAfter = await getCropValue(comfyPage)
|
||||
expect(valueAfter?.x).toBeGreaterThan(valueBefore.x)
|
||||
expect(valueAfter?.y).toBeGreaterThan(valueBefore.y)
|
||||
expect(valueAfter?.width).toBe(valueBefore.width)
|
||||
expect(valueAfter?.height).toBe(valueBefore.height)
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
await expect(node).toHaveScreenshot('image-crop-after-drag.png', {
|
||||
maxDiffPixelRatio: 0.05
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -17,8 +17,12 @@
|
||||
<div
|
||||
v-else-if="!imageUrl"
|
||||
class="flex size-full flex-col items-center justify-center text-center"
|
||||
data-testid="crop-empty-state"
|
||||
>
|
||||
<i class="mb-2 icon-[lucide--image] size-12" />
|
||||
<i
|
||||
class="mb-2 icon-[lucide--image] size-12"
|
||||
data-testid="crop-empty-icon"
|
||||
/>
|
||||
<p class="text-sm">{{ $t('imageCrop.noInputImage') }}</p>
|
||||
</div>
|
||||
|
||||
@@ -43,6 +47,7 @@
|
||||
)
|
||||
"
|
||||
:style="cropBoxStyle"
|
||||
data-testid="crop-overlay"
|
||||
@pointerdown="handleDragStart"
|
||||
@pointermove="handleDragMove"
|
||||
@pointerup="handleDragEnd"
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
<div
|
||||
v-if="isImageInputConnected"
|
||||
class="text-center text-xs text-muted-foreground"
|
||||
data-testid="painter-dimension-text"
|
||||
>
|
||||
{{ canvasWidth }} x {{ canvasHeight }}
|
||||
</div>
|
||||
@@ -100,6 +101,7 @@
|
||||
</div>
|
||||
<div
|
||||
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
|
||||
data-testid="painter-size-row"
|
||||
>
|
||||
<Slider
|
||||
:model-value="[brushSize]"
|
||||
@@ -109,9 +111,12 @@
|
||||
class="flex-1"
|
||||
@update:model-value="(v) => v?.length && (brushSize = v[0])"
|
||||
/>
|
||||
<span class="text-node-text-muted w-8 text-center text-xs">{{
|
||||
brushSize
|
||||
}}</span>
|
||||
<span
|
||||
class="text-node-text-muted w-8 text-center text-xs"
|
||||
data-testid="painter-size-value"
|
||||
>
|
||||
{{ brushSize }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<template v-if="tool === PAINTER_TOOLS.BRUSH">
|
||||
@@ -123,6 +128,7 @@
|
||||
</div>
|
||||
<div
|
||||
class="flex h-8 w-full items-center gap-2 rounded-lg bg-component-node-widget-background px-4"
|
||||
data-testid="painter-color-row"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
@@ -166,6 +172,7 @@
|
||||
</div>
|
||||
<div
|
||||
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
|
||||
data-testid="painter-hardness-row"
|
||||
>
|
||||
<Slider
|
||||
:model-value="[brushHardnessPercent]"
|
||||
@@ -192,6 +199,7 @@
|
||||
</div>
|
||||
<div
|
||||
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
|
||||
data-testid="painter-width-row"
|
||||
>
|
||||
<Slider
|
||||
:model-value="[canvasWidth]"
|
||||
@@ -214,6 +222,7 @@
|
||||
</div>
|
||||
<div
|
||||
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
|
||||
data-testid="painter-height-row"
|
||||
>
|
||||
<Slider
|
||||
:model-value="[canvasHeight]"
|
||||
@@ -255,6 +264,7 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
data-testid="painter-clear-button"
|
||||
:class="
|
||||
cn(
|
||||
'gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
@@ -9,6 +9,9 @@ import type { HasInitialMinSize } from '@/services/litegraphService'
|
||||
|
||||
setActivePinia(createTestingPinia())
|
||||
type DynamicInputs = ('INT' | 'STRING' | 'IMAGE' | DynamicInputs)[][]
|
||||
type TestAutogrowNode = LGraphNode & {
|
||||
comfyDynamic: { autogrow: Record<string, unknown> }
|
||||
}
|
||||
|
||||
const { addNodeInput } = useLitegraphService()
|
||||
|
||||
@@ -182,6 +185,45 @@ describe('Autogrow', () => {
|
||||
await nextTick()
|
||||
expect(node.inputs.length).toBe(5)
|
||||
})
|
||||
test('Removing a connection ignores stale autogrow callbacks after group removal', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode() as TestAutogrowNode
|
||||
const onConnectionsChange = vi.fn()
|
||||
node.onConnectionsChange = onConnectionsChange
|
||||
graph.add(node)
|
||||
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'test' })
|
||||
|
||||
const rafCallbacks: FrameRequestCallback[] = []
|
||||
const requestAnimationFrameSpy = vi
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockImplementation((callback) => {
|
||||
rafCallbacks.push(callback)
|
||||
return rafCallbacks.length
|
||||
})
|
||||
|
||||
try {
|
||||
connectInput(node, 0, graph)
|
||||
expect(node.inputs.length).toBe(2)
|
||||
|
||||
rafCallbacks.shift()?.(0)
|
||||
|
||||
node.disconnectInput(0)
|
||||
|
||||
const staleDisconnectCallback = rafCallbacks.shift()
|
||||
expect(staleDisconnectCallback).toBeDefined()
|
||||
|
||||
delete node.comfyDynamic.autogrow['0']
|
||||
|
||||
const callbackCountBeforeFlush = onConnectionsChange.mock.calls.length
|
||||
staleDisconnectCallback?.(0)
|
||||
|
||||
expect(onConnectionsChange).toHaveBeenCalledTimes(
|
||||
callbackCountBeforeFlush
|
||||
)
|
||||
} finally {
|
||||
requestAnimationFrameSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
test('Can deserialize a complex node', async () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
|
||||
@@ -460,7 +460,10 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
|
||||
const input = node.inputs[index]
|
||||
if (!input) return
|
||||
const groupName = input.name.slice(0, input.name.lastIndexOf('.'))
|
||||
const { min = 1, inputSpecs } = node.comfyDynamic.autogrow[groupName]
|
||||
const autogrowGroup = node.comfyDynamic.autogrow[groupName]
|
||||
if (!autogrowGroup) return
|
||||
|
||||
const { min = 1, inputSpecs } = autogrowGroup
|
||||
const ordinal = resolveAutogrowOrdinal(input.name, groupName, node)
|
||||
if (ordinal == undefined || ordinal + 1 < min) return
|
||||
|
||||
|
||||
@@ -1,51 +1,16 @@
|
||||
import { shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LLink } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LLink } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { applyFirstWidgetValueToGraph } from './widgetValuePropagation'
|
||||
|
||||
function applyToGraph(this: LGraphNode, extraLinks: LLink[] = []) {
|
||||
if (!this.outputs[0].links?.length || !this.graph) return
|
||||
|
||||
const links = [
|
||||
...this.outputs[0].links.map((l) => this.graph!.links[l]),
|
||||
...extraLinks
|
||||
]
|
||||
let v = this.widgets?.[0].value
|
||||
// For each output link copy our value over the original widget value
|
||||
for (const linkInfo of links) {
|
||||
const node = this.graph?.getNodeById(linkInfo.target_id)
|
||||
const input = node?.inputs[linkInfo.target_slot]
|
||||
if (!input) {
|
||||
console.warn('Unable to resolve node or input for link', linkInfo)
|
||||
continue
|
||||
}
|
||||
|
||||
const widgetName = input.widget?.name
|
||||
if (!widgetName) {
|
||||
console.warn('Invalid widget or widget name', input.widget)
|
||||
continue
|
||||
}
|
||||
|
||||
const widget = node.widgets?.find((w) => w.name === widgetName)
|
||||
if (!widget) {
|
||||
console.warn(`Unable to find widget "${widgetName}" on node [${node.id}]`)
|
||||
continue
|
||||
}
|
||||
|
||||
widget.value = v
|
||||
widget.callback?.(
|
||||
widget.value,
|
||||
app.canvas,
|
||||
node,
|
||||
app.canvas.graph_mouse,
|
||||
{} as CanvasPointerEvent
|
||||
)
|
||||
}
|
||||
applyFirstWidgetValueToGraph(this, extraLinks)
|
||||
}
|
||||
|
||||
function onCustomComboCreated(this: LGraphNode) {
|
||||
|
||||
@@ -26,6 +26,8 @@ import { CONFIG, GET_CONFIG } from '@/services/litegraphService'
|
||||
import { mergeInputSpec } from '@/utils/nodeDefUtil'
|
||||
import { applyTextReplacements } from '@/utils/searchAndReplace'
|
||||
|
||||
import { applyFirstWidgetValueToGraph } from './widgetValuePropagation'
|
||||
|
||||
const replacePropertyName = 'Run widget replace on values'
|
||||
export class PrimitiveNode extends LGraphNode {
|
||||
controlValues?: TWidgetValue[]
|
||||
@@ -43,49 +45,15 @@ export class PrimitiveNode extends LGraphNode {
|
||||
}
|
||||
|
||||
override applyToGraph(extraLinks: LLink[] = []) {
|
||||
if (!this.outputs[0].links?.length || !this.graph) return
|
||||
const sourceWidget = this.widgets?.[0]
|
||||
const graph = this.graph
|
||||
if (!sourceWidget || !graph) return
|
||||
|
||||
const links = [
|
||||
...this.outputs[0].links.map((l) => this.graph!.links[l]),
|
||||
...extraLinks
|
||||
]
|
||||
let v = this.widgets?.[0].value
|
||||
let v = sourceWidget.value
|
||||
if (v && this.properties[replacePropertyName]) {
|
||||
v = applyTextReplacements(this.graph, v as string)
|
||||
}
|
||||
|
||||
// For each output link copy our value over the original widget value
|
||||
for (const linkInfo of links) {
|
||||
const node = this.graph?.getNodeById(linkInfo.target_id)
|
||||
const input = node?.inputs[linkInfo.target_slot]
|
||||
if (!input) {
|
||||
console.warn('Unable to resolve node or input for link', linkInfo)
|
||||
continue
|
||||
}
|
||||
|
||||
const widgetName = input.widget?.name
|
||||
if (!widgetName) {
|
||||
console.warn('Invalid widget or widget name', input.widget)
|
||||
continue
|
||||
}
|
||||
|
||||
const widget = node.widgets?.find((w) => w.name === widgetName)
|
||||
if (!widget) {
|
||||
console.warn(
|
||||
`Unable to find widget "${widgetName}" on node [${node.id}]`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
widget.value = v
|
||||
widget.callback?.(
|
||||
widget.value,
|
||||
app.canvas,
|
||||
node,
|
||||
app.canvas.graph_mouse,
|
||||
{} as CanvasPointerEvent
|
||||
)
|
||||
v = applyTextReplacements(graph, v as string)
|
||||
}
|
||||
applyFirstWidgetValueToGraph(this, extraLinks, () => v)
|
||||
}
|
||||
|
||||
override refreshComboInNode() {
|
||||
@@ -98,7 +66,7 @@ export class PrimitiveNode extends LGraphNode {
|
||||
if (!widget.options.values.includes(widget.value as string)) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
widget.value = widget.options.values[0]
|
||||
;(widget.callback as Function)(widget.value)
|
||||
widget.callback?.(widget.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -273,7 +241,7 @@ export class PrimitiveNode extends LGraphNode {
|
||||
)
|
||||
if (this.widgets?.[1]) widget.linkedWidgets = [this.widgets[1]]
|
||||
|
||||
let filter = this.widgets_values?.[2]
|
||||
const filter = this.widgets_values?.[2]
|
||||
if (filter && this.widgets && this.widgets.length === 3) {
|
||||
this.widgets[2].value = filter
|
||||
}
|
||||
|
||||
127
src/extensions/core/widgetValuePropagation.test.ts
Normal file
127
src/extensions/core/widgetValuePropagation.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
LLink
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { createMockLLink } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: {
|
||||
graph_mouse: {}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
import { applyFirstWidgetValueToGraph } from './widgetValuePropagation'
|
||||
|
||||
type SourceNode = Pick<LGraphNode, 'graph' | 'outputs' | 'widgets'>
|
||||
|
||||
function createWidget(
|
||||
name: string,
|
||||
value: IBaseWidget['value'],
|
||||
callback = vi.fn()
|
||||
): IBaseWidget {
|
||||
return fromPartial<IBaseWidget>({
|
||||
name,
|
||||
value,
|
||||
callback
|
||||
})
|
||||
}
|
||||
|
||||
function createTargetNode(
|
||||
widget: IBaseWidget,
|
||||
id = 7
|
||||
): Pick<LGraphNode, 'id' | 'inputs' | 'widgets'> {
|
||||
return fromPartial<Pick<LGraphNode, 'id' | 'inputs' | 'widgets'>>({
|
||||
id,
|
||||
inputs: [
|
||||
fromPartial<INodeInputSlot>({
|
||||
widget: { name: widget.name }
|
||||
})
|
||||
],
|
||||
widgets: [widget]
|
||||
})
|
||||
}
|
||||
|
||||
function createLink(targetId: LLink['target_id'], targetSlot = 0): LLink {
|
||||
return createMockLLink({
|
||||
target_id: targetId,
|
||||
target_slot: targetSlot
|
||||
})
|
||||
}
|
||||
|
||||
function createSourceNode(options: {
|
||||
link: LLink
|
||||
targetNode: Pick<LGraphNode, 'id' | 'inputs' | 'widgets'>
|
||||
widgets?: IBaseWidget[]
|
||||
}): SourceNode {
|
||||
return {
|
||||
graph: {
|
||||
links: { 1: options.link },
|
||||
getNodeById: vi.fn((id: LLink['target_id']) =>
|
||||
id === options.targetNode.id ? options.targetNode : null
|
||||
)
|
||||
} as unknown as NonNullable<LGraphNode['graph']>,
|
||||
outputs: [{ links: [1] } as INodeOutputSlot],
|
||||
widgets: options.widgets ?? []
|
||||
}
|
||||
}
|
||||
|
||||
describe('applyFirstWidgetValueToGraph', () => {
|
||||
it('returns early when the source widget is missing', () => {
|
||||
const targetCallback = vi.fn()
|
||||
const targetWidget = createWidget('value', 'unchanged', targetCallback)
|
||||
const targetNode = createTargetNode(targetWidget)
|
||||
const sourceNode = createSourceNode({
|
||||
link: createLink(targetNode.id),
|
||||
targetNode
|
||||
})
|
||||
|
||||
expect(() => applyFirstWidgetValueToGraph(sourceNode)).not.toThrow()
|
||||
expect(targetWidget.value).toBe('unchanged')
|
||||
expect(targetCallback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('propagates the first widget value to the linked widget', () => {
|
||||
const targetCallback = vi.fn()
|
||||
const targetWidget = createWidget('value', 'old', targetCallback)
|
||||
const targetNode = createTargetNode(targetWidget)
|
||||
const sourceNode = createSourceNode({
|
||||
link: createLink(targetNode.id),
|
||||
targetNode,
|
||||
widgets: [createWidget('source', 'new value')]
|
||||
})
|
||||
|
||||
applyFirstWidgetValueToGraph(sourceNode)
|
||||
|
||||
expect(targetWidget.value).toBe('new value')
|
||||
expect(targetCallback).toHaveBeenCalledOnce()
|
||||
expect(targetCallback).toHaveBeenCalledWith(
|
||||
'new value',
|
||||
expect.anything(),
|
||||
targetNode,
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('applies a transform before propagating the widget value', () => {
|
||||
const targetWidget = createWidget('value', 'old')
|
||||
const targetNode = createTargetNode(targetWidget)
|
||||
const sourceNode = createSourceNode({
|
||||
link: createLink(targetNode.id),
|
||||
targetNode,
|
||||
widgets: [createWidget('source', 'draft')]
|
||||
})
|
||||
|
||||
applyFirstWidgetValueToGraph(sourceNode, [], (value) => `${value}-saved`)
|
||||
|
||||
expect(targetWidget.value).toBe('draft-saved')
|
||||
})
|
||||
})
|
||||
67
src/extensions/core/widgetValuePropagation.ts
Normal file
67
src/extensions/core/widgetValuePropagation.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { LLink } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
type SourceNode = Pick<LGraphNode, 'graph' | 'outputs' | 'widgets'>
|
||||
|
||||
export function applyFirstWidgetValueToGraph(
|
||||
node: SourceNode,
|
||||
extraLinks: LLink[] = [],
|
||||
transformValue?: (value: TWidgetValue) => TWidgetValue
|
||||
) {
|
||||
const output = node.outputs[0]
|
||||
if (!output?.links?.length || !node.graph) return
|
||||
|
||||
const sourceWidget = node.widgets?.[0]
|
||||
if (!sourceWidget) return
|
||||
|
||||
let value = sourceWidget.value
|
||||
if (transformValue) {
|
||||
value = transformValue(value)
|
||||
}
|
||||
|
||||
const graphMouse = app.canvas?.graph_mouse ?? ({} as CanvasPointerEvent)
|
||||
|
||||
const links = [
|
||||
...output.links.map((linkId) => node.graph!.links[linkId]),
|
||||
...extraLinks
|
||||
]
|
||||
|
||||
for (const linkInfo of links) {
|
||||
if (!linkInfo) continue
|
||||
|
||||
const targetNode = node.graph.getNodeById(linkInfo.target_id)
|
||||
const input = targetNode?.inputs[linkInfo.target_slot]
|
||||
if (!targetNode || !input) {
|
||||
console.warn('Unable to resolve node or input for link', linkInfo)
|
||||
continue
|
||||
}
|
||||
|
||||
const widgetName = input.widget?.name
|
||||
if (!widgetName) {
|
||||
console.warn('Invalid widget or widget name', input.widget)
|
||||
continue
|
||||
}
|
||||
|
||||
const targetWidget = targetNode.widgets?.find(
|
||||
(widget) => widget.name === widgetName
|
||||
)
|
||||
if (!targetWidget) {
|
||||
console.warn(
|
||||
`Unable to find widget "${widgetName}" on node [${targetNode.id}]`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
targetWidget.value = value
|
||||
targetWidget.callback?.(
|
||||
targetWidget.value,
|
||||
app.canvas,
|
||||
targetNode,
|
||||
graphMouse,
|
||||
{} as CanvasPointerEvent
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,11 @@ function makeOutput(
|
||||
}
|
||||
|
||||
describe(flattenNodeOutput, () => {
|
||||
it('returns empty array for nullish node output', () => {
|
||||
expect(flattenNodeOutput(['1', null])).toEqual([])
|
||||
expect(flattenNodeOutput(['1', undefined])).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for output with no known media types', () => {
|
||||
const result = flattenNodeOutput(['1', makeOutput({ unknown: 'hello' })])
|
||||
expect(result).toEqual([])
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
export function flattenNodeOutput([nodeId, nodeOutput]: [
|
||||
string | number,
|
||||
NodeExecutionOutput
|
||||
NodeExecutionOutput | null | undefined
|
||||
]): ResultItemImpl[] {
|
||||
return parseNodeOutput(nodeId, nodeOutput)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="litegraph-minimap relative border border-interface-stroke bg-comfy-menu-bg shadow-interface"
|
||||
data-testid="minimap-container"
|
||||
:style="containerStyles"
|
||||
>
|
||||
<Button
|
||||
@@ -58,12 +59,18 @@
|
||||
:width="width"
|
||||
:height="height"
|
||||
class="minimap-canvas"
|
||||
data-testid="minimap-canvas"
|
||||
/>
|
||||
|
||||
<div class="minimap-viewport" :style="viewportStyles" />
|
||||
<div
|
||||
class="minimap-viewport"
|
||||
:style="viewportStyles"
|
||||
data-testid="minimap-viewport"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 touch-none"
|
||||
data-testid="minimap-interaction-overlay"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp"
|
||||
|
||||
@@ -11,6 +11,11 @@ function makeOutput(
|
||||
}
|
||||
|
||||
describe(parseNodeOutput, () => {
|
||||
it('returns empty array for nullish node output', () => {
|
||||
expect(parseNodeOutput('1', null)).toEqual([])
|
||||
expect(parseNodeOutput('1', undefined)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for output with no known media types', () => {
|
||||
const result = parseNodeOutput('1', makeOutput({ text: 'hello' }))
|
||||
expect(result).toEqual([])
|
||||
@@ -152,6 +157,22 @@ describe(parseNodeOutput, () => {
|
||||
})
|
||||
|
||||
describe(parseTaskOutput, () => {
|
||||
it('ignores nullish node outputs', () => {
|
||||
const taskOutput: Record<string, NodeExecutionOutput | null | undefined> = {
|
||||
'1': null,
|
||||
'2': undefined,
|
||||
'3': makeOutput({
|
||||
images: [{ filename: 'a.png', subfolder: '', type: 'output' }]
|
||||
})
|
||||
}
|
||||
|
||||
const result = parseTaskOutput(taskOutput)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].nodeId).toBe('3')
|
||||
expect(result[0].filename).toBe('a.png')
|
||||
})
|
||||
|
||||
it('flattens across multiple nodes', () => {
|
||||
const taskOutput: Record<string, NodeExecutionOutput> = {
|
||||
'1': makeOutput({
|
||||
|
||||
@@ -29,8 +29,10 @@ function isResultItem(item: unknown): item is ResultItem {
|
||||
|
||||
export function parseNodeOutput(
|
||||
nodeId: string | number,
|
||||
nodeOutput: NodeExecutionOutput
|
||||
nodeOutput: NodeExecutionOutput | null | undefined
|
||||
): ResultItemImpl[] {
|
||||
if (!nodeOutput) return []
|
||||
|
||||
return Object.entries(nodeOutput)
|
||||
.filter(([key, value]) => !METADATA_KEYS.has(key) && Array.isArray(value))
|
||||
.flatMap(([mediaType, items]) =>
|
||||
@@ -41,7 +43,7 @@ export function parseNodeOutput(
|
||||
}
|
||||
|
||||
export function parseTaskOutput(
|
||||
taskOutput: Record<string, NodeExecutionOutput>
|
||||
taskOutput: Record<string, NodeExecutionOutput | null | undefined>
|
||||
): ResultItemImpl[] {
|
||||
return Object.entries(taskOutput).flatMap(([nodeId, nodeOutput]) =>
|
||||
parseNodeOutput(nodeId, nodeOutput)
|
||||
|
||||
Reference in New Issue
Block a user