Compare commits

...

13 Commits

Author SHA1 Message Date
Christian Byrne
a38bc25f45 Merge branch 'main' into glary/resize-corner-e2e-tests 2026-05-13 13:15:33 -07:00
Connor Byrne
109fa9249f test(fixture): expose poll{Left,Top,Right,Bottom}Edge and poll{Width,Height}
Move the bounding-box edge accessors from local helpers in resize.spec
into the fixture so resize tests can read 'expect.poll(node.pollWidth)'
instead of repeating the inline 'async () => (await node.boundingBox()).X'
shape. The opposite-edge anchoring test now references node-bound poll
helpers directly and the per-test edge-of factories disappear from the
spec.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11408#discussion_r3107737141
2026-05-04 13:35:47 -07:00
bymyself
a29c36f3ec test(fixture): extract expectAnchoredAt for drift assertions
Wraps the polled bounding-box x/y assertions that drift tests duplicate
on each axis. Drift test now reads as 'select, anchor, resize, anchor'
instead of repeated polling boilerplate.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11408#discussion_r3184280358
2026-05-04 13:32:13 -07:00
bymyself
80735dac10 test: drive drift test through fixture's resizeFromCorner
Replaces the bespoke select-then-mouse-drag plumbing with the existing
fixture helpers (setupResizableNode + resizeFromCorner). Drops the
hand-computed corner offset and the raw page.mouse sequence so the
drift test follows the same shape as the parameterized cases.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11408#discussion_r3184276374
2026-05-04 13:31:21 -07:00
bymyself
3682ae2ac0 test(fixture): add selectAndGetBox helper to VueNodeFixture
Encapsulates the click-header-then-grab-bounding-box pattern that
geometry-sensitive tests need. Removes the inline header click and
bounding-box null check from setupResizableNode.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11408#discussion_r3184278489
2026-05-04 13:31:00 -07:00
bymyself
213bf7b4cb test: include SE in parameterized corner resize sweeps
Drop the SE filter and its 'Exercise every non-SE corner' explanation;
SE belongs in the same parameterized loops as the other three corners.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11408#discussion_r3184283386
2026-05-04 13:30:27 -07:00
jaeone94
d03013372d Merge branch 'main' into glary/resize-corner-e2e-tests 2026-05-02 15:51:08 +09:00
jaeone94
2fceb9b68a Merge branch 'main' into glary/resize-corner-e2e-tests 2026-04-29 22:21:50 +09:00
jaeone94
7c88b22ad3 test: address resize review feedback
- Replace English aria-label lookup with a data-corner attribute on the
  handle div; drop RESIZE_HANDLE_ARIA_LABELS_EN and its main.json
  import so resizeHandleConfig stays locale-independent
- Tighten NE clamp test: poll actual clamped height, then drive a second
  overdrag and assert height stays at the same lower bound (idempotent
  clamp). Rename test to match what it verifies; rename the inner box
  to expandedBox to avoid shadowing the setup fixture's box
- Restore Comfy.Minimap.Visible in afterEach so the override does not
  leak into other specs sharing the user-data-dir
- Normalize canvas state with canvasOps.resetView() in beforeEach so
  anchor assertions stay subpixel-stable regardless of prior pan/zoom
- Guard setupResizableNode with toHaveCount(1) so the helper fails
  loudly if the default workflow ever gains a duplicate title
- Add handle.hover() before the drag mouse sequence; Playwright's
  actionability checks now verify the handle is hit-testable
- Extract edge-coordinate pollers (leftEdgeOf / rightEdgeOf /
  topEdgeOf / bottomEdgeOf) to replace nested ternaries inside
  expect.poll callbacks
- Tag the suite with @canvas @node per the Playwright guidance
- Reword the parameterization comment to stop leaking the production
  switch statement as test rationale
- Drop the aria-label mirror test; add a completeness test that asserts
  RESIZE_HANDLES defines exactly one entry per CompassCorners
- JSDoc on hasWestEdge/hasNorthEdge and the fixture's resize helpers
2026-04-21 21:08:40 +09:00
jaeone94
f5a1c724e6 refactor: share resize corner helpers across production and E2E
- Expose hasWestEdge, hasNorthEdge, and RESIZE_HANDLE_ARIA_LABELS_EN
  from resizeHandleConfig so useNodeResize and the VueNodeFixture
  consume a single source per corner; resolve English labels from
  main.json instead of hardcoding a duplicate map in tests
