mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-30 01:05:46 +00:00
Compare commits
5 Commits
refactor/n
...
test/prior
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4f4ea4c37 | ||
|
|
7ad55a866c | ||
|
|
de2a41cfb0 | ||
|
|
719d2d9b0b | ||
|
|
04c10981d2 |
@@ -1,116 +0,0 @@
|
||||
{
|
||||
"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": [400, 200],
|
||||
"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
|
||||
}
|
||||
@@ -4,10 +4,7 @@ import type {
|
||||
LGraph,
|
||||
LGraphNode
|
||||
} from '../../../src/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
ComfyWorkflowJSON,
|
||||
NodeId
|
||||
} from '../../../src/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { NodeId } from '../../../src/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import { DefaultGraphPositions } from '../constants/defaultGraphPositions'
|
||||
import type { Position, Size } from '../types'
|
||||
@@ -114,27 +111,6 @@ export class NodeOperationsHelper {
|
||||
}
|
||||
}
|
||||
|
||||
async getSerializedGraph(): Promise<ComfyWorkflowJSON> {
|
||||
return this.page.evaluate(
|
||||
() => window.app!.graph.serialize() as ComfyWorkflowJSON
|
||||
)
|
||||
}
|
||||
|
||||
async loadGraph(data: ComfyWorkflowJSON): Promise<void> {
|
||||
await this.page.evaluate(
|
||||
(d) => window.app!.loadGraphData(d, true, true, null),
|
||||
data
|
||||
)
|
||||
}
|
||||
|
||||
async repositionNodes(
|
||||
positions: Record<string, [number, number]>
|
||||
): Promise<void> {
|
||||
const data = await this.getSerializedGraph()
|
||||
applyNodePositions(data, positions)
|
||||
await this.loadGraph(data)
|
||||
}
|
||||
|
||||
async resizeNode(
|
||||
nodePos: Position,
|
||||
nodeSize: Size,
|
||||
@@ -209,13 +185,3 @@ export class NodeOperationsHelper {
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
function applyNodePositions(
|
||||
data: ComfyWorkflowJSON,
|
||||
positions: Record<string, [number, number]>
|
||||
): void {
|
||||
for (const node of data.nodes) {
|
||||
const pos = positions[String(node.id)]
|
||||
if (pos) node.pos = pos
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
export interface CanvasRect {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
export interface MeasureResult {
|
||||
selectionBounds: CanvasRect | null
|
||||
nodeVisualBounds: Record<string, CanvasRect>
|
||||
}
|
||||
|
||||
// Must match the padding value passed to createBounds() in selectionBorder.ts
|
||||
const SELECTION_PADDING = 10
|
||||
|
||||
export async function measureSelectionBounds(
|
||||
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,
|
||||
{ x: number; y: number; w: number; h: number }
|
||||
> = {}
|
||||
|
||||
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>
|
||||
}
|
||||
@@ -332,17 +332,6 @@ export class NodeReference {
|
||||
async isCollapsed() {
|
||||
return !!(await this.getFlags()).collapsed
|
||||
}
|
||||
/** Deterministic setter using node.collapse() API (not a toggle). */
|
||||
async setCollapsed(collapsed: boolean) {
|
||||
await this.comfyPage.page.evaluate(
|
||||
([id, collapsed]) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
if (!node) throw new Error('Node not found')
|
||||
if (node.collapsed !== collapsed) node.collapse(true)
|
||||
},
|
||||
[this.id, collapsed] as const
|
||||
)
|
||||
}
|
||||
async isBypassed() {
|
||||
return (await this.getProperty<number | null | undefined>('mode')) === 4
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { measureSelectionBounds } from '../fixtures/utils/boundsUtils'
|
||||
|
||||
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 comfyPage.nodeOps.repositionNodes({
|
||||
[refId]: REF_POS,
|
||||
[targetId]: TARGET_POSITIONS[pos]
|
||||
})
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.vueNodes.getNodeLocator(targetId).waitFor()
|
||||
await comfyPage.vueNodes.getNodeLocator(refId).waitFor()
|
||||
|
||||
if (state === 'collapsed') {
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById(targetId)
|
||||
await nodeRef.setCollapsed(true)
|
||||
}
|
||||
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(2)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const result = await measureSelectionBounds(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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -422,16 +422,6 @@ export class LGraphNode
|
||||
* Updated by {@link LGraphCanvas.drawNode}
|
||||
*/
|
||||
_collapsed_width?: number
|
||||
/**
|
||||
* The height of the node when collapsed (including footer).
|
||||
* Set by ResizeObserver in Vue nodes mode.
|
||||
*/
|
||||
_collapsed_height?: number
|
||||
/**
|
||||
* The footer height in Vue nodes mode.
|
||||
* Set by ResizeObserver, used by measure() to extend boundingRect.
|
||||
*/
|
||||
_footerHeight?: number
|
||||
/**
|
||||
* Called once at the start of every frame. Caller may change the values in {@link out}, which will be reflected in {@link boundingRect}.
|
||||
* WARNING: Making changes to boundingRect via onBounding is poorly supported, and will likely result in strange behaviour.
|
||||
@@ -2100,18 +2090,18 @@ export class LGraphNode
|
||||
out[1] = this.pos[1] + -titleHeight
|
||||
if (!this.flags?.collapsed) {
|
||||
out[2] = this.size[0]
|
||||
out[3] = this.size[1] + titleHeight + (this._footerHeight ?? 0)
|
||||
out[3] = this.size[1] + titleHeight
|
||||
} else {
|
||||
if (ctx && !LiteGraph.vueNodesMode) {
|
||||
ctx.font = this.innerFontStyle
|
||||
this._collapsed_width = Math.min(
|
||||
this.size[0],
|
||||
cachedMeasureText(ctx, this.getTitle() ?? '') +
|
||||
LiteGraph.NODE_TITLE_HEIGHT * 2
|
||||
)
|
||||
}
|
||||
if (ctx) ctx.font = this.innerFontStyle
|
||||
this._collapsed_width = Math.min(
|
||||
this.size[0],
|
||||
ctx
|
||||
? cachedMeasureText(ctx, this.getTitle() ?? '') +
|
||||
LiteGraph.NODE_TITLE_HEIGHT * 2
|
||||
: 0
|
||||
)
|
||||
out[2] = this._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH
|
||||
out[3] = this._collapsed_height || LiteGraph.NODE_TITLE_HEIGHT
|
||||
out[3] = LiteGraph.NODE_TITLE_HEIGHT
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
:data-collapsed="isCollapsed || undefined"
|
||||
:class="
|
||||
cn(
|
||||
'group/node lg-node absolute isolate flex flex-col border border-solid text-sm contain-layout contain-style',
|
||||
hasAnyError ? 'border-transparent' : 'border-component-node-border',
|
||||
rootBorderShapeClass,
|
||||
outlineClass,
|
||||
'group/node lg-node absolute isolate text-sm',
|
||||
'flex flex-col contain-layout contain-style',
|
||||
isRerouteNode ? 'h-(--node-height)' : 'min-w-(--min-node-width)',
|
||||
cursorClass,
|
||||
isSelected && 'outline-node-component-outline',
|
||||
executing && 'outline-node-stroke-executing',
|
||||
shouldHandleNodePointerEvents && !nodeData.flags?.ghost
|
||||
? 'pointer-events-auto'
|
||||
: 'pointer-events-none'
|
||||
@@ -45,11 +45,37 @@
|
||||
"
|
||||
:id="nodeData.id"
|
||||
/>
|
||||
<div
|
||||
v-if="isSelected || executing"
|
||||
data-testid="node-state-outline-overlay"
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none absolute z-0 border-3 outline-none',
|
||||
selectionShapeClass,
|
||||
hasAnyError ? '-inset-[7px]' : '-inset-[3px]',
|
||||
isSelected
|
||||
? 'border-node-component-outline'
|
||||
: 'border-node-stroke-executing',
|
||||
footerStateOutlineBottomClass
|
||||
)
|
||||
"
|
||||
/>
|
||||
<!-- Root Border Overlay -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none absolute border border-solid border-component-node-border',
|
||||
rootBorderShapeClass,
|
||||
hasAnyError ? '-inset-1' : 'inset-0',
|
||||
footerRootBorderBottomClass
|
||||
)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
data-testid="node-inner-wrapper"
|
||||
:class="
|
||||
cn(
|
||||
'relative z-5 flex flex-1 flex-col border border-solid border-transparent bg-node-component-header-surface',
|
||||
'flex flex-1 flex-col border border-solid border-transparent bg-node-component-header-surface',
|
||||
'w-(--node-width)',
|
||||
!isRerouteNode && 'min-h-(--node-height) min-w-(--min-node-width)',
|
||||
shapeClass,
|
||||
@@ -164,58 +190,13 @@
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template
|
||||
v-if="
|
||||
!isCollapsed &&
|
||||
!isRerouteNode &&
|
||||
nodeData.resizable !== false &&
|
||||
!isSelectMode
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-for="handle in RESIZE_HANDLES"
|
||||
v-show="
|
||||
handle.corner === 'NE' || handle.corner === 'NW' || !hasFooter
|
||||
"
|
||||
:key="handle.corner"
|
||||
role="button"
|
||||
:aria-label="t(handle.i18nKey)"
|
||||
:class="
|
||||
cn(
|
||||
baseResizeHandleClasses,
|
||||
handle.positionClasses,
|
||||
handle.cursorClass,
|
||||
'group-hover/node:opacity-100'
|
||||
)
|
||||
"
|
||||
@pointerdown.stop="handleResizePointerDown($event, handle.corner)"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 12 12"
|
||||
:class="cn('absolute size-2/5', handle.svgPositionClasses)"
|
||||
:style="
|
||||
handle.svgTransform
|
||||
? { transform: handle.svgTransform }
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
<path
|
||||
d="M11 1L1 11M11 6L6 11"
|
||||
stroke="var(--color-muted-foreground)"
|
||||
stroke-width="0.975"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<NodeFooter
|
||||
v-if="!isRerouteNode"
|
||||
:is-subgraph="!!lgraphNode?.isSubgraphNode()"
|
||||
:has-any-error="hasAnyError"
|
||||
:show-errors-tab-enabled="showErrorsTabEnabled"
|
||||
:is-collapsed="isCollapsed"
|
||||
:show-advanced-inputs-button="showAdvancedInputsButton"
|
||||
:show-advanced-state="showAdvancedState"
|
||||
:header-color="applyLightThemeColor(nodeData?.color)"
|
||||
@@ -226,7 +207,6 @@
|
||||
/>
|
||||
<template
|
||||
v-if="
|
||||
hasFooter &&
|
||||
!isCollapsed &&
|
||||
!isRerouteNode &&
|
||||
nodeData.resizable !== false &&
|
||||
@@ -234,16 +214,16 @@
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-for="handle in RESIZE_HANDLES.filter(
|
||||
(h) => h.corner === 'SE' || h.corner === 'SW'
|
||||
)"
|
||||
:key="`footer-${handle.corner}`"
|
||||
v-for="handle in RESIZE_HANDLES"
|
||||
:key="handle.corner"
|
||||
role="button"
|
||||
:aria-label="t(handle.i18nKey)"
|
||||
:class="
|
||||
cn(
|
||||
baseResizeHandleClasses,
|
||||
handle.positionClasses,
|
||||
(handle.corner === 'SE' || handle.corner === 'SW') &&
|
||||
footerResizeHandleBottomClass,
|
||||
handle.cursorClass,
|
||||
'group-hover/node:opacity-100'
|
||||
)
|
||||
@@ -524,7 +504,7 @@ onUnmounted(() => {
|
||||
})
|
||||
|
||||
const baseResizeHandleClasses =
|
||||
'absolute z-10 h-5 w-5 opacity-0 pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/40'
|
||||
'absolute h-5 w-5 opacity-0 pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/40'
|
||||
|
||||
const mutations = useLayoutMutations()
|
||||
|
||||
@@ -585,25 +565,6 @@ const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
|
||||
}
|
||||
)
|
||||
|
||||
const outlineClass = computed(() => {
|
||||
const color = isSelected.value
|
||||
? 'outline-node-component-outline'
|
||||
: executing.value
|
||||
? 'outline-node-stroke-executing'
|
||||
: null
|
||||
if (!color) return ''
|
||||
|
||||
if (!hasAnyError.value) return cn('outline-3', color)
|
||||
|
||||
const errorRadius =
|
||||
nodeData.shape === RenderShape.BOX
|
||||
? ''
|
||||
: nodeData.shape === RenderShape.CARD
|
||||
? 'rounded-tl-[16px] rounded-br-[16px]'
|
||||
: 'rounded-[16px]'
|
||||
return cn('outline-4 outline-offset-[3px]', errorRadius, color)
|
||||
})
|
||||
|
||||
const hasFooter = computed(() => {
|
||||
return !!(
|
||||
(hasAnyError.value && showErrorsTabEnabled.value) ||
|
||||
@@ -613,6 +574,21 @@ const hasFooter = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
// Footer offset computed classes
|
||||
|
||||
const footerStateOutlineBottomClass = computed(() =>
|
||||
hasFooter.value ? '-bottom-[35px]' : ''
|
||||
)
|
||||
|
||||
const footerRootBorderBottomClass = computed(() =>
|
||||
hasFooter.value ? '-bottom-8' : ''
|
||||
)
|
||||
|
||||
const footerResizeHandleBottomClass = computed(() => {
|
||||
if (!hasFooter.value) return ''
|
||||
return hasAnyError.value ? 'bottom-[-31px]' : 'bottom-[-35px]'
|
||||
})
|
||||
|
||||
const cursorClass = computed(() => {
|
||||
if (nodeData.flags?.pinned) return 'cursor-default'
|
||||
return layoutStore.isDraggingVueNodes.value
|
||||
@@ -625,9 +601,9 @@ const bodyRoundingClass = computed(() => {
|
||||
case RenderShape.BOX:
|
||||
return ''
|
||||
case RenderShape.CARD:
|
||||
return 'rounded-br-[15px]'
|
||||
return 'rounded-br-2xl'
|
||||
default:
|
||||
return 'rounded-b-[15px]'
|
||||
return 'rounded-b-2xl'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -636,9 +612,9 @@ const shapeClass = computed(() => {
|
||||
case RenderShape.BOX:
|
||||
return ''
|
||||
case RenderShape.CARD:
|
||||
return 'rounded-tl-[15px] rounded-br-[15px]'
|
||||
return 'rounded-tl-2xl rounded-br-2xl'
|
||||
default:
|
||||
return 'rounded-[15px]'
|
||||
return 'rounded-2xl'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -651,15 +627,33 @@ const isTransparentHeaderless = computed(
|
||||
|
||||
const rootBorderShapeClass = computed(() => {
|
||||
if (isTransparentHeaderless.value) return 'border-0'
|
||||
|
||||
const isExpanded = hasAnyError.value
|
||||
switch (nodeData.shape) {
|
||||
case RenderShape.BOX:
|
||||
return ''
|
||||
case RenderShape.CARD:
|
||||
return hasAnyError.value
|
||||
? 'rounded-tl-[21px] rounded-br-[21px]'
|
||||
: 'rounded-tl-[16px] rounded-br-[16px]'
|
||||
return isExpanded
|
||||
? 'rounded-tl-[20px] rounded-br-[20px]'
|
||||
: 'rounded-tl-2xl rounded-br-2xl'
|
||||
default:
|
||||
return hasAnyError.value ? 'rounded-[21px]' : 'rounded-[16px]'
|
||||
return isExpanded ? 'rounded-[20px]' : 'rounded-2xl'
|
||||
}
|
||||
})
|
||||
|
||||
const selectionShapeClass = computed(() => {
|
||||
if (isTransparentHeaderless.value) return 'border-0'
|
||||
|
||||
const isExpanded = hasAnyError.value
|
||||
switch (nodeData.shape) {
|
||||
case RenderShape.BOX:
|
||||
return ''
|
||||
case RenderShape.CARD:
|
||||
return isExpanded
|
||||
? 'rounded-tl-[23px] rounded-br-[23px]'
|
||||
: 'rounded-tl-[19px] rounded-br-[19px]'
|
||||
default:
|
||||
return isExpanded ? 'rounded-[23px]' : 'rounded-[19px]'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
<template>
|
||||
<!-- Case 1: Subgraph + Error (Dual Tabs) -->
|
||||
<div
|
||||
v-if="isSubgraph && hasAnyError && showErrorsTabEnabled"
|
||||
class="-mx-1 -mt-5 -mb-2 box-border flex w-[calc(100%+8px)] pb-1"
|
||||
>
|
||||
<template v-if="isSubgraph && hasAnyError && showErrorsTabEnabled">
|
||||
<Button
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
getTabStyles(false),
|
||||
'box-border w-1/2 rounded-none bg-destructive-background pt-9 pb-4 text-white hover:bg-destructive-background-hover',
|
||||
errorRadiusClass
|
||||
errorTabWidth,
|
||||
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
|
||||
)
|
||||
"
|
||||
@click.stop="$emit('openErrors')"
|
||||
@@ -27,37 +24,36 @@
|
||||
:class="
|
||||
cn(
|
||||
getTabStyles(true),
|
||||
'-ml-5 box-border w-[calc(50%+20px)] rounded-none bg-node-component-header-surface pt-9 pb-4 pl-5 ring-1 ring-component-node-border ring-inset',
|
||||
enterRadiusClass
|
||||
enterTabFullWidth,
|
||||
'-z-10 bg-node-component-header-surface'
|
||||
)
|
||||
"
|
||||
:style="headerColorStyle"
|
||||
@click.stop="$emit('enterSubgraph')"
|
||||
>
|
||||
<div class="flex size-full items-center justify-center gap-2">
|
||||
<div class="ml-auto flex h-full w-1/2 items-center justify-center gap-2">
|
||||
<span class="truncate">{{ t('g.enter') }}</span>
|
||||
<i class="icon-[comfy--workflow] size-4 shrink-0" />
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Case 1b: Advanced + Error (Dual Tabs, Regular Nodes) -->
|
||||
<div
|
||||
<template
|
||||
v-else-if="
|
||||
!isSubgraph &&
|
||||
hasAnyError &&
|
||||
showErrorsTabEnabled &&
|
||||
(showAdvancedInputsButton || showAdvancedState)
|
||||
"
|
||||
class="-mx-1 -mt-5 -mb-2 box-border flex w-[calc(100%+8px)] pb-1"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
getTabStyles(false),
|
||||
'box-border w-1/2 rounded-none bg-destructive-background pt-9 pb-4 text-white hover:bg-destructive-background-hover',
|
||||
errorRadiusClass
|
||||
errorTabWidth,
|
||||
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
|
||||
)
|
||||
"
|
||||
@click.stop="$emit('openErrors')"
|
||||
@@ -73,14 +69,14 @@
|
||||
:class="
|
||||
cn(
|
||||
getTabStyles(true),
|
||||
'-ml-5 box-border w-[calc(50%+20px)] rounded-none bg-node-component-header-surface pt-9 pb-4 pl-5 ring-1 ring-component-node-border ring-inset',
|
||||
enterRadiusClass
|
||||
enterTabFullWidth,
|
||||
'-z-10 bg-node-component-header-surface'
|
||||
)
|
||||
"
|
||||
:style="headerColorStyle"
|
||||
@click.stop="$emit('toggleAdvanced')"
|
||||
>
|
||||
<div class="flex size-full items-center justify-center gap-2">
|
||||
<div class="ml-auto flex h-full w-1/2 items-center justify-center gap-2">
|
||||
<span class="truncate">{{
|
||||
showAdvancedState
|
||||
? t('rightSidePanel.hideAdvancedShort')
|
||||
@@ -95,20 +91,17 @@
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Case 2: Error Only (Full Width) -->
|
||||
<div
|
||||
v-else-if="hasAnyError && showErrorsTabEnabled"
|
||||
class="-mx-1 -mt-5 -mb-2 box-border flex w-[calc(100%+8px)] pb-1"
|
||||
>
|
||||
<template v-else-if="hasAnyError && showErrorsTabEnabled">
|
||||
<Button
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
getTabStyles(false),
|
||||
'box-border w-full rounded-none bg-destructive-background pt-9 pb-4 text-white hover:bg-destructive-background-hover',
|
||||
footerRadiusClass
|
||||
enterTabFullWidth,
|
||||
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
|
||||
)
|
||||
"
|
||||
@click.stop="$emit('openErrors')"
|
||||
@@ -118,29 +111,18 @@
|
||||
<i class="icon-[lucide--info] size-4 shrink-0" />
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Case 3: Subgraph only (Full Width) -->
|
||||
<div
|
||||
v-else-if="isSubgraph"
|
||||
:class="
|
||||
cn(
|
||||
'-mt-5 box-border flex',
|
||||
hasAnyError ? '-mx-1 -mb-2 w-[calc(100%+8px)] pb-1' : 'w-full'
|
||||
)
|
||||
"
|
||||
>
|
||||
<template v-else-if="isSubgraph">
|
||||
<Button
|
||||
variant="textonly"
|
||||
data-testid="subgraph-enter-button"
|
||||
:class="
|
||||
cn(
|
||||
getTabStyles(true),
|
||||
'box-border w-full rounded-none bg-node-component-header-surface',
|
||||
hasAnyError
|
||||
? 'pt-9 pb-4 ring-1 ring-component-node-border ring-inset'
|
||||
: 'pt-8 pb-4',
|
||||
footerRadiusClass
|
||||
hasAnyError ? 'w-[calc(100%+8px)]' : 'w-full',
|
||||
'-z-10 bg-node-component-header-surface'
|
||||
)
|
||||
"
|
||||
:style="headerColorStyle"
|
||||
@@ -151,49 +133,37 @@
|
||||
<i class="icon-[comfy--workflow] size-4 shrink-0" />
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Case 4: Advanced Footer (Regular Nodes) -->
|
||||
<div
|
||||
<Button
|
||||
v-else-if="showAdvancedInputsButton || showAdvancedState"
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
'-mt-5 box-border flex',
|
||||
hasAnyError ? '-mx-1 -mb-2 w-[calc(100%+8px)] pb-1' : 'w-full'
|
||||
getTabStyles(true),
|
||||
hasAnyError ? 'w-[calc(100%+8px)]' : 'w-full',
|
||||
'-z-10 bg-node-component-header-surface'
|
||||
)
|
||||
"
|
||||
:style="headerColorStyle"
|
||||
@click.stop="$emit('toggleAdvanced')"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
getTabStyles(true),
|
||||
'box-border w-full rounded-none bg-node-component-header-surface',
|
||||
hasAnyError
|
||||
? 'pt-9 pb-4 ring-1 ring-component-node-border ring-inset'
|
||||
: 'pt-8 pb-4',
|
||||
footerRadiusClass
|
||||
)
|
||||
"
|
||||
:style="headerColorStyle"
|
||||
@click.stop="$emit('toggleAdvanced')"
|
||||
>
|
||||
<div class="flex size-full items-center justify-center gap-2">
|
||||
<template v-if="showAdvancedState">
|
||||
<span class="truncate">{{
|
||||
t('rightSidePanel.hideAdvancedInputsButton')
|
||||
}}</span>
|
||||
<i class="icon-[lucide--chevron-up] size-4 shrink-0" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="truncate">{{
|
||||
t('rightSidePanel.showAdvancedInputsButton')
|
||||
}}</span>
|
||||
<i class="icon-[lucide--settings-2] size-4 shrink-0" />
|
||||
</template>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex size-full items-center justify-center gap-2">
|
||||
<template v-if="showAdvancedState">
|
||||
<span class="truncate">{{
|
||||
t('rightSidePanel.hideAdvancedInputsButton')
|
||||
}}</span>
|
||||
<i class="icon-[lucide--chevron-up] size-4 shrink-0" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="truncate">{{
|
||||
t('rightSidePanel.showAdvancedInputsButton')
|
||||
}}</span>
|
||||
<i class="icon-[lucide--settings-2] size-4 shrink-0" />
|
||||
</template>
|
||||
</div>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -209,6 +179,7 @@ interface Props {
|
||||
isSubgraph: boolean
|
||||
hasAnyError: boolean
|
||||
showErrorsTabEnabled: boolean
|
||||
isCollapsed: boolean
|
||||
showAdvancedInputsButton?: boolean
|
||||
showAdvancedState?: boolean
|
||||
headerColor?: string
|
||||
@@ -224,43 +195,51 @@ defineEmits<{
|
||||
}>()
|
||||
|
||||
const footerRadiusClass = computed(() => {
|
||||
const isError = props.hasAnyError
|
||||
const isExpanded = props.hasAnyError
|
||||
|
||||
switch (props.shape) {
|
||||
case RenderShape.BOX:
|
||||
return ''
|
||||
case RenderShape.CARD:
|
||||
return isError ? 'rounded-br-[19px]' : 'rounded-br-[15px]'
|
||||
return isExpanded ? 'rounded-br-[20px]' : 'rounded-br-2xl'
|
||||
default:
|
||||
return isError ? 'rounded-b-[19px]' : 'rounded-b-[15px]'
|
||||
}
|
||||
})
|
||||
|
||||
const errorRadiusClass = computed(() => {
|
||||
switch (props.shape) {
|
||||
case RenderShape.BOX:
|
||||
return ''
|
||||
case RenderShape.CARD:
|
||||
return 'rounded-br-[19px]'
|
||||
default:
|
||||
return 'rounded-b-[19px]'
|
||||
}
|
||||
})
|
||||
|
||||
const enterRadiusClass = computed(() => {
|
||||
switch (props.shape) {
|
||||
case RenderShape.BOX:
|
||||
return ''
|
||||
case RenderShape.CARD:
|
||||
default:
|
||||
return 'rounded-br-[19px]'
|
||||
return isExpanded ? 'rounded-b-[20px]' : 'rounded-b-2xl'
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Returns shared size/position classes for footer tabs
|
||||
* @param isBackground If true, calculates styles for the background/right tab (Enter Subgraph)
|
||||
*/
|
||||
const getTabStyles = (isBackground = false) => {
|
||||
return cn('pointer-events-auto h-9 text-xs', isBackground ? 'z-0' : 'z-2')
|
||||
let sizeClasses = ''
|
||||
if (props.isCollapsed) {
|
||||
let pt = 'pt-10'
|
||||
if (isBackground) {
|
||||
pt = props.hasAnyError ? 'pt-10.5' : 'pt-9'
|
||||
}
|
||||
sizeClasses = cn('-mt-7.5 h-15', pt)
|
||||
} else {
|
||||
let pt = 'pt-12.5'
|
||||
if (isBackground) {
|
||||
pt = props.hasAnyError ? 'pt-12.5' : 'pt-11.5'
|
||||
}
|
||||
sizeClasses = cn('-mt-10 h-17.5', pt)
|
||||
}
|
||||
|
||||
return cn(
|
||||
'pointer-events-auto absolute top-full left-0 text-xs',
|
||||
footerRadiusClass.value,
|
||||
sizeClasses,
|
||||
props.hasAnyError ? '-translate-x-1 translate-y-0.5' : 'translate-y-0.5'
|
||||
)
|
||||
}
|
||||
|
||||
const headerColorStyle = computed(() =>
|
||||
props.headerColor ? { backgroundColor: props.headerColor } : undefined
|
||||
)
|
||||
|
||||
// Case 1 context: Split widths
|
||||
const errorTabWidth = 'w-[calc(50%+4px)]'
|
||||
const enterTabFullWidth = 'w-[calc(100%+8px)]'
|
||||
</script>
|
||||
|
||||
@@ -15,9 +15,8 @@ import { useDocumentVisibility } from '@vueuse/core'
|
||||
|
||||
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import {
|
||||
@@ -140,44 +139,25 @@ const resizeObserver = new ResizeObserver((entries) => {
|
||||
const nodeId: NodeId | undefined =
|
||||
elementType === 'node' ? elementId : undefined
|
||||
|
||||
// Collapsed nodes: don't update layoutStore (preserve expanded size),
|
||||
// but sync the collapsed DOM width to litegraph for boundingRect.
|
||||
// Skip collapsed nodes — their DOM height is just the header, and writing
|
||||
// that back to the layout store would overwrite the stored expanded size.
|
||||
if (elementType === 'node' && element.dataset.collapsed != null) {
|
||||
if (nodeId) {
|
||||
const lgNode = app.graph?.getNodeById(nodeId)
|
||||
if (lgNode) {
|
||||
const body = element.querySelector(
|
||||
'[data-testid^="node-inner-wrapper"]'
|
||||
)
|
||||
lgNode._collapsed_width =
|
||||
body instanceof HTMLElement ? body.offsetWidth : element.offsetWidth
|
||||
lgNode._collapsed_height = element.offsetHeight
|
||||
}
|
||||
nodesNeedingSlotResync.add(nodeId)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Measure body (node-inner-wrapper) for node.size to exclude footer,
|
||||
// but store the full height (with footer) for boundingRect.
|
||||
const bodyEl = element.querySelector('[data-testid^="node-inner-wrapper"]')
|
||||
const measuredEl = bodyEl instanceof HTMLElement ? bodyEl : element
|
||||
const width = Math.max(0, measuredEl.offsetWidth)
|
||||
const height = Math.max(0, measuredEl.offsetHeight)
|
||||
const fullHeight = Math.max(0, element.offsetHeight)
|
||||
|
||||
// Store footer-inclusive height for boundingRect calculation
|
||||
if (nodeId) {
|
||||
const lgNode = app.graph?.getNodeById(nodeId)
|
||||
if (lgNode) {
|
||||
const footerExtra = fullHeight - measuredEl.offsetHeight
|
||||
if (footerExtra > 0) {
|
||||
lgNode._footerHeight = footerExtra
|
||||
} else {
|
||||
lgNode._footerHeight = undefined
|
||||
// Use borderBoxSize when available; fall back to contentRect for older engines/tests
|
||||
// Border box is the border included FULL wxh DOM value.
|
||||
const borderBox = Array.isArray(entry.borderBoxSize)
|
||||
? entry.borderBoxSize[0]
|
||||
: {
|
||||
inlineSize: entry.contentRect.width,
|
||||
blockSize: entry.contentRect.height
|
||||
}
|
||||
}
|
||||
}
|
||||
const width = Math.max(0, borderBox.inlineSize)
|
||||
const height = Math.max(0, borderBox.blockSize)
|
||||
const nodeLayout = nodeId
|
||||
? layoutStore.getNodeLayoutRef(nodeId).value
|
||||
: null
|
||||
|
||||
@@ -51,10 +51,7 @@ export function useNodeResize(
|
||||
const nodeId = nodeElement.dataset.nodeId
|
||||
if (!nodeId) return
|
||||
|
||||
const bodyElement =
|
||||
nodeElement.querySelector('[data-testid^="node-inner-wrapper"]') ??
|
||||
nodeElement
|
||||
const rect = bodyElement.getBoundingClientRect()
|
||||
const rect = nodeElement.getBoundingClientRect()
|
||||
const scale = transformState.camera.z
|
||||
|
||||
const startSize: Size = {
|
||||
@@ -64,7 +61,7 @@ export function useNodeResize(
|
||||
|
||||
const savedNodeHeight = nodeElement.style.getPropertyValue('--node-height')
|
||||
nodeElement.style.setProperty('--node-height', '0px')
|
||||
const minContentHeight = bodyElement.getBoundingClientRect().height / scale
|
||||
const minContentHeight = nodeElement.getBoundingClientRect().height / scale
|
||||
nodeElement.style.setProperty('--node-height', savedNodeHeight || '')
|
||||
|
||||
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
|
||||
|
||||
211
src/stores/commandStore.test.ts
Normal file
211
src/stores/commandStore.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({
|
||||
wrapWithErrorHandlingAsync:
|
||||
(fn: () => Promise<void>, errorHandler?: (e: unknown) => void) =>
|
||||
async () => {
|
||||
try {
|
||||
await fn()
|
||||
} catch (e) {
|
||||
if (errorHandler) errorHandler(e)
|
||||
else throw e
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/keybindings/keybindingStore', () => ({
|
||||
useKeybindingStore: () => ({
|
||||
getKeybindingByCommandId: () => null
|
||||
})
|
||||
}))
|
||||
|
||||
describe('commandStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
describe('registerCommand', () => {
|
||||
it('registers a command by id', () => {
|
||||
const store = useCommandStore()
|
||||
store.registerCommand({
|
||||
id: 'test.command',
|
||||
function: vi.fn()
|
||||
})
|
||||
expect(store.isRegistered('test.command')).toBe(true)
|
||||
})
|
||||
|
||||
it('warns on duplicate registration', () => {
|
||||
const store = useCommandStore()
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
store.registerCommand({ id: 'dup', function: vi.fn() })
|
||||
store.registerCommand({ id: 'dup', function: vi.fn() })
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith('Command dup already registered')
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerCommands', () => {
|
||||
it('registers multiple commands at once', () => {
|
||||
const store = useCommandStore()
|
||||
store.registerCommands([
|
||||
{ id: 'cmd.a', function: vi.fn() },
|
||||
{ id: 'cmd.b', function: vi.fn() }
|
||||
])
|
||||
expect(store.isRegistered('cmd.a')).toBe(true)
|
||||
expect(store.isRegistered('cmd.b')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCommand', () => {
|
||||
it('returns the registered command', () => {
|
||||
const store = useCommandStore()
|
||||
const fn = vi.fn()
|
||||
store.registerCommand({ id: 'get.test', function: fn, label: 'Test' })
|
||||
const cmd = store.getCommand('get.test')
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd?.label).toBe('Test')
|
||||
})
|
||||
|
||||
it('returns undefined for unregistered command', () => {
|
||||
const store = useCommandStore()
|
||||
expect(store.getCommand('nonexistent')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('commands getter', () => {
|
||||
it('returns all registered commands as an array', () => {
|
||||
const store = useCommandStore()
|
||||
store.registerCommand({ id: 'a', function: vi.fn() })
|
||||
store.registerCommand({ id: 'b', function: vi.fn() })
|
||||
expect(store.commands).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('execute', () => {
|
||||
it('executes a registered command', async () => {
|
||||
const store = useCommandStore()
|
||||
const fn = vi.fn()
|
||||
store.registerCommand({ id: 'exec.test', function: fn })
|
||||
await store.execute('exec.test')
|
||||
expect(fn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws for unregistered command', async () => {
|
||||
const store = useCommandStore()
|
||||
await expect(store.execute('missing')).rejects.toThrow(
|
||||
'Command missing not found'
|
||||
)
|
||||
})
|
||||
|
||||
it('passes metadata to the command function', async () => {
|
||||
const store = useCommandStore()
|
||||
const fn = vi.fn()
|
||||
store.registerCommand({ id: 'meta.test', function: fn })
|
||||
await store.execute('meta.test', { metadata: { source: 'keyboard' } })
|
||||
expect(fn).toHaveBeenCalledWith({ source: 'keyboard' })
|
||||
})
|
||||
|
||||
it('calls errorHandler on failure', async () => {
|
||||
const store = useCommandStore()
|
||||
const error = new Error('fail')
|
||||
store.registerCommand({
|
||||
id: 'err.test',
|
||||
function: () => {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
const handler = vi.fn()
|
||||
await store.execute('err.test', { errorHandler: handler })
|
||||
expect(handler).toHaveBeenCalledWith(error)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRegistered', () => {
|
||||
it('returns false for unregistered command', () => {
|
||||
const store = useCommandStore()
|
||||
expect(store.isRegistered('nope')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadExtensionCommands', () => {
|
||||
it('registers commands from an extension', () => {
|
||||
const store = useCommandStore()
|
||||
store.loadExtensionCommands({
|
||||
name: 'test-ext',
|
||||
commands: [
|
||||
{ id: 'ext.cmd1', function: vi.fn(), label: 'Cmd 1' },
|
||||
{ id: 'ext.cmd2', function: vi.fn(), label: 'Cmd 2' }
|
||||
]
|
||||
})
|
||||
expect(store.isRegistered('ext.cmd1')).toBe(true)
|
||||
expect(store.isRegistered('ext.cmd2')).toBe(true)
|
||||
})
|
||||
|
||||
it('skips extensions without commands', () => {
|
||||
const store = useCommandStore()
|
||||
store.loadExtensionCommands({ name: 'no-commands' })
|
||||
expect(store.commands).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ComfyCommandImpl', () => {
|
||||
it('resolves label as string', () => {
|
||||
const store = useCommandStore()
|
||||
store.registerCommand({
|
||||
id: 'label.str',
|
||||
function: vi.fn(),
|
||||
label: 'Static'
|
||||
})
|
||||
expect(store.getCommand('label.str')?.label).toBe('Static')
|
||||
})
|
||||
|
||||
it('resolves label as function', () => {
|
||||
const store = useCommandStore()
|
||||
store.registerCommand({
|
||||
id: 'label.fn',
|
||||
function: vi.fn(),
|
||||
label: () => 'Dynamic'
|
||||
})
|
||||
expect(store.getCommand('label.fn')?.label).toBe('Dynamic')
|
||||
})
|
||||
|
||||
it('resolves icon as function', () => {
|
||||
const store = useCommandStore()
|
||||
store.registerCommand({
|
||||
id: 'icon.fn',
|
||||
function: vi.fn(),
|
||||
icon: () => 'pi pi-check'
|
||||
})
|
||||
expect(store.getCommand('icon.fn')?.icon).toBe('pi pi-check')
|
||||
})
|
||||
|
||||
it('uses label as default menubarLabel', () => {
|
||||
const store = useCommandStore()
|
||||
store.registerCommand({
|
||||
id: 'mbl.default',
|
||||
function: vi.fn(),
|
||||
label: 'My Label'
|
||||
})
|
||||
expect(store.getCommand('mbl.default')?.menubarLabel).toBe('My Label')
|
||||
})
|
||||
|
||||
it('uses explicit menubarLabel over label', () => {
|
||||
const store = useCommandStore()
|
||||
store.registerCommand({
|
||||
id: 'mbl.explicit',
|
||||
function: vi.fn(),
|
||||
label: 'Label',
|
||||
menubarLabel: 'Menu Label'
|
||||
})
|
||||
expect(store.getCommand('mbl.explicit')?.menubarLabel).toBe('Menu Label')
|
||||
})
|
||||
})
|
||||
})
|
||||
155
src/stores/extensionStore.test.ts
Normal file
155
src/stores/extensionStore.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useExtensionStore } from '@/stores/extensionStore'
|
||||
|
||||
describe('extensionStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
describe('registerExtension', () => {
|
||||
it('registers an extension by name', () => {
|
||||
const store = useExtensionStore()
|
||||
store.registerExtension({ name: 'test.ext' })
|
||||
expect(store.isExtensionInstalled('test.ext')).toBe(true)
|
||||
})
|
||||
|
||||
it('throws for extension without name', () => {
|
||||
const store = useExtensionStore()
|
||||
expect(() => store.registerExtension({ name: '' })).toThrow(
|
||||
"Extensions must have a 'name' property."
|
||||
)
|
||||
})
|
||||
|
||||
it('throws for duplicate registration', () => {
|
||||
const store = useExtensionStore()
|
||||
store.registerExtension({ name: 'dup' })
|
||||
expect(() => store.registerExtension({ name: 'dup' })).toThrow(
|
||||
"Extension named 'dup' already registered."
|
||||
)
|
||||
})
|
||||
|
||||
it('warns when registering a disabled extension', () => {
|
||||
const store = useExtensionStore()
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
store.loadDisabledExtensionNames(['disabled.ext'])
|
||||
store.registerExtension({ name: 'disabled.ext' })
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'Extension disabled.ext is disabled.'
|
||||
)
|
||||
} finally {
|
||||
warnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('extensions getter', () => {
|
||||
it('returns all registered extensions', () => {
|
||||
const store = useExtensionStore()
|
||||
store.registerExtension({ name: 'ext.a' })
|
||||
store.registerExtension({ name: 'ext.b' })
|
||||
expect(store.extensions).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isExtensionInstalled', () => {
|
||||
it('returns false for uninstalled extension', () => {
|
||||
const store = useExtensionStore()
|
||||
expect(store.isExtensionInstalled('missing')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isExtensionEnabled / loadDisabledExtensionNames', () => {
|
||||
it('all extensions are enabled by default', () => {
|
||||
const store = useExtensionStore()
|
||||
store.registerExtension({ name: 'fresh' })
|
||||
expect(store.isExtensionEnabled('fresh')).toBe(true)
|
||||
})
|
||||
|
||||
it('disables extensions from provided list', () => {
|
||||
const store = useExtensionStore()
|
||||
store.loadDisabledExtensionNames(['off.ext'])
|
||||
store.registerExtension({ name: 'off.ext' })
|
||||
expect(store.isExtensionEnabled('off.ext')).toBe(false)
|
||||
})
|
||||
|
||||
it('always disables hardcoded extensions', () => {
|
||||
const store = useExtensionStore()
|
||||
store.loadDisabledExtensionNames([])
|
||||
expect(store.isExtensionEnabled('pysssss.Locking')).toBe(false)
|
||||
expect(store.isExtensionEnabled('pysssss.SnapToGrid')).toBe(false)
|
||||
expect(store.isExtensionEnabled('pysssss.FaviconStatus')).toBe(false)
|
||||
expect(store.isExtensionEnabled('KJNodes.browserstatus')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('enabledExtensions', () => {
|
||||
it('filters out disabled extensions', () => {
|
||||
const store = useExtensionStore()
|
||||
store.loadDisabledExtensionNames(['ext.off'])
|
||||
store.registerExtension({ name: 'ext.on' })
|
||||
store.registerExtension({ name: 'ext.off' })
|
||||
|
||||
const enabled = store.enabledExtensions
|
||||
expect(enabled).toHaveLength(1)
|
||||
expect(enabled[0].name).toBe('ext.on')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isExtensionReadOnly', () => {
|
||||
it('returns true for always-disabled extensions', () => {
|
||||
const store = useExtensionStore()
|
||||
expect(store.isExtensionReadOnly('pysssss.Locking')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for normal extensions', () => {
|
||||
const store = useExtensionStore()
|
||||
expect(store.isExtensionReadOnly('some.custom.ext')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('inactiveDisabledExtensionNames', () => {
|
||||
it('returns disabled names not currently installed', () => {
|
||||
const store = useExtensionStore()
|
||||
store.loadDisabledExtensionNames(['ghost.ext', 'installed.ext'])
|
||||
store.registerExtension({ name: 'installed.ext' })
|
||||
|
||||
expect(store.inactiveDisabledExtensionNames).toContain('ghost.ext')
|
||||
expect(store.inactiveDisabledExtensionNames).not.toContain(
|
||||
'installed.ext'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('core extensions', () => {
|
||||
it('captures current extensions as core', () => {
|
||||
const store = useExtensionStore()
|
||||
store.registerExtension({ name: 'core.a' })
|
||||
store.registerExtension({ name: 'core.b' })
|
||||
store.captureCoreExtensions()
|
||||
|
||||
expect(store.isCoreExtension('core.a')).toBe(true)
|
||||
expect(store.isCoreExtension('core.b')).toBe(true)
|
||||
})
|
||||
|
||||
it('identifies third-party extensions registered after capture', () => {
|
||||
const store = useExtensionStore()
|
||||
store.registerExtension({ name: 'core.x' })
|
||||
store.captureCoreExtensions()
|
||||
|
||||
expect(store.hasThirdPartyExtensions).toBe(false)
|
||||
|
||||
store.registerExtension({ name: 'third.party' })
|
||||
expect(store.hasThirdPartyExtensions).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for isCoreExtension before capture', () => {
|
||||
const store = useExtensionStore()
|
||||
store.registerExtension({ name: 'ext.pre' })
|
||||
expect(store.isCoreExtension('ext.pre')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
101
src/stores/widgetStore.test.ts
Normal file
101
src/stores/widgetStore.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { InputSpec as InputSpecV1 } from '@/schemas/nodeDefSchema'
|
||||
import { useWidgetStore } from '@/stores/widgetStore'
|
||||
|
||||
/** Cast shorthand — the mock bypasses Zod validation, so we only need the shape `inputIsWidget` reads. */
|
||||
const v1 = (spec: unknown) => spec as InputSpecV1
|
||||
const v2 = (spec: unknown) => spec as InputSpecV2
|
||||
|
||||
vi.mock('@/scripts/widgets', () => ({
|
||||
ComfyWidgets: {
|
||||
INT: vi.fn(),
|
||||
FLOAT: vi.fn(),
|
||||
STRING: vi.fn(),
|
||||
BOOLEAN: vi.fn(),
|
||||
COMBO: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/schemas/nodeDefSchema', () => ({
|
||||
getInputSpecType: (spec: unknown[]) => spec[0]
|
||||
}))
|
||||
|
||||
describe('widgetStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
describe('widgets getter', () => {
|
||||
it('includes core widgets', () => {
|
||||
const store = useWidgetStore()
|
||||
expect(store.widgets.has('INT')).toBe(true)
|
||||
expect(store.widgets.has('FLOAT')).toBe(true)
|
||||
expect(store.widgets.has('STRING')).toBe(true)
|
||||
})
|
||||
|
||||
it('includes custom widgets after registration', () => {
|
||||
const store = useWidgetStore()
|
||||
const customFn = vi.fn()
|
||||
store.registerCustomWidgets({ CUSTOM_TYPE: customFn })
|
||||
expect(store.widgets.has('CUSTOM_TYPE')).toBe(true)
|
||||
})
|
||||
|
||||
it('core widgets take precedence over custom widgets with same key', () => {
|
||||
const store = useWidgetStore()
|
||||
const override = vi.fn()
|
||||
store.registerCustomWidgets({ INT: override })
|
||||
// Core widgets are spread last, so they win
|
||||
expect(store.widgets.get('INT')).not.toBe(override)
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerCustomWidgets', () => {
|
||||
it('registers multiple custom widgets', () => {
|
||||
const store = useWidgetStore()
|
||||
store.registerCustomWidgets({
|
||||
TYPE_A: vi.fn(),
|
||||
TYPE_B: vi.fn()
|
||||
})
|
||||
expect(store.widgets.has('TYPE_A')).toBe(true)
|
||||
expect(store.widgets.has('TYPE_B')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('inputIsWidget', () => {
|
||||
it('returns true for known widget type (v1 spec)', () => {
|
||||
const store = useWidgetStore()
|
||||
expect(store.inputIsWidget(v1(['INT', {}]))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for unknown type (v1 spec)', () => {
|
||||
const store = useWidgetStore()
|
||||
expect(store.inputIsWidget(v1(['UNKNOWN_TYPE', {}]))).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for v2 spec with known type', () => {
|
||||
const store = useWidgetStore()
|
||||
expect(
|
||||
store.inputIsWidget(v2({ type: 'STRING', name: 'test_input' }))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for v2 spec with unknown type', () => {
|
||||
const store = useWidgetStore()
|
||||
expect(
|
||||
store.inputIsWidget(v2({ type: 'LATENT', name: 'test_input' }))
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for custom registered type', () => {
|
||||
const store = useWidgetStore()
|
||||
store.registerCustomWidgets({ MY_WIDGET: vi.fn() })
|
||||
expect(
|
||||
store.inputIsWidget(v2({ type: 'MY_WIDGET', name: 'test_input' }))
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user