mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-25 08:49:36 +00:00
Compare commits
4 Commits
test/mask-
...
test/image
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b0d59552d | ||
|
|
367f810702 | ||
|
|
798f6de4a9 | ||
|
|
752641cc67 |
38
CODEOWNERS
38
CODEOWNERS
@@ -41,12 +41,46 @@
|
||||
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
|
||||
# Mask Editor
|
||||
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp
|
||||
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp
|
||||
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/components/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/composables/maskeditor/ @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/stores/maskEditorStore.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
/src/stores/maskEditorDataStore.ts @trsommer @brucew4yn3rp @jtydhr88
|
||||
|
||||
# Image Crop
|
||||
/src/extensions/core/imageCrop.ts @jtydhr88
|
||||
/src/components/imagecrop/ @jtydhr88
|
||||
/src/composables/useImageCrop.ts @jtydhr88
|
||||
/src/lib/litegraph/src/widgets/ImageCropWidget.ts @jtydhr88
|
||||
|
||||
# Image Compare
|
||||
/src/extensions/core/imageCompare.ts @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.test.ts @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.stories.ts @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget.ts @jtydhr88
|
||||
/src/lib/litegraph/src/widgets/ImageCompareWidget.ts @jtydhr88
|
||||
|
||||
# Painter
|
||||
/src/extensions/core/painter.ts @jtydhr88
|
||||
/src/components/painter/ @jtydhr88
|
||||
/src/composables/painter/ @jtydhr88
|
||||
/src/renderer/extensions/vueNodes/widgets/composables/usePainterWidget.ts @jtydhr88
|
||||
/src/lib/litegraph/src/widgets/PainterWidget.ts @jtydhr88
|
||||
|
||||
# 3D
|
||||
/src/extensions/core/load3d.ts @jtydhr88
|
||||
/src/extensions/core/load3dLazy.ts @jtydhr88
|
||||
/src/extensions/core/load3d/ @jtydhr88
|
||||
/src/components/load3d/ @jtydhr88
|
||||
/src/composables/useLoad3d.ts @jtydhr88
|
||||
/src/composables/useLoad3d.test.ts @jtydhr88
|
||||
/src/composables/useLoad3dDrag.ts @jtydhr88
|
||||
/src/composables/useLoad3dDrag.test.ts @jtydhr88
|
||||
/src/composables/useLoad3dViewer.ts @jtydhr88
|
||||
/src/composables/useLoad3dViewer.test.ts @jtydhr88
|
||||
/src/services/load3dService.ts @jtydhr88
|
||||
|
||||
# Manager
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
|
||||
|
||||
69
browser_tests/assets/widgets/image_crop_widget.json
Normal file
69
browser_tests/assets/widgets/image_crop_widget.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "ImageCropV2",
|
||||
"pos": [400, 50],
|
||||
"size": [200, 300],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageCropV2"
|
||||
},
|
||||
"widgets_values": [{ "x": 0, "y": 0, "width": 512, "height": 512 }]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"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"]
|
||||
}
|
||||
],
|
||||
"links": [[1, 2, 0, 1, 0, "IMAGE"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -101,10 +101,6 @@ export const TestIds = {
|
||||
errors: {
|
||||
imageLoadError: 'error-loading-image',
|
||||
videoLoadError: 'error-loading-video'
|
||||
},
|
||||
maskEditor: {
|
||||
dialog: 'mask-editor-dialog',
|
||||
uiContainer: 'mask-editor-ui-container'
|
||||
}
|
||||
} as const
|
||||
|
||||
@@ -131,4 +127,3 @@ export type TestIdValue =
|
||||
>
|
||||
| (typeof TestIds.user)[keyof typeof TestIds.user]
|
||||
| (typeof TestIds.errors)[keyof typeof TestIds.errors]
|
||||
| (typeof TestIds.maskEditor)[keyof typeof TestIds.maskEditor]
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
export async function loadImageOnNode(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
)[0]
|
||||
const { x, y } = await loadImageNode.getPosition()
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
|
||||
dropPosition: { x, y }
|
||||
})
|
||||
|
||||
const imagePreview = comfyPage.page.locator('.image-preview')
|
||||
await expect(imagePreview).toBeVisible()
|
||||
await expect(imagePreview.locator('img')).toBeVisible()
|
||||
await expect(imagePreview).toContainText('x')
|
||||
|
||||
return {
|
||||
imagePreview,
|
||||
nodeId: String(loadImageNode.id)
|
||||
}
|
||||
}
|
||||
|
||||
export async function openMaskEditorViaCommand(comfyPage: ComfyPage) {
|
||||
const { nodeId } = await loadImageOnNode(comfyPage)
|
||||
await comfyPage.vueNodes.selectNode(nodeId)
|
||||
await comfyPage.command.executeCommand('Comfy.MaskEditor.OpenMaskEditor')
|
||||
return comfyPage.page.getByTestId(TestIds.maskEditor.dialog)
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 14 KiB |
429
browser_tests/tests/imageCrop.spec.ts
Normal file
429
browser_tests/tests/imageCrop.spec.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
// ---- Helpers ---------------------------------------------------------------
|
||||
|
||||
function createTestImageDataUrl(
|
||||
width: number,
|
||||
height: number,
|
||||
color: string
|
||||
): string {
|
||||
const svg =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">` +
|
||||
`<rect width="${width}" height="${height}" fill="${color}"/>` +
|
||||
`</svg>`
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
|
||||
}
|
||||
|
||||
type Bounds = { x: number; y: number; width: number; height: number }
|
||||
|
||||
/**
|
||||
* Injects an image into the node output store for the given source node,
|
||||
* simulating what happens after a node executes and produces image output.
|
||||
*/
|
||||
async function injectSourceImage(
|
||||
page: Page,
|
||||
sourceNodeId: number,
|
||||
dataUrl: string
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ nodeId, url }) => {
|
||||
type NodeOutputStore = {
|
||||
setNodePreviewsByNodeId: (id: number, urls: string[]) => void
|
||||
}
|
||||
type VueAppElement = HTMLElement & {
|
||||
__vue_app__: {
|
||||
config: {
|
||||
globalProperties: {
|
||||
$pinia: { _s: Map<string, NodeOutputStore> }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const el = document.getElementById('vue-app') as unknown as VueAppElement
|
||||
const store =
|
||||
el.__vue_app__.config.globalProperties.$pinia._s.get('nodeOutput')!
|
||||
store.setNodePreviewsByNodeId(nodeId, [url])
|
||||
},
|
||||
{ nodeId: sourceNodeId, url: dataUrl }
|
||||
)
|
||||
}
|
||||
|
||||
async function setCropState(page: Page, bounds: Bounds): Promise<void> {
|
||||
await page.evaluate((bounds) => {
|
||||
type BoundsValue = { x: number; y: number; width: number; height: number }
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
const widget = node?.widgets?.find((w) => w.type === 'imagecrop')
|
||||
if (widget?.value) {
|
||||
const value = widget.value as unknown as BoundsValue
|
||||
value.x = bounds.x
|
||||
value.y = bounds.y
|
||||
value.width = bounds.width
|
||||
value.height = bounds.height
|
||||
widget.callback?.(widget.value)
|
||||
}
|
||||
}, bounds)
|
||||
}
|
||||
|
||||
async function getCropState(page: Page): Promise<Bounds> {
|
||||
return page.evaluate(() => {
|
||||
type BoundsValue = { x: number; y: number; width: number; height: number }
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
const widget = node?.widgets?.find((w) => w.type === 'imagecrop')
|
||||
const v = widget?.value as unknown as BoundsValue
|
||||
return { x: v.x, y: v.y, width: v.width, height: v.height }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects a test image into the source node and waits for the crop widget to
|
||||
* be fully ready: image loaded, scale factor computed, crop box visible.
|
||||
*/
|
||||
async function setupWithImage(
|
||||
comfyPage: ComfyPage,
|
||||
imageWidth: number,
|
||||
imageHeight: number,
|
||||
initialBounds: Bounds
|
||||
): Promise<void> {
|
||||
await injectSourceImage(
|
||||
comfyPage.page,
|
||||
2,
|
||||
createTestImageDataUrl(imageWidth, imageHeight, 'steelblue')
|
||||
)
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node.locator('img')).toBeVisible()
|
||||
await comfyPage.page.waitForFunction(() => {
|
||||
const img = document.querySelector(
|
||||
'[data-node-id="1"] img'
|
||||
) as HTMLImageElement | null
|
||||
return (img?.complete ?? false) && (img?.naturalWidth ?? 0) > 0
|
||||
})
|
||||
await setCropState(comfyPage.page, initialBounds)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(node.locator('.cursor-move')).toBeVisible()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a locator for one of the 8 resize handles on the crop widget.
|
||||
* Handles are ordered in DOM as: top, bottom, left, right, nw, ne, sw, se.
|
||||
*/
|
||||
function getResizeHandle(
|
||||
nodeLocator: Locator,
|
||||
direction: 'top' | 'bottom' | 'left' | 'right' | 'nw' | 'ne' | 'sw' | 'se'
|
||||
): Locator {
|
||||
switch (direction) {
|
||||
case 'top':
|
||||
return nodeLocator.locator('.cursor-ns-resize').first()
|
||||
case 'bottom':
|
||||
return nodeLocator.locator('.cursor-ns-resize').last()
|
||||
case 'left':
|
||||
return nodeLocator.locator('.cursor-ew-resize').first()
|
||||
case 'right':
|
||||
return nodeLocator.locator('.cursor-ew-resize').last()
|
||||
case 'nw':
|
||||
return nodeLocator.locator('.cursor-nwse-resize').first()
|
||||
case 'se':
|
||||
return nodeLocator.locator('.cursor-nwse-resize').last()
|
||||
case 'ne':
|
||||
return nodeLocator.locator('.cursor-nesw-resize').first()
|
||||
case 'sw':
|
||||
return nodeLocator.locator('.cursor-nesw-resize').last()
|
||||
}
|
||||
}
|
||||
|
||||
async function dragFrom(
|
||||
page: Page,
|
||||
locator: Locator,
|
||||
deltaX: number,
|
||||
deltaY: number
|
||||
): Promise<void> {
|
||||
const box = await locator.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
const startX = box!.x + box!.width / 2
|
||||
const startY = box!.y + box!.height / 2
|
||||
await page.mouse.move(startX, startY)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(startX + deltaX, startY + deltaY, { steps: 10 })
|
||||
await page.mouse.up()
|
||||
}
|
||||
|
||||
// ---- Tests -----------------------------------------------------------------
|
||||
|
||||
test.describe('Image Crop', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('widgets/image_crop_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test(
|
||||
'shows empty state when no input image is available',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
await expect(node).toContainText('No input image connected')
|
||||
await expect(node.locator('.cursor-move')).not.toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'enforces minimum node size of 300×450',
|
||||
{ tag: '@node' },
|
||||
async ({ comfyPage }) => {
|
||||
const size = await comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
return node?.size as [number, number]
|
||||
})
|
||||
expect(size[0]).toBeGreaterThanOrEqual(300)
|
||||
expect(size[1]).toBeGreaterThanOrEqual(450)
|
||||
}
|
||||
)
|
||||
|
||||
test.describe('drag', { tag: '@widget' }, () => {
|
||||
test('moves the crop box', async ({ comfyPage }) => {
|
||||
await setupWithImage(comfyPage, 800, 600, {
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
|
||||
const cropBox = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.cursor-move')
|
||||
await dragFrom(comfyPage.page, cropBox, 100, 0)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const state = await getCropState(comfyPage.page)
|
||||
expect(state.x).toBeGreaterThan(100)
|
||||
expect(state.y).toBe(100)
|
||||
expect(state.width).toBe(200)
|
||||
expect(state.height).toBe(200)
|
||||
})
|
||||
|
||||
test('clamps to right boundary', async ({ comfyPage }) => {
|
||||
await setupWithImage(comfyPage, 800, 600, {
|
||||
x: 550,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
|
||||
const cropBox = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.cursor-move')
|
||||
await dragFrom(comfyPage.page, cropBox, 500, 0)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const state = await getCropState(comfyPage.page)
|
||||
expect(state.x + state.width).toBeLessThanOrEqual(800)
|
||||
})
|
||||
|
||||
test('clamps to top-left boundary', async ({ comfyPage }) => {
|
||||
await setupWithImage(comfyPage, 800, 600, {
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
|
||||
const cropBox = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.cursor-move')
|
||||
await dragFrom(comfyPage.page, cropBox, -500, -500)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const state = await getCropState(comfyPage.page)
|
||||
expect(state.x).toBeGreaterThanOrEqual(0)
|
||||
expect(state.y).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
test('does nothing when no image is loaded', async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node.locator('.cursor-move')).not.toBeVisible()
|
||||
|
||||
const stateBefore = await getCropState(comfyPage.page)
|
||||
|
||||
const nodeBox = await node.boundingBox()
|
||||
if (nodeBox) {
|
||||
await comfyPage.page.mouse.click(
|
||||
nodeBox.x + nodeBox.width / 2,
|
||||
nodeBox.y + nodeBox.height / 2
|
||||
)
|
||||
}
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const stateAfter = await getCropState(comfyPage.page)
|
||||
expect(stateAfter).toEqual(stateBefore)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('free resize', { tag: '@widget' }, () => {
|
||||
test('right edge increases width', async ({ comfyPage }) => {
|
||||
await setupWithImage(comfyPage, 800, 600, {
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await dragFrom(comfyPage.page, getResizeHandle(node, 'right'), 80, 0)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const state = await getCropState(comfyPage.page)
|
||||
expect(state.width).toBeGreaterThan(200)
|
||||
expect(state.x).toBe(100)
|
||||
expect(state.y).toBe(100)
|
||||
expect(state.height).toBe(200)
|
||||
})
|
||||
|
||||
test('left edge adjusts x and width', async ({ comfyPage }) => {
|
||||
await setupWithImage(comfyPage, 800, 600, {
|
||||
x: 200,
|
||||
y: 100,
|
||||
width: 300,
|
||||
height: 200
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await dragFrom(comfyPage.page, getResizeHandle(node, 'left'), -80, 0)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const state = await getCropState(comfyPage.page)
|
||||
expect(state.x).toBeLessThan(200)
|
||||
expect(state.width).toBeGreaterThan(300)
|
||||
expect(state.y).toBe(100)
|
||||
expect(state.height).toBe(200)
|
||||
})
|
||||
|
||||
test('bottom edge increases height', async ({ comfyPage }) => {
|
||||
await setupWithImage(comfyPage, 800, 600, {
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await dragFrom(comfyPage.page, getResizeHandle(node, 'bottom'), 0, 80)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const state = await getCropState(comfyPage.page)
|
||||
expect(state.height).toBeGreaterThan(200)
|
||||
expect(state.x).toBe(100)
|
||||
expect(state.y).toBe(100)
|
||||
expect(state.width).toBe(200)
|
||||
})
|
||||
|
||||
test('top edge adjusts y and height', async ({ comfyPage }) => {
|
||||
await setupWithImage(comfyPage, 800, 600, {
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await dragFrom(comfyPage.page, getResizeHandle(node, 'top'), 0, -80)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const state = await getCropState(comfyPage.page)
|
||||
expect(state.y).toBeLessThan(200)
|
||||
expect(state.height).toBeGreaterThan(200)
|
||||
expect(state.x).toBe(100)
|
||||
expect(state.width).toBe(200)
|
||||
})
|
||||
|
||||
test('SE corner increases width and height', async ({ comfyPage }) => {
|
||||
await setupWithImage(comfyPage, 800, 600, {
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await dragFrom(comfyPage.page, getResizeHandle(node, 'se'), 80, 80)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const state = await getCropState(comfyPage.page)
|
||||
expect(state.width).toBeGreaterThan(200)
|
||||
expect(state.height).toBeGreaterThan(200)
|
||||
expect(state.x).toBe(100)
|
||||
expect(state.y).toBe(100)
|
||||
})
|
||||
|
||||
test('NW corner adjusts x, y, width, and height', async ({ comfyPage }) => {
|
||||
await setupWithImage(comfyPage, 800, 600, {
|
||||
x: 200,
|
||||
y: 200,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await dragFrom(comfyPage.page, getResizeHandle(node, 'nw'), -80, -80)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const state = await getCropState(comfyPage.page)
|
||||
expect(state.x).toBeLessThan(200)
|
||||
expect(state.y).toBeLessThan(200)
|
||||
expect(state.width).toBeGreaterThan(200)
|
||||
expect(state.height).toBeGreaterThan(200)
|
||||
})
|
||||
|
||||
test('enforces minimum crop size of 16px', async ({ comfyPage }) => {
|
||||
await setupWithImage(comfyPage, 800, 600, {
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 50,
|
||||
height: 50
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
// Drag right edge far left to try to collapse width below the minimum
|
||||
await dragFrom(comfyPage.page, getResizeHandle(node, 'right'), -500, 0)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const state = await getCropState(comfyPage.page)
|
||||
expect(state.width).toBeGreaterThanOrEqual(16)
|
||||
})
|
||||
|
||||
test('clamps resize to image boundary', async ({ comfyPage }) => {
|
||||
await setupWithImage(comfyPage, 800, 600, {
|
||||
x: 600,
|
||||
y: 100,
|
||||
width: 100,
|
||||
height: 200
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
// Drag right edge far past the image right boundary
|
||||
await dragFrom(comfyPage.page, getResizeHandle(node, 'right'), 500, 0)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const state = await getCropState(comfyPage.page)
|
||||
expect(state.x + state.width).toBeLessThanOrEqual(800)
|
||||
})
|
||||
|
||||
test('shows 8 handles when ratio is unlocked', async ({ comfyPage }) => {
|
||||
await setupWithImage(comfyPage, 800, 600, {
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 200
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const handles = node.locator(
|
||||
'.cursor-ns-resize, .cursor-ew-resize, .cursor-nwse-resize, .cursor-nesw-resize'
|
||||
)
|
||||
await expect(handles).toHaveCount(8)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,17 +1,37 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
import {
|
||||
loadImageOnNode,
|
||||
openMaskEditorViaCommand
|
||||
} from '../helpers/maskEditorTestUtils'
|
||||
|
||||
test.describe('Mask Editor', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
async function loadImageOnNode(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
)[0]
|
||||
const { x, y } = await loadImageNode.getPosition()
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
|
||||
dropPosition: { x, y }
|
||||
})
|
||||
|
||||
const imagePreview = comfyPage.page.locator('.image-preview')
|
||||
await expect(imagePreview).toBeVisible()
|
||||
await expect(imagePreview.locator('img')).toBeVisible()
|
||||
await expect(imagePreview).toContainText('x')
|
||||
|
||||
return {
|
||||
imagePreview,
|
||||
nodeId: String(loadImageNode.id)
|
||||
}
|
||||
}
|
||||
|
||||
test(
|
||||
'opens mask editor from image preview button',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
@@ -22,7 +42,7 @@ test.describe('Mask Editor', () => {
|
||||
await imagePreview.getByRole('region').hover()
|
||||
await comfyPage.page.getByLabel('Edit or mask image').click()
|
||||
|
||||
const dialog = comfyPage.page.getByTestId(TestIds.maskEditor.dialog)
|
||||
const dialog = comfyPage.page.locator('.mask-editor-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await expect(
|
||||
@@ -33,9 +53,7 @@ test.describe('Mask Editor', () => {
|
||||
await expect(canvasContainer).toBeVisible()
|
||||
await expect(canvasContainer.locator('canvas')).toHaveCount(4)
|
||||
|
||||
await expect(
|
||||
dialog.getByTestId(TestIds.maskEditor.uiContainer)
|
||||
).toBeVisible()
|
||||
await expect(dialog.locator('.maskEditor-ui-container')).toBeVisible()
|
||||
await expect(dialog.getByText('Save')).toBeVisible()
|
||||
await expect(dialog.getByText('Cancel')).toBeVisible()
|
||||
|
||||
@@ -60,7 +78,7 @@ test.describe('Mask Editor', () => {
|
||||
|
||||
await contextMenu.getByText('Open in Mask Editor').click()
|
||||
|
||||
const dialog = comfyPage.page.getByTestId(TestIds.maskEditor.dialog)
|
||||
const dialog = comfyPage.page.locator('.mask-editor-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole('heading', { name: 'Mask Editor' })
|
||||
@@ -71,47 +89,4 @@ test.describe('Mask Editor', () => {
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'opens mask editor via command execution',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorViaCommand(comfyPage)
|
||||
|
||||
await expect(
|
||||
dialog.getByTestId(TestIds.maskEditor.uiContainer)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole('heading', { name: 'Mask Editor' })
|
||||
).toBeVisible()
|
||||
await expect(dialog).toHaveScreenshot('mask-editor-open-via-command.png')
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'cancel closes mask editor dialog without uploading',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorViaCommand(comfyPage)
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const uploadRequests: string[] = []
|
||||
await comfyPage.page.route('**/upload/mask', (route) => {
|
||||
uploadRequests.push('mask')
|
||||
return route.continue()
|
||||
})
|
||||
await comfyPage.page.route('**/upload/image', (route) => {
|
||||
uploadRequests.push('image')
|
||||
return route.continue()
|
||||
})
|
||||
await expect(dialog).toHaveScreenshot('mask-editor-before-cancel.png')
|
||||
await dialog.getByRole('button', { name: /cancel/i }).click()
|
||||
|
||||
await expect(dialog).not.toBeVisible()
|
||||
expect(uploadRequests).toHaveLength(0)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'mask-editor-cancelled-canvas-state.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 321 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 102 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 321 KiB |
@@ -40,10 +40,7 @@
|
||||
|
||||
<LoadingOverlay :loading="!initialized" size="sm" />
|
||||
|
||||
<div
|
||||
class="maskEditor-ui-container flex min-h-0 flex-1 flex-col"
|
||||
data-testid="mask-editor-ui-container"
|
||||
>
|
||||
<div class="maskEditor-ui-container flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex min-h-0 flex-1 overflow-hidden">
|
||||
<ToolPanel
|
||||
v-if="initialized"
|
||||
|
||||
@@ -29,8 +29,7 @@ export function useMaskEditor() {
|
||||
closable: true,
|
||||
pt: {
|
||||
root: {
|
||||
class: 'mask-editor-dialog flex flex-col',
|
||||
'data-testid': 'mask-editor-dialog'
|
||||
class: 'mask-editor-dialog flex flex-col'
|
||||
},
|
||||
content: {
|
||||
class: 'flex flex-col min-h-0 flex-1 !p-0'
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('WidgetImageCompare Display', () => {
|
||||
expect(images[1].attributes('src')).toBe('https://example.com/before.jpg')
|
||||
|
||||
images.forEach((img) => {
|
||||
expect(img.classes()).toContain('object-cover')
|
||||
expect(img.classes()).toContain('object-contain')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
:src="afterImage"
|
||||
:alt="afterAlt"
|
||||
draggable="false"
|
||||
class="absolute inset-0 size-full object-cover"
|
||||
class="absolute inset-0 size-full object-contain"
|
||||
/>
|
||||
|
||||
<img
|
||||
@@ -41,7 +41,7 @@
|
||||
:src="beforeImage"
|
||||
:alt="beforeAlt"
|
||||
draggable="false"
|
||||
class="absolute inset-0 size-full object-cover"
|
||||
class="absolute inset-0 size-full object-contain"
|
||||
:style="
|
||||
hasCompareImages
|
||||
? { clipPath: `inset(0 ${100 - sliderPosition}% 0 0)` }
|
||||
|
||||
@@ -233,7 +233,8 @@ const EXPANDING_TYPES = [
|
||||
'markdown',
|
||||
'load3D',
|
||||
'curve',
|
||||
'painter'
|
||||
'painter',
|
||||
'imagecompare'
|
||||
] as const
|
||||
|
||||
export function shouldExpand(type: string): boolean {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { Settings } from '@/schemas/apiSchema'
|
||||
import { useColorPaletteService } from '@/services/colorPaletteService'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import type { SidebarTabExtension, ToastManager } from '@/types/extensionTypes'
|
||||
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
|
||||
|
||||
import { useApiKeyAuthStore } from './apiKeyAuthStore'
|
||||
import { useCommandStore } from './commandStore'
|
||||
@@ -113,7 +114,8 @@ function workspaceStoreSetup() {
|
||||
|
||||
registerSidebarTab,
|
||||
unregisterSidebarTab,
|
||||
getSidebarTabs
|
||||
getSidebarTabs,
|
||||
renderMarkdownToHtml
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -117,6 +117,14 @@ export interface ExtensionManager {
|
||||
// Execution error state (read-only)
|
||||
lastNodeErrors: Record<NodeId, NodeError> | null
|
||||
lastExecutionError: ExecutionErrorWsMessage | null
|
||||
|
||||
/**
|
||||
* Renders a markdown string to sanitized HTML.
|
||||
* Uses marked (GFM) + DOMPurify. Safe for direct use with innerHTML.
|
||||
* @param markdown - The markdown string to render.
|
||||
* @param baseUrl - Optional base URL for resolving relative image/media paths.
|
||||
*/
|
||||
renderMarkdownToHtml(markdown: string, baseUrl?: string): string
|
||||
}
|
||||
|
||||
export interface CommandManager {
|
||||
|
||||
Reference in New Issue
Block a user