- Replace activeCorner.includes('W'|'N') substring checks in
  useNodeResize with the new helpers
- Drop nextFrame from resizeFromCorner; call sites rely on expect.poll
- Disable Comfy.Minimap.Visible in the resize spec's beforeEach so the
  minimap overlay does not hijack handle pointer events mid-drag and
  trigger forwardPanEvent on the LGraphCanvas
- Target KSampler (center-positioned with open canvas around it) for
  corner-resize cases; pre-expand via SE before NE clamp so there is
  room to shrink below minContentHeight
- Replace the redundant runtime corner-existence guard with the
  exhaustive CompassCorners union; extract setupResizableNode to drop
  repeated header-click + boundingBox scaffolding
- Unit tests cover the new helpers and verify aria-label resolution
  stays in sync with the handles' i18nKey values
2026-04-21 20:40:59 +09:00
Glary-Bot
c77bccefea test: add nextFrame after resize, tighten min-size assertions
- resizeFromCorner() now waits one frame after mouse up for layout
  settlement, preventing intermittent failures
- SW width clamp asserts >= MIN_NODE_WIDTH instead of > 0
- NE height clamp asserts < original height (actual shrinkage)
2026-04-19 08:15:54 +00:00
Glary-Bot
77c69cf931 test: add upfront guard against silent corner removal
Fail loudly if RESIZE_HANDLES drops a corner, preventing the
parameterized tests from silently losing coverage.
2026-04-19 08:06:00 +00:00
Glary-Bot
c84fa6ac39 test: add E2E coverage for NE, SW, NW corner node resizing
Adds parameterized Playwright tests covering all non-SE resize
corners, derived from the production RESIZE_HANDLES config.

- Add resizeFromCorner() and getResizeHandle() to VueNodeFixture
  using locator-based aria-label handle discovery
- Corner resize direction tests (NE, SW, NW)
- Opposite edge anchoring tests
- Minimum size enforcement tests (SW width clamp, NE height clamp)
2026-04-19 08:00:04 +00:00
6 changed files with 314 additions and 49 deletions

View File

@@ -1,8 +1,16 @@
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
import { TitleEditor } from '@e2e/fixtures/components/TitleEditor'
import { TestIds } from '@e2e/fixtures/selectors'
interface BoxOrigin {
readonly x: number
readonly y: number
}
/** DOM-centric helper for a single Vue-rendered node on the canvas. */
export class VueNodeFixture {
public readonly header: Locator
@@ -60,4 +68,100 @@ export class VueNodeFixture {
boundingBox(): ReturnType<Locator['boundingBox']> {
return this.locator.boundingBox()
}
/**
* Click the node header to select it, then return its bounding box.
* Throws if the node is not laid out (no bounding box) — resize and other
* geometry-sensitive tests cannot proceed without coordinates.
*/
async selectAndGetBox(): Promise<{
x: number
y: number
width: number
height: number
}> {
await this.header.click()
const box = await this.boundingBox()
if (!box) {
throw new Error('Node bounding box not found after select')
}
return box
}
/**
* Assert this node's top-left origin stays within `precision` decimal
* places of `expected`. Wraps the polled bounding-box pattern that drift
* tests repeat for both axes.
*/
async expectAnchoredAt(
expected: BoxOrigin,
{ precision = 1 }: { precision?: number } = {}
): Promise<void> {
await expect.poll(this.pollLeftEdge).toBeCloseTo(expected.x, precision)
await expect.poll(this.pollTopEdge).toBeCloseTo(expected.y, precision)
}
/** Poll the node's left/x edge for use with `expect.poll`. */
pollLeftEdge = async (): Promise<number | null> =>
(await this.boundingBox())?.x ?? null
/** Poll the node's top/y edge for use with `expect.poll`. */
pollTopEdge = async (): Promise<number | null> =>
(await this.boundingBox())?.y ?? null
/** Poll the node's right edge (x + width) for use with `expect.poll`. */
pollRightEdge = async (): Promise<number | null> => {
const b = await this.boundingBox()
return b ? b.x + b.width : null
}
/** Poll the node's bottom edge (y + height) for use with `expect.poll`. */
pollBottomEdge = async (): Promise<number | null> => {
const b = await this.boundingBox()
return b ? b.y + b.height : null
}
/** Poll the node's width for use with `expect.poll`. */
pollWidth = async (): Promise<number | null> =>
(await this.boundingBox())?.width ?? null
/** Poll the node's height for use with `expect.poll`. */
pollHeight = async (): Promise<number | null> =>
(await this.boundingBox())?.height ?? null
/** Locator for the resize handle at the given corner, scoped to this node. */
getResizeHandle(corner: CompassCorners): Locator {
return this.root.locator(`[data-corner="${corner}"]`)
}
/**
* Drag the resize handle at `corner` by (deltaX, deltaY) viewport pixels.
* Uses `hover()` to land the pointer on the handle with Playwright's
* actionability checks before starting the mouse sequence, which protects
* against occluding overlays and subpixel hit-test misses.
*/
async resizeFromCorner(
corner: CompassCorners,
deltaX: number,
deltaY: number
): Promise<void> {
const handle = this.getResizeHandle(corner)
await handle.hover()
const box = await handle.boundingBox()
if (!box) {
throw new Error(
`Resize handle for corner "${corner}" has no bounding box`
)
}
const page = this.locator.page()
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: 5
})
await page.mouse.up()
}
}

