Compare commits

...

3 Commits

Author SHA1 Message Date
jaeone94
cc0c0769c7 test: add E2E tests for selection bounding box and collapsed node links
- 8 parameterized tests for selection bounds covering subgraph/regular
  nodes in expanded/collapsed states at different positions
- 3 tests for collapsed node slot positions: within bounds, after
  position change, and after expand recovery
- Use condition-based waits (waitForFunction, locator.waitFor) instead
  of fixed frame counts to avoid CI flakiness
2026-03-28 11:49:06 +09:00
jaeone94
d5bd40e94b test: add unit tests for collapsed node slot sync fallback 2026-03-28 10:50:08 +09:00
jaeone94
bdb3fd0d60 fix: selection bounding box and collapsed node link positions
- Extend node boundingRect via onBounding to include footer overlay height
- Override collapsed node width to MIN_NODE_WIDTH (measure() lacks canvas ctx)
- Fall back to clientPosToCanvasPos for collapsed node slot positioning
  since DOM-relative scale derivation is invalid when layout preserves
  expanded size
- Clear stale cachedOffset on collapse to prevent drag misalignment
- Defer collapsed node slot sync when canvas is not yet initialized
2026-03-28 10:38:36 +09:00
6 changed files with 578 additions and 18 deletions

View File