View File

@@ -1,56 +1,165 @@
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { MIN_NODE_WIDTH } from '@/renderer/core/layout/transform/graphRenderTransform'
import {
RESIZE_HANDLES,
hasNorthEdge,
hasWestEdge
} from '@/renderer/extensions/vueNodes/interactions/resize/resizeHandleConfig'
test.describe('Vue Node Resizing', { tag: '@vue-nodes' }, () => {
test('should resize node without position drift after selecting', async ({
comfyPage
}) => {
// Get a Vue node fixture
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
const initialBox = await node.boundingBox()
if (!initialBox) throw new Error('Node bounding box not found')
async function setupResizableNode(comfyPage: ComfyPage, title: string) {
await expect(comfyPage.vueNodes.getNodeByTitle(title)).toHaveCount(1)
const node = await comfyPage.vueNodes.getFixtureByTitle(title)
const box = await node.selectAndGetBox()
return { node, box }
}
// Select the node first (this was causing the bug)
await node.header.click()
test.describe(
'Vue Node Resizing',
{ tag: ['@vue-nodes', '@canvas', '@node'] },
() => {
let originalMinimapVisible: boolean | undefined
// Get position after selection
const selectedBox = await node.boundingBox()
if (!selectedBox)
throw new Error('Node bounding box not found after select')
// Minimap overlays the canvas and intercepts pointer events that land in
// its hit area during resize drags, so disable it for this suite. Capture
// and restore the prior value to avoid leaking the override to other specs
// that run on the same user-data-dir.
test.beforeEach(async ({ comfyPage }) => {
originalMinimapVisible = await comfyPage.settings.getSetting<boolean>(
'Comfy.Minimap.Visible'
)
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
await comfyPage.canvasOps.resetView()
})
// Verify position unchanged after selection
await expect
.poll(async () => (await node.boundingBox())?.x)
.toBeCloseTo(initialBox.x, 1)
await expect
.poll(async () => (await node.boundingBox())?.y)
.toBeCloseTo(initialBox.y, 1)
test.afterEach(async ({ comfyPage }) => {
if (originalMinimapVisible !== undefined) {
await comfyPage.settings.setSetting(
'Comfy.Minimap.Visible',
originalMinimapVisible
)
}
})
// Now resize from bottom-right corner
const resizeStartX = selectedBox.x + selectedBox.width - 5
const resizeStartY = selectedBox.y + selectedBox.height - 5
test('should resize node without position drift after selecting', async ({
comfyPage
}) => {
const { node, box: initialBox } = await setupResizableNode(
comfyPage,
'Load Checkpoint'
)
await comfyPage.page.mouse.move(resizeStartX, resizeStartY)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(resizeStartX + 50, resizeStartY + 30)
await comfyPage.page.mouse.up()
await node.expectAnchoredAt(initialBox)
// Position should NOT have changed (the bug was position drift)
await expect
.poll(async () => (await node.boundingBox())?.x)
.toBeCloseTo(initialBox.x, 1)
await expect
.poll(async () => (await node.boundingBox())?.y)
.toBeCloseTo(initialBox.y, 1)
await node.resizeFromCorner('SE', 50, 30)
// Size should have increased
await expect
.poll(async () => (await node.boundingBox())?.width)
.toBeGreaterThan(initialBox.width)
await expect
.poll(async () => (await node.boundingBox())?.height)
.toBeGreaterThan(initialBox.height)
})
})
await node.expectAnchoredAt(initialBox)
await expect.poll(node.pollWidth).toBeGreaterThan(initialBox.width)
await expect.poll(node.pollHeight).toBeGreaterThan(initialBox.height)
})
const cornerCases = RESIZE_HANDLES.map((h) => ({
corner: h.corner,
dragX: hasWestEdge(h.corner) ? -50 : 50,
dragY: hasNorthEdge(h.corner) ? -40 : 40
}))
test.describe('corner resize directions', () => {
cornerCases.forEach(({ corner, dragX, dragY }) => {
test(`${corner}: size increases and correct edges shift`, async ({
comfyPage
}) => {
const { node, box } = await setupResizableNode(comfyPage, 'KSampler')
await node.resizeFromCorner(corner, dragX, dragY)
await expect.poll(node.pollWidth).toBeGreaterThan(box.width)
await expect.poll(node.pollHeight).toBeGreaterThan(box.height)
if (hasWestEdge(corner)) {
await expect.poll(node.pollLeftEdge).toBeLessThan(box.x)
} else {
await expect.poll(node.pollLeftEdge).toBeCloseTo(box.x, 0)
}
if (hasNorthEdge(corner)) {
await expect.poll(node.pollTopEdge).toBeLessThan(box.y)
} else {
await expect.poll(node.pollTopEdge).toBeCloseTo(box.y, 0)
}
})
})
})
test.describe('opposite edge anchoring', () => {
cornerCases.forEach(({ corner, dragX, dragY }) => {
test(`${corner} resize keeps opposite corner fixed`, async ({
comfyPage
}) => {
const { node, box } = await setupResizableNode(comfyPage, 'KSampler')
const pollAnchorX = hasWestEdge(corner)
? node.pollRightEdge
: node.pollLeftEdge
const pollAnchorY = hasNorthEdge(corner)
? node.pollBottomEdge
: node.pollTopEdge
const anchorX = hasWestEdge(corner) ? box.x + box.width : box.x
const anchorY = hasNorthEdge(corner) ? box.y + box.height : box.y
await node.resizeFromCorner(corner, dragX, dragY)
await expect.poll(pollAnchorX).toBeCloseTo(anchorX, 0)
await expect.poll(pollAnchorY).toBeCloseTo(anchorY, 0)
})
})
})
test.describe('minimum size enforcement', () => {
test('SW resize clamps width, keeping right edge fixed', async ({
comfyPage
}) => {
const { node, box } = await setupResizableNode(comfyPage, 'KSampler')
const rightEdge = box.x + box.width
await node.resizeFromCorner('SW', box.width + 100, 0)
await expect.poll(node.pollRightEdge).toBeCloseTo(rightEdge, 0)
await expect.poll(node.pollWidth).toBeGreaterThanOrEqual(MIN_NODE_WIDTH)
})
test('NE resize clamps height at its lower bound', async ({
comfyPage
}) => {
const { node } = await setupResizableNode(comfyPage, 'KSampler')
// Default nodes render at content-minimum height; grow from SE so NE
// has room to shrink back down to the clamp.
await node.resizeFromCorner('SE', 0, 200)
const expandedBox = await node.boundingBox()
if (!expandedBox)
throw new Error('Node bounding box not found after SE grow')
const bottomEdge = expandedBox.y + expandedBox.height
// Overdrag once to hit the clamp, then again to prove further dragging
// does not shrink past the minimum (idempotent clamp).
await node.resizeFromCorner('NE', 0, expandedBox.height + 100)
const clampedHeight = (await node.boundingBox())?.height
if (clampedHeight === undefined)
throw new Error('Node bounding box not found after NE clamp')
expect(clampedHeight).toBeLessThan(expandedBox.height)
await node.resizeFromCorner('NE', 0, 200)
await expect.poll(node.pollHeight).toBeCloseTo(clampedHeight, 0)
await expect.poll(node.pollBottomEdge).toBeCloseTo(bottomEdge, 0)
})
})
}
)

View File

@@ -216,6 +216,7 @@
v-for="handle in RESIZE_HANDLES"
:key="handle.corner"
role="button"
:data-corner="handle.corner"
:aria-label="t(handle.i18nKey)"
:class="
cn(

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
import { RESIZE_HANDLES, hasNorthEdge, hasWestEdge } from './resizeHandleConfig'
describe('hasWestEdge', () => {
it.each<[CompassCorners, boolean]>([
['NW', true],
['SW', true],
['NE', false],
['SE', false]
])('corner %s -> %s', (corner, expected) => {
expect(hasWestEdge(corner)).toBe(expected)
})
})
describe('hasNorthEdge', () => {
it.each<[CompassCorners, boolean]>([
['NW', true],
['NE', true],
['SW', false],
['SE', false]
])('corner %s -> %s', (corner, expected) => {
expect(hasNorthEdge(corner)).toBe(expected)
})
})
describe('RESIZE_HANDLES', () => {
it('defines exactly one entry per CompassCorners member', () => {
const expected = new Set<CompassCorners>(['NE', 'NW', 'SE', 'SW'])
const actual = new Set(RESIZE_HANDLES.map((handle) => handle.corner))
expect(actual).toEqual(expected)
expect(RESIZE_HANDLES).toHaveLength(expected.size)
})
})

View File

@@ -43,3 +43,11 @@ export const RESIZE_HANDLES: ResizeHandle[] = [
svgTransform: 'scale(-1, -1)'
}
] as const
/** True for corners on the left edge of a node (SW, NW) — these move the x-origin when dragged. */
export const hasWestEdge = (corner: CompassCorners): boolean =>
corner === 'SW' || corner === 'NW'
/** True for corners on the top edge of a node (NE, NW) — these move the y-origin when dragged. */
export const hasNorthEdge = (corner: CompassCorners): boolean =>
corner === 'NE' || corner === 'NW'