@@ -0,0 +1,116 @@
{
"id": "selection-bbox-test",
"revision": 0,
"last_node_id": 3,
"last_link_id": 1,
"nodes": [
{
"id": 2,
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
"pos": [300, 200],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "positive",
"type": "CONDITIONING",
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [1]
}
],
"properties": {},
"widgets_values": []
},
{
"id": 3,
"type": "EmptyLatentImage",
"pos": [800, 200],
"size": [315, 106],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "latent",
"type": "LATENT",
"link": 1
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {},
"widgets_values": [512, 512, 1]
}
],
"links": [[1, 2, 0, 3, 0, "LATENT"]],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 0,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Test Subgraph",
"inputNode": {
"id": -10,
"bounding": [100, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [500, 200, 120, 60]
},
"inputs": [
{
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [],
"pos": { "0": 200, "1": 220 }
}
],
"outputs": [
{
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
"name": "LATENT",
"type": "LATENT",
"linkIds": [],
"pos": { "0": 520, "1": 220 }
}
],
"widgets": [],
"nodes": [],
"groups": [],
"links": [],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

View File

@@ -0,0 +1,318 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
interface CanvasRect {
x: number
y: number
w: number
h: number
}
interface MeasureResult {
selectionBounds: CanvasRect | null
nodeVisualBounds: Record<string, CanvasRect>
}
// Must match the padding value passed to createBounds() in selectionBorder.ts
const SELECTION_PADDING = 10
const SLOT_BOUNDS_MARGIN = 20
async function waitForSelectedCount(page: Page, count: number) {
await page.waitForFunction(
(n) => window.app!.canvas.selectedItems.size === n,
count,
{ timeout: 5000 }
)
}
async function waitForNodeLayout(page: Page, nodeId: string) {
await page.waitForFunction(
(id) => {
const el = document.querySelector(`[data-node-id="${id}"]`)
if (!el) return false
const rect = el.getBoundingClientRect()
return rect.width > 0 && rect.height > 0
},
nodeId,
{ timeout: 5000 }
)
}
async function measureBounds(
page: Page,
nodeIds: string[]
): Promise<MeasureResult> {
return page.evaluate(
({ ids, padding }) => {
const canvas = window.app!.canvas
const ds = canvas.ds
const selectedItems = canvas.selectedItems
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const item of selectedItems) {
const rect = item.boundingRect
minX = Math.min(minX, rect[0])
minY = Math.min(minY, rect[1])
maxX = Math.max(maxX, rect[0] + rect[2])
maxY = Math.max(maxY, rect[1] + rect[3])
}
const selectionBounds =
selectedItems.size > 0
? {
x: minX - padding,
y: minY - padding,
w: maxX - minX + 2 * padding,
h: maxY - minY + 2 * padding
}
: null
const canvasEl = canvas.canvas as HTMLCanvasElement
const canvasRect = canvasEl.getBoundingClientRect()
const nodeVisualBounds: Record<string, CanvasRect> = {}
for (const id of ids) {
const nodeEl = document.querySelector(
`[data-node-id="${id}"]`
) as HTMLElement | null
if (!nodeEl) continue
const domRect = nodeEl.getBoundingClientRect()
const footerEls = nodeEl.querySelectorAll(
'[data-testid="subgraph-enter-button"], [data-testid="node-footer"]'
)
let bottom = domRect.bottom
for (const footerEl of footerEls) {
bottom = Math.max(bottom, footerEl.getBoundingClientRect().bottom)
}
nodeVisualBounds[id] = {
x: (domRect.left - canvasRect.left) / ds.scale - ds.offset[0],
y: (domRect.top - canvasRect.top) / ds.scale - ds.offset[1],
w: domRect.width / ds.scale,
h: (bottom - domRect.top) / ds.scale
}
}
return { selectionBounds, nodeVisualBounds }
},
{ ids: nodeIds, padding: SELECTION_PADDING }
) as Promise<MeasureResult>
}
async function loadWithPositions(
page: Page,
positions: Record<string, [number, number]>
) {
await page.evaluate(
async ({ positions }) => {
const data = window.app!.graph.serialize()
for (const node of data.nodes) {
const pos = positions[String(node.id)]
if (pos) node.pos = pos
}
await window.app!.loadGraphData(
data as ComfyWorkflowJSON,
true,
true,
null
)
},
{ positions }
)
}
async function setNodeCollapsed(
page: Page,
nodeId: string,
collapsed: boolean
) {
await page.evaluate(
({ id, collapsed }) => {
const node = window.app!.graph._nodes.find(
(n: { id: number | string }) => String(n.id) === id
)
if (node) {
node.flags = node.flags || {}
node.flags.collapsed = collapsed
window.app!.canvas.setDirty(true, true)
}
},
{ id: nodeId, collapsed }
)
await waitForNodeLayout(page, nodeId)
}
async function assertSlotsWithinNodeBounds(page: Page, nodeId: string) {
await page
.locator(`[data-node-id="${nodeId}"] [data-slot-key]`)
.first()
.waitFor()
const result = await page.evaluate(
({ nodeId, margin }) => {
const nodeEl = document.querySelector(
`[data-node-id="${nodeId}"]`
) as HTMLElement | null
if (!nodeEl) return { ok: false, violations: ['node element not found'] }
const nodeRect = nodeEl.getBoundingClientRect()
const slotEls = nodeEl.querySelectorAll('[data-slot-key]')
const violations: string[] = []
for (const slotEl of slotEls) {
const slotRect = slotEl.getBoundingClientRect()
const cx = slotRect.left + slotRect.width / 2 - nodeRect.left
const cy = slotRect.top + slotRect.height / 2 - nodeRect.top
if (cx < -margin || cx > nodeRect.width + margin)
violations.push(`slot X=${cx} outside width=${nodeRect.width}`)
if (cy < -margin || cy > nodeRect.height + margin)
violations.push(`slot Y=${cy} outside height=${nodeRect.height}`)
}
return { ok: violations.length === 0, violations }
},
{ nodeId, margin: SLOT_BOUNDS_MARGIN }
)
expect(
result.ok,
`Slot positions out of bounds: ${result.violations?.join(', ')}`
).toBe(true)
}
const SUBGRAPH_ID = '2'
const REGULAR_ID = '3'
const WORKFLOW = 'selection/subgraph-with-regular-node'
const REF_POS: [number, number] = [100, 100]
const TARGET_POSITIONS: Record<string, [number, number]> = {
'bottom-left': [50, 500],
'bottom-right': [600, 500]
}
type NodeType = 'subgraph' | 'regular'
type NodeState = 'expanded' | 'collapsed'
type Position = 'bottom-left' | 'bottom-right'
function getTargetId(type: NodeType): string {
return type === 'subgraph' ? SUBGRAPH_ID : REGULAR_ID
}
function getRefId(type: NodeType): string {
return type === 'subgraph' ? REGULAR_ID : SUBGRAPH_ID
}
test.describe('Selection bounding box', { tag: ['@canvas', '@node'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
const nodeTypes: NodeType[] = ['subgraph', 'regular']
const nodeStates: NodeState[] = ['expanded', 'collapsed']
const positions: Position[] = ['bottom-left', 'bottom-right']
for (const type of nodeTypes) {
for (const state of nodeStates) {
for (const pos of positions) {
test(`${type} node (${state}) at ${pos}: selection bounds encompass node`, async ({
comfyPage
}) => {
const page = comfyPage.page
const targetId = getTargetId(type)
const refId = getRefId(type)
await loadWithPositions(page, {
[refId]: REF_POS,
[targetId]: TARGET_POSITIONS[pos]
})
await comfyPage.vueNodes.waitForNodes()
await waitForNodeLayout(page, targetId)
await waitForNodeLayout(page, refId)
if (state === 'collapsed') {
await setNodeCollapsed(page, targetId, true)
}
await comfyPage.canvas.press('Control+a')
await waitForSelectedCount(page, 2)
await comfyPage.nextFrame()
const result = await measureBounds(page, [refId, targetId])
expect(result.selectionBounds).not.toBeNull()
const sel = result.selectionBounds!
const vis = result.nodeVisualBounds[targetId]
expect(vis).toBeDefined()
const selRight = sel.x + sel.w
const selBottom = sel.y + sel.h
const visRight = vis.x + vis.w
const visBottom = vis.y + vis.h
expect(sel.x).toBeLessThanOrEqual(vis.x)
expect(selRight).toBeGreaterThanOrEqual(visRight)
expect(sel.y).toBeLessThanOrEqual(vis.y)
expect(selBottom).toBeGreaterThanOrEqual(visBottom)
})
}
}
}
})
test.describe(
'Collapsed node link positions',
{ tag: ['@canvas', '@node'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
test('link endpoints stay within collapsed node bounds', async ({
comfyPage
}) => {
await setNodeCollapsed(comfyPage.page, SUBGRAPH_ID, true)
await assertSlotsWithinNodeBounds(comfyPage.page, SUBGRAPH_ID)
})
test('links follow collapsed node after position change', async ({
comfyPage
}) => {
const page = comfyPage.page
await loadWithPositions(page, { [SUBGRAPH_ID]: [200, 200] })
await comfyPage.vueNodes.waitForNodes()
await setNodeCollapsed(page, SUBGRAPH_ID, true)
await assertSlotsWithinNodeBounds(page, SUBGRAPH_ID)
})
test('links recover correct positions after expand', async ({
comfyPage
}) => {
const page = comfyPage.page
await setNodeCollapsed(page, SUBGRAPH_ID, true)
await waitForNodeLayout(page, SUBGRAPH_ID)
await setNodeCollapsed(page, SUBGRAPH_ID, false)
await waitForNodeLayout(page, SUBGRAPH_ID)
await assertSlotsWithinNodeBounds(page, SUBGRAPH_ID)
})
}
)

View File

@@ -261,7 +261,8 @@ import {
onMounted,
onUnmounted,
ref,
watch
watch,
watchEffect
} from 'vue'
import { useI18n } from 'vue-i18n'
@@ -780,6 +781,34 @@ const showAdvancedState = customRef((track, trigger) => {
}
})
const FOOTER_BOUNDING_OFFSET = 32
const FOOTER_BOUNDING_OFFSET_COLLAPSED = 34
watchEffect((onCleanup) => {
const node = lgraphNode.value
if (!node) return
const needsFooterOffset = hasFooter.value
const collapsed = isCollapsed.value
if (needsFooterOffset || collapsed) {
node.onBounding = (out) => {
if (needsFooterOffset)
out[3] += collapsed
? FOOTER_BOUNDING_OFFSET_COLLAPSED
: FOOTER_BOUNDING_OFFSET
// Must match CSS --min-node-width in the template.
if (collapsed) out[2] = MIN_NODE_WIDTH
}
} else {
node.onBounding = undefined
}
onCleanup(() => {
node.onBounding = undefined
})
})
const hasVideoInput = computed(() => {
return (
lgraphNode.value?.inputs?.some((input) => input.type === 'VIDEO') ?? false

View File

@@ -19,11 +19,27 @@ import {
} from './useSlotElementTracking'
const mockGraph = vi.hoisted(() => ({ _nodes: [] as unknown[] }))
const mockCanvasState = vi.hoisted(() => ({
canvas: {} as object | null
}))
const mockClientPosToCanvasPos = vi.hoisted(() =>
vi.fn(([x, y]: [number, number]) => [x * 0.5, y * 0.5] as [number, number])
)
vi.mock('@/scripts/app', () => ({
app: { canvas: { graph: mockGraph, setDirty: vi.fn() } }
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => mockCanvasState
}))
vi.mock('@/composables/element/useCanvasPositionConversion', () => ({
useSharedCanvasPositionConversion: () => ({
clientPosToCanvasPos: mockClientPosToCanvasPos
})
}))
const NODE_ID = 'test-node'
const SLOT_INDEX = 0
@@ -45,9 +61,10 @@ function createWrapperComponent(type: 'input' | 'output') {
})
}
function createSlotElement(): HTMLElement {
function createSlotElement(collapsed = false): HTMLElement {
const container = document.createElement('div')
container.dataset.nodeId = NODE_ID
if (collapsed) container.dataset.collapsed = ''
container.getBoundingClientRect = () =>
({
left: 0,
@@ -113,6 +130,8 @@ describe('useSlotElementTracking', () => {
actor: 'test'
})
mockGraph._nodes = [{ id: 1 }]
mockCanvasState.canvas = {}
mockClientPosToCanvasPos.mockClear()
})
it.each([
@@ -251,4 +270,57 @@ describe('useSlotElementTracking', () => {
expect(batchUpdateSpy).not.toHaveBeenCalled()
})
describe('collapsed node slot sync', () => {
function registerCollapsedSlot() {
const slotKey = getSlotKey(NODE_ID, SLOT_INDEX, true)
const slotEl = createSlotElement(true)
const registryStore = useNodeSlotRegistryStore()
const node = registryStore.ensureNode(NODE_ID)
node.slots.set(slotKey, {
el: slotEl,
index: SLOT_INDEX,
type: 'input',
cachedOffset: { x: 50, y: 60 }
})
return { slotKey, node }
}
it('uses clientPosToCanvasPos for collapsed nodes', () => {
const { slotKey } = registerCollapsedSlot()
syncNodeSlotLayoutsFromDOM(NODE_ID)
// Slot element center: (10 + 10/2, 30 + 10/2) = (15, 35)
const screenCenter: [number, number] = [15, 35]
expect(mockClientPosToCanvasPos).toHaveBeenCalledWith(screenCenter)
// Mock returns x*0.5, y*0.5
const layout = layoutStore.getSlotLayout(slotKey)
expect(layout).not.toBeNull()
expect(layout!.position.x).toBe(screenCenter[0] * 0.5)
expect(layout!.position.y).toBe(screenCenter[1] * 0.5)
})
it('clears cachedOffset for collapsed nodes', () => {
const { slotKey, node } = registerCollapsedSlot()
const entry = node.slots.get(slotKey)!
expect(entry.cachedOffset).toBeDefined()
syncNodeSlotLayoutsFromDOM(NODE_ID)
expect(entry.cachedOffset).toBeUndefined()
})
it('defers sync when canvas is not initialized', () => {
mockCanvasState.canvas = null
registerCollapsedSlot()
syncNodeSlotLayoutsFromDOM(NODE_ID)
expect(mockClientPosToCanvasPos).not.toHaveBeenCalled()
})
})
})

View File

@@ -8,7 +8,9 @@
import { onMounted, onUnmounted, watch } from 'vue'
import type { Ref } from 'vue'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { app } from '@/scripts/app'
@@ -134,11 +136,26 @@ export function syncNodeSlotLayoutsFromDOM(nodeId: string) {
.value?.el.closest('[data-node-id]')
const nodeEl = closestNode instanceof HTMLElement ? closestNode : null
const nodeRect = nodeEl?.getBoundingClientRect()
// Collapsed nodes preserve expanded size in layoutStore, so DOM-relative
// scale derivation breaks. Fall back to clientPosToCanvasPos instead.
const isCollapsed = nodeEl?.dataset.collapsed != null
const effectiveScale =
nodeRect && nodeLayout.size.width > 0
!isCollapsed && nodeRect && nodeLayout.size.width > 0
? nodeRect.width / nodeLayout.size.width
: 0
const canvasStore = useCanvasStore()
const conv =
isCollapsed && canvasStore.canvas
? useSharedCanvasPositionConversion()
: null
if (isCollapsed && !conv) {
scheduleSlotLayoutSync(nodeId)
return
}
const batch: Array<{ key: string; layout: SlotLayout }> = []
for (const [slotKey, entry] of node.slots) {
@@ -155,22 +172,30 @@ export function syncNodeSlotLayoutsFromDOM(nodeId: string) {
rect.top + rect.height / 2
]
if (!nodeRect || effectiveScale <= 0) continue
let centerCanvas: { x: number; y: number }
// DOM-relative measurement: compute offset from the node element's
// top-left corner in canvas units. The node element is rendered at
// (position.x, position.y - NODE_TITLE_HEIGHT), so the Y offset
// must subtract NODE_TITLE_HEIGHT to be relative to position.y.
entry.cachedOffset = {
x: (screenCenter[0] - nodeRect.left) / effectiveScale,
y:
(screenCenter[1] - nodeRect.top) / effectiveScale -
LiteGraph.NODE_TITLE_HEIGHT
}
if (conv) {
const [cx, cy] = conv.clientPosToCanvasPos(screenCenter)
centerCanvas = { x: cx, y: cy }
entry.cachedOffset = undefined
} else {
if (!nodeRect || effectiveScale <= 0) continue
const centerCanvas = {
x: nodeLayout.position.x + entry.cachedOffset.x,
y: nodeLayout.position.y + entry.cachedOffset.y
// DOM-relative measurement: compute offset from the node element's
// top-left corner in canvas units. The node element is rendered at
// (position.x, position.y - NODE_TITLE_HEIGHT), so the Y offset
// must subtract NODE_TITLE_HEIGHT to be relative to position.y.
entry.cachedOffset = {
x: (screenCenter[0] - nodeRect.left) / effectiveScale,
y:
(screenCenter[1] - nodeRect.top) / effectiveScale -
LiteGraph.NODE_TITLE_HEIGHT
}
centerCanvas = {
x: nodeLayout.position.x + entry.cachedOffset.x,
y: nodeLayout.position.y + entry.cachedOffset.y
}
}
const nextLayout = createSlotLayout({

View File

@@ -15,8 +15,8 @@ import { useDocumentVisibility } from '@vueuse/core'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
import { LayoutSource } from '@/renderer/core/layout/types'
import {