View File

@@ -8,6 +8,10 @@ import { MIN_NODE_WIDTH } from '@/renderer/core/layout/transform/graphRenderTran
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
import {
hasNorthEdge,
hasWestEdge
} from '@/renderer/extensions/vueNodes/interactions/resize/resizeHandleConfig'
export interface ResizeCallbackPayload {
size: Size
@@ -135,20 +139,23 @@ export function useNodeResize(
break
}
const isWestCorner = hasWestEdge(activeCorner)
const isNorthCorner = hasNorthEdge(activeCorner)
// Apply snap-to-grid
if (shouldSnap(moveEvent)) {
// Snap position first for N/W corners, then compensate size
if (activeCorner.includes('N') || activeCorner.includes('W')) {
if (isNorthCorner || isWestCorner) {
const originalX = newX
const originalY = newY
const snapped = applySnapToPosition({ x: newX, y: newY })
newX = snapped.x
newY = snapped.y
if (activeCorner.includes('N')) {
if (isNorthCorner) {
newHeight += originalY - newY
}
if (activeCorner.includes('W')) {
if (isWestCorner) {
newWidth += originalX - newX
}
}
@@ -166,7 +173,7 @@ export function useNodeResize(
parseFloat(nodeElement.style.getPropertyValue('min-width') || '0') ||
MIN_NODE_WIDTH
if (newWidth < minWidth) {
if (activeCorner.includes('W')) {
if (isWestCorner) {
newX =
resizeStartPosition.value.x + resizeStartSize.value.width - minWidth
}
@@ -179,7 +186,7 @@ export function useNodeResize(
// a responsive breakpoint.
const minContentHeight = measureMinContentHeight(newWidth)
if (newHeight < minContentHeight) {
if (activeCorner.includes('N')) {
if (isNorthCorner) {
newY =
resizeStartPosition.value.y +
resizeStartSize.value.height -