Compare commits

..

3 Commits

Author SHA1 Message Date
jaeone94
9491e68511 test: align swap node unit expectations 2026-06-11 03:59:53 +09:00
jaeone94
0f67162521 test: add swap node error e2e coverage 2026-06-11 03:35:11 +09:00
jaeone94
a2cdfb6716 feat: simplify swap node error presentation 2026-06-11 03:05:39 +09:00
47 changed files with 581 additions and 790 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,23 +1,23 @@
{
"name": "Comfy",
"short_name": "Comfy",
"id": "/",
"start_url": "/",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
"purpose": "any"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
"purpose": "any"
}
],
"theme_color": "#211927",
"background_color": "#211927",
"display": "standalone",
"id": "/",
"start_url": "/"
"display": "standalone"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -8,7 +8,6 @@ import {
useDownloadUrl
} from '../../../composables/useDownloadUrl'
import { t } from '../../../i18n/translations'
import { captureDownloadClick } from '../../../scripts/posthog'
import BrandButton from '../../common/BrandButton.vue'
const { locale = 'en', class: customClass = '' } = defineProps<{
@@ -70,7 +69,6 @@ const buttons = computed<ButtonSpec[]>(() => {
size="lg"
:class="customClass"
:aria-label="btn.ariaLabel"
@click="captureDownloadClick(btn.key)"
>
<span class="inline-flex items-center gap-2">
<img

View File

@@ -73,7 +73,7 @@ const websiteJsonLd = {
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" sizes="48x48" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#211927" />

View File

@@ -53,28 +53,3 @@ describe('initPostHog', () => {
expect(result.$set_once).toHaveProperty('plan', 'free')
})
})
describe('captureDownloadClick', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('captures the download event with the platform', async () => {
const { initPostHog, captureDownloadClick } = await import('./posthog')
initPostHog()
captureDownloadClick('mac')
expect(hoisted.mockCapture).toHaveBeenCalledWith(
'website:download_button_clicked',
{ platform: 'mac' }
)
})
it('does not capture before PostHog is initialized', async () => {
const { captureDownloadClick } = await import('./posthog')
captureDownloadClick('windows')
expect(hoisted.mockCapture).not.toHaveBeenCalled()
})
})

View File

@@ -38,12 +38,3 @@ export function capturePageview() {
console.error('PostHog pageview capture failed', error)
}
}
export function captureDownloadClick(platform: string) {
if (!initialized) return
try {
posthog.capture('website:download_button_clicked', { platform })
} catch (error) {
console.error('PostHog download click capture failed', error)
}
}

View File

@@ -0,0 +1,61 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "E2E_OldSampler",
"pos": [100, 100],
"size": [400, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [],
"slot_index": 0
}
],
"properties": { "Node name for S&R": "E2E_OldSampler" },
"widgets_values": [42, 20, 7, "euler", "normal"]
},
{
"id": 2,
"type": "E2E_OldSampler",
"pos": [520, 100],
"size": [400, 262],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [],
"slot_index": 0
}
],
"properties": { "Node name for S&R": "E2E_OldSampler" },
"widgets_values": [43, 20, 7, "euler", "normal"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": { "ds": { "scale": 1, "offset": [0, 0] } },
"version": 0.4
}

View File

@@ -70,6 +70,7 @@ export const TestIds = {
missingModelImportUnsupported: 'missing-model-import-unsupported',
missingMediaGroup: 'error-group-missing-media',
swapNodesGroup: 'error-group-swap-nodes',
swapNodeGroupCount: 'swap-node-group-count',
missingMediaRow: 'missing-media-row',
missingMediaLocateButton: 'missing-media-locate-button',
publishTabPanel: 'publish-tab-panel',

View File

@@ -48,6 +48,36 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
).toBeVisible()
})
test('Shows direct row label and locate action for a single replacement group', async ({
comfyPage
}) => {
const swapGroup = getSwapNodesGroup(comfyPage.page)
const rowLabel = swapGroup.getByRole('button', {
name: 'E2E_OldSampler',
exact: true
})
await expect(rowLabel).toBeVisible()
await expect(
swapGroup.getByRole('button', {
name: 'Locate node on canvas',
exact: true
})
).toBeVisible()
await expect(
swapGroup.getByTestId(TestIds.dialogs.swapNodeGroupCount)
).toHaveCount(0)
await comfyPage.canvasOps.pan({ x: -800, y: -800 })
const offsetBeforeLocate = await comfyPage.canvasOps.getOffset()
await rowLabel.click()
await expect
.poll(() => comfyPage.canvasOps.getOffset())
.not.toEqual(offsetBeforeLocate)
})
test('Replace Node replaces a single group in-place', async ({
comfyPage
}) => {
@@ -116,6 +146,55 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
})
})
test.describe('Same-type replacement group', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.VueNodes.Enabled',
mode.vueNodesEnabled
)
await setupNodeReplacement(comfyPage, mockNodeReplacementsSingle)
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/node_replacement_same_type'
)
})
test('Groups same-type replacement rows behind the title disclosure', async ({
comfyPage
}) => {
const swapGroup = getSwapNodesGroup(comfyPage.page)
const countBadge = swapGroup.getByTestId(
TestIds.dialogs.swapNodeGroupCount
)
const childRows = swapGroup.getByRole('listitem')
const expandButton = swapGroup.getByRole('button', {
name: 'Expand E2E_OldSampler',
exact: true
})
await expect(expandButton).toBeVisible()
await expect(countBadge).toHaveText('2')
await expect(childRows).toHaveCount(0)
await expandButton.click()
await expect(childRows).toHaveCount(2)
await expect(
swapGroup.getByRole('button', {
name: 'E2E_OldSampler',
exact: true
})
).toHaveCount(2)
await swapGroup
.getByRole('button', {
name: 'Collapse E2E_OldSampler',
exact: true
})
.click()
await expect(childRows).toHaveCount(0)
})
})
test.describe('Multi-type replacement', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(

View File

@@ -111,7 +111,6 @@ describe('formatUtil', () => {
expect(getMediaTypeFromFilename('scene.fbx')).toBe('3D')
expect(getMediaTypeFromFilename('asset.gltf')).toBe('3D')
expect(getMediaTypeFromFilename('binary.glb')).toBe('3D')
expect(getMediaTypeFromFilename('print.stl')).toBe('3D')
expect(getMediaTypeFromFilename('apple.usdz')).toBe('3D')
expect(getMediaTypeFromFilename('scan.ply')).toBe('3D')
})

View File

@@ -591,15 +591,7 @@ const IMAGE_EXTENSIONS = [
] as const
const VIDEO_EXTENSIONS = ['mp4', 'm4v', 'webm', 'mov', 'avi', 'mkv'] as const
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const
const THREE_D_EXTENSIONS = [
'obj',
'fbx',
'gltf',
'glb',
'stl',
'usdz',
'ply'
] as const
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb', 'usdz', 'ply'] as const
const TEXT_EXTENSIONS = [
'txt',
'md',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -33,6 +33,7 @@ const {
items,
gridStyle,
bufferRows = 1,
scrollThrottle = 64,
resizeDebounce = 64,
defaultItemHeight = 200,
defaultItemWidth = 200,
@@ -41,6 +42,7 @@ const {
items: (T & { key: string })[]
gridStyle: CSSProperties
bufferRows?: number
scrollThrottle?: number
resizeDebounce?: number
defaultItemHeight?: number
defaultItemWidth?: number
@@ -59,6 +61,7 @@ const itemWidth = ref(defaultItemWidth)
const container = ref<HTMLElement | null>(null)
const { width, height } = useElementSize(container)
const { y: scrollY } = useScroll(container, {
throttle: scrollThrottle,
eventListenerOptions: { passive: true }
})

View File

@@ -23,8 +23,6 @@
:can-use-gizmo="canUseGizmo"
:can-use-lighting="canUseLighting"
:can-export="canExport"
:can-use-hdri="canUseHdri"
:can-use-background-image="canUseBackgroundImage"
:material-modes="materialModes"
:has-skeleton="hasSkeleton"
@update-background-image="handleBackgroundImageUpdate"
@@ -88,7 +86,7 @@
/>
<RecordingControls
v-if="canUseRecording && !isPreview"
v-if="!isPreview"
v-model:is-recording="isRecording"
v-model:has-recording="hasRecording"
v-model:recording-duration="recordingDuration"
@@ -119,18 +117,9 @@ import { resolveNode } from '@/utils/litegraphUtil'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const {
widget,
nodeId,
canUseRecording = true,
canUseHdri = true,
canUseBackgroundImage = true
} = defineProps<{
const props = defineProps<{
widget: ComponentWidget<string[]> | SimplifiedWidget
nodeId?: NodeId
canUseRecording?: boolean
canUseHdri?: boolean
canUseBackgroundImage?: boolean
}>()
function isComponentWidget(
@@ -141,11 +130,11 @@ function isComponentWidget(
const node = ref<LGraphNode | null>(null)
if (isComponentWidget(widget)) {
node.value = widget.node
} else if (nodeId) {
if (isComponentWidget(props.widget)) {
node.value = props.widget.node
} else if (props.nodeId) {
onMounted(() => {
node.value = resolveNode(nodeId) ?? null
node.value = resolveNode(props.nodeId!) ?? null
})
}

View File

@@ -1,47 +0,0 @@
import { render } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent, h, ref } from 'vue'
const lastProps = ref<Record<string, unknown> | null>(null)
vi.mock('@/components/load3d/Load3D.vue', () => ({
default: defineComponent({
name: 'Load3D',
props: {
widget: { type: null, required: false, default: undefined },
nodeId: { type: null, required: false, default: undefined },
canUseRecording: { type: Boolean, default: true },
canUseHdri: { type: Boolean, default: true },
canUseBackgroundImage: { type: Boolean, default: true }
},
setup(props: Record<string, unknown>) {
lastProps.value = { ...props }
return () => h('div', { 'data-testid': 'load3d-stub' })
}
})
}))
import Load3DAdvanced from '@/components/load3d/Load3DAdvanced.vue'
describe('Load3DAdvanced', () => {
it('renders the inner Load3D with all expressive features disabled', () => {
const MOCK_NODE = { id: 'node', type: 'Load3DAdvanced' }
render(Load3DAdvanced, {
props: {
widget: { node: MOCK_NODE } as never
}
})
expect(lastProps.value).toMatchObject({
canUseRecording: false,
canUseHdri: false,
canUseBackgroundImage: false
})
})
it('forwards widget and nodeId to the inner Load3D', () => {
const widget = { node: { id: 'a', type: 'Load3DAdvanced' } }
render(Load3DAdvanced, { props: { widget: widget as never, nodeId: 'a' } })
expect(lastProps.value?.widget).toEqual(widget)
expect(lastProps.value?.nodeId).toBe('a')
})
})

View File

@@ -1,21 +0,0 @@
<template>
<Load3D
:widget="widget"
:node-id="nodeId"
:can-use-recording="false"
:can-use-hdri="false"
:can-use-background-image="false"
/>
</template>
<script setup lang="ts">
import Load3D from '@/components/load3d/Load3D.vue'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
defineProps<{
widget: ComponentWidget<string[]> | SimplifiedWidget
nodeId?: NodeId
}>()
</script>

View File

@@ -52,7 +52,6 @@
v-model:background-image="sceneConfig!.backgroundImage"
v-model:background-render-mode="sceneConfig!.backgroundRenderMode"
v-model:fov="cameraConfig!.fov"
:show-background-image="canUseBackgroundImage"
:hdri-active="
!!lightConfig?.hdri?.hdriPath && !!lightConfig?.hdri?.enabled
"
@@ -82,7 +81,6 @@
/>
<HDRIControls
v-if="canUseHdri"
v-model:hdri-config="lightConfig!.hdri"
:has-background-image="!!sceneConfig?.backgroundImage"
@update-hdri-file="handleHDRIFileUpdate"
@@ -131,16 +129,12 @@ const {
canUseGizmo = true,
canUseLighting = true,
canExport = true,
canUseHdri = true,
canUseBackgroundImage = true,
materialModes = ['original', 'normal', 'wireframe'],
hasSkeleton = false
} = defineProps<{
canUseGizmo?: boolean
canUseLighting?: boolean
canExport?: boolean
canUseHdri?: boolean
canUseBackgroundImage?: boolean
materialModes?: readonly MaterialMode[]
hasSkeleton?: boolean
}>()

View File

@@ -37,7 +37,7 @@
</Button>
</div>
<div v-if="showBackgroundImage && !hasBackgroundImage">
<div v-if="!hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.uploadBackgroundImage'),
@@ -61,7 +61,7 @@
</div>
</template>
<div v-if="showBackgroundImage && hasBackgroundImage">
<div v-if="hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.panoramaMode'),
@@ -83,16 +83,12 @@
</div>
<PopupSlider
v-if="
showBackgroundImage &&
hasBackgroundImage &&
backgroundRenderMode === 'panorama'
"
v-if="hasBackgroundImage && backgroundRenderMode === 'panorama'"
v-model="fov"
:tooltip-text="$t('load3d.fov')"
/>
<div v-if="showBackgroundImage && hasBackgroundImage">
<div v-if="hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.removeBackgroundImage'),
@@ -118,9 +114,8 @@ import Button from '@/components/ui/button/Button.vue'
import type { BackgroundRenderModeType } from '@/extensions/core/load3d/interfaces'
import { cn } from '@comfyorg/tailwind-utils'
const { hdriActive = false, showBackgroundImage = true } = defineProps<{
const { hdriActive = false } = defineProps<{
hdriActive?: boolean
showBackgroundImage?: boolean
}>()
const emit = defineEmits<{

View File

@@ -530,7 +530,9 @@ describe('TabErrors.vue', () => {
expect(
screen.getByText('Some nodes can be replaced with alternatives')
).toBeInTheDocument()
expect(screen.getByText('OldSampler (1)')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'OldSampler' })
).toBeInTheDocument()
expect(screen.getByText('KSampler')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /Replace Node/ })

View File

@@ -157,7 +157,6 @@
<SwapNodesCard
v-if="group.type === 'swap_nodes'"
:swap-node-groups="swapNodeGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-node="handleLocateMissingNode"
@replace="handleReplaceGroup"
/>

View File

@@ -22,7 +22,6 @@ import {
LOAD3D_NONE_MODEL,
SUPPORTED_EXTENSIONS_ACCEPT
} from '@/extensions/core/load3d/constants'
import { snapshotLoad3dState } from '@/extensions/core/load3d/load3dSerialize'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
@@ -414,10 +413,16 @@ useExtensionService().registerExtension({
if (cached) return cached
}
const { camera_info, model_3d_info } = snapshotLoad3dState(
node,
currentLoad3d
)
const cameraConfig: CameraConfig = (node.properties[
'Camera Config'
] as CameraConfig | undefined) || {
cameraType: currentLoad3d.getCurrentCameraType(),
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
}
cameraConfig.state = currentLoad3d.getCameraState()
node.properties['Camera Config'] = cameraConfig
currentLoad3d.stopRecording()
const {
scene: imageData,
@@ -436,11 +441,16 @@ useExtensionService().registerExtension({
currentLoad3d.handleResize()
const modelInfo = currentLoad3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
const returnVal: Load3dCachedOutput = {
image: `threed/${data.name} [temp]`,
mask: `threed/${dataMask.name} [temp]`,
normal: `threed/${dataNormal.name} [temp]`,
camera_info,
camera_info:
(node.properties['Camera Config'] as CameraConfig | undefined)
?.state || null,
recording: '',
model_3d_info
}

View File

@@ -23,7 +23,6 @@ const mtlLoaderStub = {
const objLoaderStub = {
setWorkerUrl: vi.fn(),
setMaterials: vi.fn(),
setBaseObject3d: vi.fn(),
loadAsync: vi.fn<(url: string) => Promise<THREE.Object3D>>()
}
@@ -59,7 +58,6 @@ vi.mock('wwobjloader2', () => ({
OBJLoader2Parallel: class {
setWorkerUrl = objLoaderStub.setWorkerUrl
setMaterials = objLoaderStub.setMaterials
setBaseObject3d = objLoaderStub.setBaseObject3d
loadAsync = objLoaderStub.loadAsync
},
MtlObjBridge: {
@@ -249,24 +247,6 @@ describe('MeshModelAdapter', () => {
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
})
it('resets baseObject3d on every load so meshes do not accumulate across calls', async () => {
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
const adapter = new MeshModelAdapter()
const ctx = makeContext('wireframe')
await adapter.load(ctx, '/api/view/', 'first.obj')
await adapter.load(ctx, '/api/view/', 'second.obj')
expect(objLoaderStub.setBaseObject3d).toHaveBeenCalledTimes(2)
const bases = objLoaderStub.setBaseObject3d.mock.calls.map(
([base]) => base
)
expect(bases[0]).toBeInstanceOf(THREE.Object3D)
expect(bases[1]).toBeInstanceOf(THREE.Object3D)
// Each call should hand the loader a fresh container, not the same one.
expect(bases[0]).not.toBe(bases[1])
})
})
describe('GLTF loader path', () => {

View File

@@ -102,8 +102,6 @@ export class MeshModelAdapter implements ModelAdapter {
path: string,
filename: string
): Promise<THREE.Object3D> {
this.objLoader.setBaseObject3d(new THREE.Object3D())
if (ctx.materialMode === 'original') {
try {
this.mtlLoader.setPath(path)

View File

@@ -1,87 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type Load3d from '@/extensions/core/load3d/Load3d'
import { snapshotLoad3dState } from '@/extensions/core/load3d/load3dSerialize'
import type { CameraState } from '@/extensions/core/load3d/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
function makeNode(props: Record<string, unknown> = {}): LGraphNode {
return { properties: { ...props } } as unknown as LGraphNode
}
const baseCameraState: CameraState = {
position: { x: 1, y: 2, z: 3 },
target: { x: 0, y: 0, z: 0 },
zoom: 1,
cameraType: 'perspective'
} as unknown as CameraState
function makeLoad3d({
cameraType = 'perspective',
fov = 35,
modelInfo = { transform: { position: [0, 0, 0] } } as unknown
}: {
cameraType?: string
fov?: number
modelInfo?: unknown
} = {}) {
return {
getCurrentCameraType: vi.fn(() => cameraType),
cameraManager: { perspectiveCamera: { fov } },
getCameraState: vi.fn(() => baseCameraState),
stopRecording: vi.fn(),
getModelInfo: vi.fn(() => modelInfo)
} as unknown as Load3d
}
describe('snapshotLoad3dState', () => {
it('returns only camera_info and model_3d_info', () => {
const result = snapshotLoad3dState(makeNode(), makeLoad3d())
expect(Object.keys(result).sort()).toEqual(['camera_info', 'model_3d_info'])
})
it('writes the camera state into properties["Camera Config"]', () => {
const node = makeNode()
snapshotLoad3dState(node, makeLoad3d({ fov: 42 }))
const cfg = node.properties['Camera Config'] as Record<string, unknown>
expect(cfg).toMatchObject({
cameraType: 'perspective',
fov: 42,
state: baseCameraState
})
})
it('preserves an existing Camera Config object instead of replacing it', () => {
const existing = { cameraType: 'orthographic', fov: 99 }
const node = makeNode({ 'Camera Config': existing })
snapshotLoad3dState(node, makeLoad3d())
// Same object reference (mutated in place), with state attached.
expect(node.properties['Camera Config']).toBe(existing)
expect(
(node.properties['Camera Config'] as Record<string, unknown>).state
).toBe(baseCameraState)
})
it('stops in-progress recording as a side effect', () => {
const load3d = makeLoad3d()
snapshotLoad3dState(makeNode(), load3d)
expect(load3d.stopRecording).toHaveBeenCalledOnce()
})
it('returns model_3d_info as a single-element list when a model is loaded', () => {
const info = { transform: { position: [1, 2, 3] } }
const result = snapshotLoad3dState(
makeNode(),
makeLoad3d({ modelInfo: info })
)
expect(result.model_3d_info).toEqual([info])
})
it('returns an empty model_3d_info list when no model is loaded', () => {
const result = snapshotLoad3dState(
makeNode(),
makeLoad3d({ modelInfo: null })
)
expect(result.model_3d_info).toEqual([])
})
})

View File

@@ -1,36 +0,0 @@
import type Load3d from '@/extensions/core/load3d/Load3d'
import type {
CameraConfig,
CameraState,
Model3DInfo
} from '@/extensions/core/load3d/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
export type Load3dSerializedBase = {
camera_info: CameraState | null
model_3d_info: Model3DInfo
}
export function snapshotLoad3dState(
node: LGraphNode,
load3d: Load3d
): Load3dSerializedBase {
const cameraConfig: CameraConfig = (node.properties['Camera Config'] as
| CameraConfig
| undefined) || {
cameraType: load3d.getCurrentCameraType(),
fov: load3d.cameraManager.perspectiveCamera.fov
}
cameraConfig.state = load3d.getCameraState()
node.properties['Camera Config'] = cameraConfig
load3d.stopRecording()
const modelInfo = load3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
return {
camera_info: cameraConfig.state ?? null,
model_3d_info
}
}

View File

@@ -9,12 +9,7 @@ const LOAD3D_PREVIEW_NODES = new Set([
'PreviewPointCloud'
])
const LOAD3D_ALL_NODES = new Set([
...LOAD3D_PREVIEW_NODES,
'Load3D',
'Load3DAdvanced',
'SaveGLB'
])
const LOAD3D_ALL_NODES = new Set([...LOAD3D_PREVIEW_NODES, 'Load3D', 'SaveGLB'])
export const isLoad3dPreviewNode = (nodeType: string): boolean =>
LOAD3D_PREVIEW_NODES.has(nodeType)

View File

@@ -1,103 +0,0 @@
import { nextTick } from 'vue'
import Load3DAdvanced from '@/components/load3d/Load3DAdvanced.vue'
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import type { CameraConfig } from '@/extensions/core/load3d/interfaces'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import { snapshotLoad3dState } from '@/extensions/core/load3d/load3dSerialize'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
import { useLoad3dService } from '@/services/load3dService'
const inputSpecLoad3DAdvanced: CustomInputSpec = {
name: 'viewport_state',
type: 'LOAD_3D_ADVANCED',
isPreview: false
}
useExtensionService().registerExtension({
name: 'Comfy.Load3DAdvanced',
beforeRegisterNodeDef(_nodeType, nodeData) {
if (nodeData.name !== 'Load3DAdvanced') return
if (!nodeData.input?.required) return
nodeData.input.required.viewport_state = ['LOAD_3D_ADVANCED', {}]
},
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
if (node.constructor.comfyClass !== 'Load3DAdvanced') return []
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
return createExportMenuItems(load3d)
},
getCustomWidgets() {
return {
LOAD_3D_ADVANCED(node) {
const widget = new ComponentWidgetImpl({
node,
name: 'viewport_state',
component: Load3DAdvanced,
inputSpec: inputSpecLoad3DAdvanced,
options: {}
})
widget.type = 'load3DAdvanced'
addWidget(node, widget)
return { widget }
}
}
},
async nodeCreated(node: LGraphNode) {
if (node.constructor.comfyClass !== 'Load3DAdvanced') return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 300), Math.max(oldHeight, 600)])
await nextTick()
useLoad3d(node).onLoad3dReady((load3d) => {
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
const width = node.widgets?.find((w) => w.name === 'width')
const height = node.widgets?.find((w) => w.name === 'height')
if (!modelWidget || !width || !height) return
const cameraConfig = node.properties['Camera Config'] as
| CameraConfig
| undefined
const cameraState = cameraConfig?.state
const config = new Load3DConfiguration(load3d, node.properties)
config.configure({
loadFolder: 'input',
modelWidget,
cameraState,
width,
height
})
})
useLoad3d(node).waitForLoad3d(() => {
const sceneWidget = node.widgets?.find((w) => w.name === 'viewport_state')
if (!sceneWidget) return
sceneWidget.serializeValue = async () => {
const currentLoad3d = nodeToLoad3dMap.get(node)
if (!currentLoad3d) {
console.error('No load3d instance found for node')
return null
}
return snapshotLoad3dState(node, currentLoad3d)
}
})
}
})

View File

@@ -37,7 +37,6 @@ async function loadLoad3dExtensions(): Promise<ComfyExtension[]> {
// Import extensions - they self-register via useExtensionService()
await Promise.all([
import('./load3d'),
import('./load3dAdvanced'),
import('./load3dPreviewExtensions'),
import('./saveMesh')
])
@@ -67,12 +66,6 @@ useExtensionService().registerExtension({
modelFile[1].mesh_upload = true
modelFile[1].upload_subfolder = '3d'
}
} else if (nodeData.name === 'Load3DAdvanced') {
const modelFile = nodeData.input?.required?.model_file
if (modelFile?.[1]) {
modelFile[1].mesh_upload = true
modelFile[1].upload_subfolder = ''
}
}
// Load the 3D extensions and replay their beforeRegisterNodeDef hooks,

View File

@@ -22,7 +22,7 @@ const zAsset = z.object({
})
const zAssetResponse = zListAssetsResponse
.pick({ total: true, has_more: true, next_cursor: true })
.pick({ total: true, has_more: true })
.extend({
assets: z.array(zAsset)
})

View File

@@ -53,7 +53,6 @@ const fetchApiMock = vi.mocked(api.fetchApi)
type AssetListResponseOptions = {
hasMore?: AssetResponse['has_more']
total?: AssetResponse['total']
nextCursor?: AssetResponse['next_cursor']
}
function buildResponse(
@@ -69,18 +68,9 @@ function buildResponse(
function buildAssetListResponse(
assets: AssetItem[],
{
hasMore = false,
total = assets.length,
nextCursor
}: AssetListResponseOptions = {}
{ hasMore = false, total = assets.length }: AssetListResponseOptions = {}
): Response {
return buildResponse({
assets,
total,
has_more: hasMore,
...(nextCursor === undefined ? {} : { next_cursor: nextCursor })
})
return buildResponse({ assets, total, has_more: hasMore })
}
function validAsset(overrides: Partial<AssetItem> = {}): AssetItem {
@@ -522,7 +512,7 @@ describe(assetService.getAllAssetsByTag, () => {
vi.clearAllMocks()
})
it('walks pages by keyset cursor with include_public=true', async () => {
it('paginates tagged asset requests with include_public=true', async () => {
fetchApiMock
.mockResolvedValueOnce(
buildAssetListResponse(
@@ -530,7 +520,7 @@ describe(assetService.getAllAssetsByTag, () => {
validAsset({ id: 'a', tags: ['input'] }),
validAsset({ id: 'b', tags: ['input'] })
],
{ hasMore: true, nextCursor: 'cursor-page-2' }
{ hasMore: true }
)
)
.mockResolvedValueOnce(
@@ -548,8 +538,6 @@ describe(assetService.getAllAssetsByTag, () => {
expect(firstParams.get('include_public')).toBe('true')
expect(firstParams.get('exclude_tags')).toBe(MISSING_TAG)
expect(firstParams.get('limit')).toBe('2')
// First page carries neither a cursor nor an offset.
expect(firstParams.has('after')).toBe(false)
expect(firstParams.has('offset')).toBe(false)
const secondUrl = fetchApiMock.mock.calls[1]?.[0] as string
@@ -557,9 +545,7 @@ describe(assetService.getAllAssetsByTag, () => {
expect(secondParams.get('include_public')).toBe('true')
expect(secondParams.get('exclude_tags')).toBe(MISSING_TAG)
expect(secondParams.get('limit')).toBe('2')
// Subsequent pages resume from the prior response's next_cursor, never offset.
expect(secondParams.get('after')).toBe('cursor-page-2')
expect(secondParams.has('offset')).toBe(false)
expect(secondParams.get('offset')).toBe('2')
})
it('honors has_more when walking tagged asset pages', async () => {
@@ -570,7 +556,7 @@ describe(assetService.getAllAssetsByTag, () => {
validAsset({ id: 'first', tags: ['input'] }),
validAsset({ id: 'second', tags: ['input'] })
],
{ hasMore: true, nextCursor: 'cursor-next' }
{ hasMore: true }
)
)
.mockResolvedValueOnce(
@@ -591,45 +577,7 @@ describe(assetService.getAllAssetsByTag, () => {
throw new Error('Expected a second asset request URL')
}
const secondParams = new URL(secondUrl, 'http://localhost').searchParams
expect(secondParams.get('after')).toBe('cursor-next')
})
it('stops walking when next_cursor is absent even if has_more is true', async () => {
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([validAsset({ id: 'only', tags: ['input'] })], {
hasMore: true
})
)
const assets = await assetService.getAllAssetsByTag('input', true, {
limit: 2
})
expect(assets.map((a) => a.id)).toEqual(['only'])
expect(fetchApiMock).toHaveBeenCalledOnce()
})
it('stops walking when the server returns a non-advancing cursor', async () => {
fetchApiMock
.mockResolvedValueOnce(
buildAssetListResponse([validAsset({ id: 'a', tags: ['input'] })], {
hasMore: true,
nextCursor: 'stuck'
})
)
.mockResolvedValueOnce(
buildAssetListResponse([validAsset({ id: 'b', tags: ['input'] })], {
hasMore: true,
nextCursor: 'stuck'
})
)
const assets = await assetService.getAllAssetsByTag('input', true, {
limit: 1
})
expect(assets.map((a) => a.id)).toEqual(['a', 'b'])
expect(fetchApiMock).toHaveBeenCalledTimes(2)
expect(secondParams.get('offset')).toBe('2')
})
it.for([
@@ -688,7 +636,7 @@ describe(assetService.getAllAssetsByTag, () => {
validAsset({ id: 'a', tags: ['input'] }),
validAsset({ id: 'b', tags: ['input'] })
],
{ hasMore: true, nextCursor: 'cursor-page-2' }
{ hasMore: true }
)
})

View File

@@ -31,11 +31,6 @@ export interface PaginationOptions {
}
interface AssetPaginationOptions extends PaginationOptions {
/**
* Opaque keyset cursor from a prior response's `next_cursor`. When set, the
* server resumes after that cursor and `offset` is ignored.
*/
after?: string
signal?: AbortSignal
}
@@ -43,7 +38,6 @@ interface AssetRequestOptions extends PaginationOptions {
includeTags: string[]
excludeTags?: string[]
includePublic?: boolean
after?: string
signal?: AbortSignal
}
@@ -292,7 +286,6 @@ function createAssetService() {
excludeTags = DEFAULT_EXCLUDED_ASSET_TAGS,
limit = DEFAULT_LIMIT,
offset,
after,
includePublic,
signal
} = options
@@ -306,11 +299,7 @@ function createAssetService() {
if (normalizedExcludeTags.length > 0) {
queryParams.set('exclude_tags', normalizedExcludeTags.join(','))
}
// `after` (keyset cursor) takes precedence over `offset`; the server ignores
// `offset` when a cursor is supplied, so we avoid sending a redundant param.
if (after) {
queryParams.set('after', after)
} else if (offset !== undefined && offset > 0) {
if (offset !== undefined && offset > 0) {
queryParams.set('offset', offset.toString())
}
if (includePublic !== undefined) {
@@ -492,17 +481,11 @@ function createAssetService() {
async function getAssetsByTag(
tag: string,
includePublic: boolean = true,
{
limit = DEFAULT_LIMIT,
offset = 0,
after,
signal
}: AssetPaginationOptions = {}
{ limit = DEFAULT_LIMIT, offset = 0, signal }: AssetPaginationOptions = {}
): Promise<AssetItem[]> {
const data = await getAssetsPageByTag(tag, includePublic, {
limit,
offset,
after,
signal
})
@@ -515,27 +498,17 @@ function createAssetService() {
async function getAssetsPageByTag(
tag: string,
includePublic: boolean = true,
{
limit = DEFAULT_LIMIT,
offset = 0,
after,
signal
}: AssetPaginationOptions = {}
{ limit = DEFAULT_LIMIT, offset = 0, signal }: AssetPaginationOptions = {}
): Promise<AssetResponse> {
return await handleAssetRequest(
{ includeTags: [tag], limit, offset, after, includePublic, signal },
{ includeTags: [tag], limit, offset, includePublic, signal },
`assets for tag ${tag}`
)
}
/**
* Gets every asset for a tag by walking paginated asset API responses.
*
* Uses keyset (cursor) pagination: each page is fetched with the prior
* response's `next_cursor`, which is stable under concurrent inserts/deletes
* and avoids the duplicate/skip drift that offset paging exhibits when the
* underlying set changes mid-walk. Falls back to terminating on `has_more`
* when the server omits `next_cursor`.
* Pagination follows the required server-provided `has_more` flag.
*
* @param tag - The tag to filter by (e.g., 'models', 'input')
* @param includePublic - Whether to include public assets (default: true)
@@ -547,21 +520,18 @@ function createAssetService() {
async function getAllAssetsByTag(
tag: string,
includePublic: boolean = true,
{
limit = DEFAULT_LIMIT,
signal
}: Pick<AssetPaginationOptions, 'limit' | 'signal'> = {}
{ limit = DEFAULT_LIMIT, signal }: AssetPaginationOptions = {}
): Promise<AssetItem[]> {
const assets: AssetItem[] = []
const pageSize = limit > 0 ? limit : DEFAULT_LIMIT
let after: string | undefined
let offset = 0
while (true) {
if (signal?.aborted) throw createAbortError()
const data = await getAssetsPageByTag(tag, includePublic, {
limit: pageSize,
after,
offset,
signal
})
const batch = data.assets
@@ -571,12 +541,11 @@ function createAssetService() {
assets.push(...batch)
// A server that returns a non-advancing cursor would loop forever.
if (!data.has_more || !data.next_cursor || data.next_cursor === after) {
if (!data.has_more) {
return assets
}
after = data.next_cursor
offset += batch.length
}
}

View File

@@ -1,6 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { render, screen } from '@testing-library/vue'
import { render, screen, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
@@ -15,8 +15,11 @@ const i18n = createI18n({
locale: 'en',
messages: {
en: {
g: {
nodesCount: '{count} node | {count} nodes'
},
rightSidePanel: {
locateNode: 'Locate Node',
locateNode: 'Locate node on canvas',
missingNodePacks: {
collapse: 'Collapse',
expand: 'Expand'
@@ -48,7 +51,6 @@ function makeGroup(overrides: Partial<SwapNodeGroup> = {}): SwapNodeGroup {
function renderRow(
props: Partial<{
group: SwapNodeGroup
showNodeIdBadge: boolean
'onLocate-node': (nodeId: string) => void
onReplace: (group: SwapNodeGroup) => void
}> = {}
@@ -56,7 +58,6 @@ function renderRow(
return render(SwapNodeGroupRow, {
props: {
group: makeGroup(),
showNodeIdBadge: false,
...props
},
global: {
@@ -75,13 +76,15 @@ describe('SwapNodeGroupRow', () => {
expect(container.textContent).toContain('OldNodeType')
})
it('renders node count in parentheses', () => {
const { container } = renderRow()
expect(container.textContent).toContain('(2)')
it('renders node count as a badge', () => {
renderRow()
const badge = screen.getByLabelText('2 nodes')
expect(badge).toBeInTheDocument()
expect(within(badge).getByText('2')).toBeInTheDocument()
})
it('renders node count of 5 for 5 nodeTypes', () => {
const { container } = renderRow({
renderRow({
group: makeGroup({
nodeTypes: Array.from({ length: 5 }, (_, i) => ({
type: 'OldNodeType',
@@ -90,7 +93,9 @@ describe('SwapNodeGroupRow', () => {
}))
})
})
expect(container.textContent).toContain('(5)')
const badge = screen.getByLabelText('5 nodes')
expect(badge).toBeInTheDocument()
expect(within(badge).getByText('5')).toBeInTheDocument()
})
it('renders the replacement target name', () => {
@@ -115,106 +120,147 @@ describe('SwapNodeGroupRow', () => {
describe('Expand / Collapse', () => {
it('starts collapsed — node list not visible', () => {
const { container } = renderRow({ showNodeIdBadge: true })
expect(container.textContent).not.toContain('#1')
renderRow()
expect(
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
})
it('expands when chevron is clicked', async () => {
it('expands when title is clicked', async () => {
const user = userEvent.setup()
const { container } = renderRow({ showNodeIdBadge: true })
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(container.textContent).toContain('#1')
expect(container.textContent).toContain('#2')
renderRow()
await user.click(
screen.getByRole('button', { name: 'Expand OldNodeType' })
)
expect(
screen.getAllByRole('button', { name: 'Locate node on canvas' })
).toHaveLength(2)
})
it('collapses when chevron is clicked again', async () => {
it('collapses when title is clicked again', async () => {
const user = userEvent.setup()
const { container } = renderRow({ showNodeIdBadge: true })
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(container.textContent).toContain('#1')
await user.click(screen.getByRole('button', { name: 'Collapse' }))
expect(container.textContent).not.toContain('#1')
renderRow()
await user.click(
screen.getByRole('button', { name: 'Expand OldNodeType' })
)
expect(
screen.getAllByRole('button', { name: 'Locate node on canvas' })
).toHaveLength(2)
await user.click(
screen.getByRole('button', { name: 'Collapse OldNodeType' })
)
expect(
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
})
it('updates the toggle control state when expanded', async () => {
const user = userEvent.setup()
renderRow()
expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(
screen.getByRole('button', { name: 'Collapse' })
).toBeInTheDocument()
const titleButton = screen.getByRole('button', {
name: 'Expand OldNodeType'
})
expect(titleButton).toHaveAttribute('aria-expanded', 'false')
await user.click(titleButton)
const collapseButton = screen.getByRole('button', {
name: 'Collapse OldNodeType'
})
expect(collapseButton).toHaveAttribute('aria-expanded', 'true')
})
})
describe('Node Type List (Expanded)', () => {
async function expand() {
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'Expand' }))
await user.click(screen.getByRole('button', { name: /^Expand / }))
}
it('renders all nodeTypes when expanded', async () => {
const { container } = renderRow({
renderRow({
group: makeGroup({
type: 'GroupedNodeType',
nodeTypes: [
{ type: 'OldNodeType', nodeId: '10', isReplaceable: true },
{ type: 'OldNodeType', nodeId: '20', isReplaceable: true },
{ type: 'OldNodeType', nodeId: '30', isReplaceable: true }
{ type: 'GroupedNodeType', nodeId: '10', isReplaceable: true },
{ type: 'GroupedNodeType', nodeId: '20', isReplaceable: true },
{ type: 'GroupedNodeType', nodeId: '30', isReplaceable: true }
]
}),
showNodeIdBadge: true
})
})
await expand()
expect(container.textContent).toContain('#10')
expect(container.textContent).toContain('#20')
expect(container.textContent).toContain('#30')
})
expect(screen.queryByRole('list')).not.toBeInTheDocument()
it('shows nodeId badge when showNodeIdBadge is true', async () => {
const { container } = renderRow({ showNodeIdBadge: true })
await expand()
expect(container.textContent).toContain('#1')
expect(container.textContent).toContain('#2')
})
it('hides nodeId badge when showNodeIdBadge is false', async () => {
const { container } = renderRow({ showNodeIdBadge: false })
await expand()
expect(container.textContent).not.toContain('#1')
expect(container.textContent).not.toContain('#2')
expect(
within(screen.getByRole('list')).getAllByRole('listitem')
).toHaveLength(3)
expect(
within(screen.getByRole('list')).getAllByText('GroupedNodeType')
).toHaveLength(3)
})
it('renders Locate button for each nodeType with nodeId', async () => {
renderRow({ showNodeIdBadge: true })
renderRow()
await expand()
expect(
screen.getAllByRole('button', { name: 'Locate Node' })
screen.getAllByRole('button', { name: 'Locate node on canvas' })
).toHaveLength(2)
})
it('does not render Locate button for nodeTypes without nodeId', async () => {
renderRow({
group: makeGroup({
// Intentionally omits nodeId to test graceful handling of incomplete node data
nodeTypes: fromAny<MissingNodeType[], unknown>([
{ type: 'NoIdNode', isReplaceable: true }
{ type: 'NoIdNode', isReplaceable: true },
{ type: 'OtherNoIdNode', isReplaceable: true }
])
})
})
await expand()
expect(
screen.queryByRole('button', { name: 'Locate Node' })
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
})
it('renders locate controls only for locatable nodeTypes', async () => {
renderRow({
group: makeGroup({
type: 'MixedNodeType',
nodeTypes: fromAny<MissingNodeType[], unknown>([
{ type: 'MixedNodeType', nodeId: '10', isReplaceable: true },
{ type: 'MixedNodeType', isReplaceable: true }
])
})
})
await expand()
expect(
within(screen.getByRole('list')).getAllByText('MixedNodeType')
).toHaveLength(2)
expect(
within(screen.getByRole('list')).getAllByRole('button', {
name: 'MixedNodeType'
})
).toHaveLength(1)
expect(
screen.getAllByRole('button', { name: 'Locate node on canvas' })
).toHaveLength(1)
})
})
describe('Events', () => {
it('emits locate-node with correct nodeId', async () => {
const onLocateNode = vi.fn()
const user = userEvent.setup()
renderRow({ showNodeIdBadge: true, 'onLocate-node': onLocateNode })
await user.click(screen.getByRole('button', { name: 'Expand' }))
const locateBtns = screen.getAllByRole('button', { name: 'Locate Node' })
renderRow({ 'onLocate-node': onLocateNode })
await user.click(
screen.getByRole('button', { name: 'Expand OldNodeType' })
)
const locateBtns = screen.getAllByRole('button', {
name: 'Locate node on canvas'
})
await user.click(locateBtns[0])
expect(onLocateNode).toHaveBeenCalledWith('1')
@@ -233,24 +279,100 @@ describe('SwapNodeGroupRow', () => {
})
})
describe('Single Node Groups', () => {
it('locates a single node without expanding', async () => {
const onLocateNode = vi.fn()
const user = userEvent.setup()
renderRow({
group: makeGroup({
type: 'SingleNodeType',
nodeTypes: [
{ type: 'SingleNodeType', nodeId: '42', isReplaceable: true }
]
}),
'onLocate-node': onLocateNode
})
expect(
screen.queryByRole('button', { name: /^Expand / })
).not.toBeInTheDocument()
expect(screen.queryByLabelText('1 node')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'SingleNodeType' }))
expect(onLocateNode).toHaveBeenCalledWith('42')
await user.click(
screen.getByRole('button', { name: 'Locate node on canvas' })
)
expect(onLocateNode).toHaveBeenCalledTimes(2)
expect(onLocateNode).toHaveBeenLastCalledWith('42')
})
it('renders a single node without nodeId as non-locatable text', () => {
renderRow({
group: makeGroup({
type: 'NoIdNode',
nodeTypes: fromAny<MissingNodeType[], unknown>([
{ type: 'NoIdNode', isReplaceable: true }
])
})
})
expect(screen.getByText('NoIdNode')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'NoIdNode' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('handles empty nodeTypes array', () => {
const { container } = renderRow({
group: makeGroup({ nodeTypes: [] })
renderRow({
group: makeGroup({
nodeTypes: []
})
})
expect(container.textContent).toContain('(0)')
expect(screen.getByText('OldNodeType')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'OldNodeType' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /^Expand / })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
})
it('handles string nodeType entries', async () => {
const user = userEvent.setup()
const { container } = renderRow({
renderRow({
group: makeGroup({
// Intentionally uses a plain string entry to test legacy node type handling
nodeTypes: fromAny<MissingNodeType[], unknown>(['StringType'])
nodeTypes: fromAny<MissingNodeType[], unknown>([
'StringType',
'OtherStringType'
])
})
})
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(container.textContent).toContain('StringType')
await user.click(
screen.getByRole('button', { name: 'Expand OldNodeType' })
)
expect(screen.getByText('StringType')).toBeInTheDocument()
expect(screen.getByText('OtherStringType')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'StringType' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'OtherStringType' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
})
})
})

View File

@@ -1,100 +1,152 @@
<template>
<div class="mb-4 flex w-full flex-col">
<!-- Type header row: type name + chevron -->
<div class="flex h-8 w-full items-center">
<p class="text-foreground min-w-0 flex-1 truncate text-sm font-medium">
{{ `${group.type} (${group.nodeTypes.length})` }}
</p>
<div class="mb-1 flex w-full flex-col gap-0.5 last:mb-0">
<div class="flex min-h-8 w-full items-center gap-1">
<Button
v-if="hasMultipleNodeTypes"
variant="textonly"
size="icon-sm"
size="unset"
:class="
cn(
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
{ 'rotate-180': expanded }
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-90'
)
"
:aria-label="
expanded
? t('rightSidePanel.missingNodePacks.collapse', 'Collapse')
: t('rightSidePanel.missingNodePacks.expand', 'Expand')
"
aria-hidden="true"
tabindex="-1"
@click="toggleExpand"
>
<i
class="icon-[lucide--chevron-down] size-4 text-muted-foreground group-hover:text-base-foreground"
aria-hidden="true"
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
/>
</Button>
</div>
<!-- Sub-labels: individual node instances, each with their own Locate button -->
<TransitionCollapse>
<div
v-if="expanded"
class="mb-2 flex flex-col gap-0.5 overflow-hidden pl-2"
>
<div
v-for="nodeType in group.nodeTypes"
:key="getKey(nodeType)"
class="flex h-7 items-center"
>
<span
v-if="
showNodeIdBadge &&
typeof nodeType !== 'string' &&
nodeType.nodeId != null
"
class="mr-1 shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
>
#{{ nodeType.nodeId }}
<span class="flex min-w-0 flex-1 flex-col gap-0">
<span class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 items-center gap-2.5">
<button
v-if="hasMultipleNodeTypes"
type="button"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
:title="group.type"
:aria-label="titleToggleAriaLabel"
:aria-expanded="expanded"
@click="toggleExpand"
>
{{ group.type }}
</button>
<button
v-else-if="primaryLocatableNodeType"
type="button"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
:title="group.type"
@click="handleLocateNode(primaryLocatableNodeType)"
>
{{ group.type }}
</button>
<span
v-else
class="min-w-0 truncate text-sm/relaxed font-normal text-base-foreground"
:title="group.type"
>
{{ group.type }}
</span>
<span
v-if="hasMultipleNodeTypes"
data-testid="swap-node-group-count"
role="img"
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected text-xs font-bold text-muted-foreground"
:aria-label="t('g.nodesCount', group.nodeTypes.length)"
>
{{ group.nodeTypes.length }}
</span>
</span>
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{{ getLabel(nodeType) }}
</p>
<Button
v-if="typeof nodeType !== 'string' && nodeType.nodeId != null"
variant="textonly"
size="icon-sm"
class="mr-1 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.locateNode', 'Locate Node')"
@click="handleLocateNode(nodeType)"
</span>
<span class="min-w-0 text-xs/relaxed text-muted-foreground">
{{
t(
'nodeReplacement.willBeReplacedBy',
'This node will be replaced by:'
)
}}
<span
class="inline-flex rounded-sm bg-modal-card-tag-background px-1.5 py-0.5 text-xs/none font-medium text-modal-card-tag-foreground"
>
<i class="icon-[lucide--locate] size-3" />
</Button>
</div>
</div>
</TransitionCollapse>
{{ replacementLabel }}
</span>
</span>
</span>
<!-- Description rows: what it is replaced by -->
<div class="mt-1 mb-2 flex flex-col gap-0.5 px-1 text-[13px]">
<span class="text-muted-foreground">{{
t('nodeReplacement.willBeReplacedBy', 'This node will be replaced by:')
}}</span>
<span class="text-foreground font-bold">{{
group.newNodeId ?? t('nodeReplacement.unknownNode', 'Unknown')
}}</span>
</div>
<!-- Replace Action Button -->
<div class="flex w-full items-start py-1">
<Button
variant="secondary"
size="md"
class="flex w-full flex-1"
size="sm"
class="h-8 shrink-0 rounded-lg text-sm"
@click="handleReplaceNode"
>
<i class="text-foreground mr-1 icon-[lucide--repeat] size-4 shrink-0" />
<span class="text-foreground min-w-0 truncate text-sm">
<i
aria-hidden="true"
class="text-foreground mr-1 icon-[lucide--repeat] size-4 shrink-0"
/>
<span class="text-foreground min-w-0 truncate">
{{ t('nodeReplacement.replaceNode', 'Replace Node') }}
</span>
</Button>
<Button
v-if="primaryLocatableNodeType"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="locateNodeLabel"
@click="handleLocateNode(primaryLocatableNodeType)"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
<TransitionCollapse>
<ul v-if="expanded" class="m-0 list-none space-y-1 p-0 pl-5">
<li
v-for="(nodeType, index) in group.nodeTypes"
:key="getKey(nodeType, index)"
class="min-w-0"
>
<div class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 flex-1 items-center gap-1">
<button
v-if="isLocatableNodeType(nodeType)"
type="button"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
@click="handleLocateNode(nodeType)"
>
{{ getLabel(nodeType) }}
</button>
<span
v-else
class="text-sm/relaxed wrap-break-word text-muted-foreground"
>
{{ getLabel(nodeType) }}
</span>
</span>
<Button
v-if="isLocatableNodeType(nodeType)"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="locateNodeLabel"
@click="handleLocateNode(nodeType)"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
</li>
</ul>
</TransitionCollapse>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
@@ -102,9 +154,8 @@ import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCol
import type { MissingNodeType } from '@/types/comfy'
import type { SwapNodeGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
const props = defineProps<{
const { group } = defineProps<{
group: SwapNodeGroup
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
@@ -115,28 +166,54 @@ const emit = defineEmits<{
const { t } = useI18n()
const expanded = ref(false)
const hasMultipleNodeTypes = computed(() => group.nodeTypes.length > 1)
const replacementLabel = computed(
() => group.newNodeId ?? t('nodeReplacement.unknownNode', 'Unknown')
)
const locateNodeLabel = computed(() =>
t('rightSidePanel.locateNode', 'Locate node on canvas')
)
const titleToggleAriaLabel = computed(
() =>
`${
expanded.value
? t('rightSidePanel.missingNodePacks.collapse', 'Collapse')
: t('rightSidePanel.missingNodePacks.expand', 'Expand')
} ${group.type}`
)
const primaryLocatableNodeType = computed(() => {
if (group.nodeTypes.length !== 1) return null
const [nodeType] = group.nodeTypes
return isLocatableNodeType(nodeType) ? nodeType : null
})
function toggleExpand() {
expanded.value = !expanded.value
}
function getKey(nodeType: MissingNodeType): string {
if (typeof nodeType === 'string') return nodeType
return nodeType.nodeId != null ? String(nodeType.nodeId) : nodeType.type
function getKey(nodeType: MissingNodeType, index: number): string {
if (typeof nodeType === 'string') return `${nodeType}-${index}`
return nodeType.nodeId != null
? String(nodeType.nodeId)
: `${nodeType.type}-${index}`
}
function getLabel(nodeType: MissingNodeType): string {
return typeof nodeType === 'string' ? nodeType : nodeType.type
}
function isLocatableNodeType(
nodeType: MissingNodeType
): nodeType is Exclude<MissingNodeType, string> & { nodeId: string | number } {
return typeof nodeType !== 'string' && nodeType.nodeId != null
}
function handleLocateNode(nodeType: MissingNodeType) {
if (typeof nodeType === 'string') return
if (nodeType.nodeId != null) {
emit('locate-node', String(nodeType.nodeId))
}
if (!isLocatableNodeType(nodeType)) return
emit('locate-node', String(nodeType.nodeId))
}
function handleReplaceNode() {
emit('replace', props.group)
emit('replace', group)
}
</script>

View File

@@ -10,8 +10,8 @@ vi.mock('./SwapNodeGroupRow.vue', () => ({
default: {
name: 'SwapNodeGroupRow',
template:
'<div class="swap-row" :data-show-node-id-badge="showNodeIdBadge" :data-group-type="group?.type"><button class="locate-trigger" @click="$emit(\'locate-node\', group?.nodeTypes?.[0]?.nodeId)">Locate</button><button class="replace-trigger" @click="$emit(\'replace\', group)">Replace</button></div>',
props: ['group', 'showNodeIdBadge'],
'<div class="swap-row" :data-group-type="group?.type"><button class="locate-trigger" @click="$emit(\'locate-node\', group?.nodeTypes?.[0]?.nodeId)">Locate</button><button class="replace-trigger" @click="$emit(\'replace\', group)">Replace</button></div>',
props: ['group'],
emits: ['locate-node', 'replace']
}
}))
@@ -29,7 +29,6 @@ function makeGroups(count = 2): SwapNodeGroup[] {
function mountCard(
props: Partial<{
swapNodeGroups: SwapNodeGroup[]
showNodeIdBadge: boolean
}> = {},
callbacks?: {
onLocateNode?: (nodeId: string) => void
@@ -39,7 +38,6 @@ function mountCard(
return render(SwapNodesCard, {
props: {
swapNodeGroups: makeGroups(),
showNodeIdBadge: false,
...props,
...(callbacks?.onLocateNode
? { 'onLocate-node': callbacks.onLocateNode }
@@ -72,16 +70,6 @@ describe('SwapNodesCard', () => {
expect(container.querySelectorAll('.swap-row')).toHaveLength(1)
})
it('passes showNodeIdBadge to children', () => {
const { container } = mountCard({
swapNodeGroups: makeGroups(1),
showNodeIdBadge: true
})
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const row = container.querySelector('.swap-row')
expect(row!.getAttribute('data-show-node-id-badge')).toBe('true')
})
it('passes group prop to children', () => {
const groups = makeGroups(1)
const { container } = mountCard({ swapNodeGroups: groups })

View File

@@ -4,7 +4,6 @@
v-for="group in swapNodeGroups"
:key="group.type"
:group="group"
:show-node-id-badge="showNodeIdBadge"
@locate-node="emit('locate-node', $event)"
@replace="emit('replace', $event)"
/>
@@ -15,9 +14,8 @@
import type { SwapNodeGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
import SwapNodeGroupRow from '@/platform/nodeReplacement/components/SwapNodeGroupRow.vue'
const { swapNodeGroups, showNodeIdBadge } = defineProps<{
const { swapNodeGroups } = defineProps<{
swapNodeGroups: SwapNodeGroup[]
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{

View File

@@ -53,8 +53,7 @@ describe('useReleaseService', () => {
project: 'comfyui',
current_version: '1.0.0'
},
signal: undefined,
headers: undefined
signal: undefined
})
expect(result).toEqual(mockReleases)
@@ -77,8 +76,7 @@ describe('useReleaseService', () => {
current_version: '1.0.0',
form_factor: 'desktop-windows'
},
signal: undefined,
headers: undefined
signal: undefined
})
expect(result).toEqual(mockReleases)
@@ -88,30 +86,11 @@ describe('useReleaseService', () => {
const abortController = new AbortController()
mockAxiosInstance.get.mockResolvedValue({ data: mockReleases })
await service.getReleases(
{ project: 'comfyui' },
{ signal: abortController.signal }
)
await service.getReleases({ project: 'comfyui' }, abortController.signal)
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/releases', {
params: { project: 'comfyui' },
signal: abortController.signal,
headers: undefined
})
})
it('should send Comfy-Env header when deployEnvironment is provided', async () => {
mockAxiosInstance.get.mockResolvedValue({ data: mockReleases })
await service.getReleases(
{ project: 'comfyui' },
{ deployEnvironment: 'local-desktop' }
)
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/releases', {
params: { project: 'comfyui' },
signal: undefined,
headers: { 'Comfy-Env': 'local-desktop' }
signal: abortController.signal
})
})

View File

@@ -98,9 +98,8 @@ export const useReleaseService = () => {
// Fetch release notes from API
const getReleases = async (
params: GetReleasesParams,
options: { signal?: AbortSignal; deployEnvironment?: string } = {}
signal?: AbortSignal
): Promise<ReleaseNote[] | null> => {
const { signal, deployEnvironment } = options
const endpoint = '/releases'
const errorContext = 'Failed to get releases'
const routeSpecificErrors = {
@@ -111,10 +110,7 @@ export const useReleaseService = () => {
() =>
releaseApiClient.get<ReleaseNote[]>(endpoint, {
params,
signal,
headers: deployEnvironment
? { 'Comfy-Env': deployEnvironment }
: undefined
signal
}),
errorContext,
routeSpecificErrors

View File

@@ -228,15 +228,12 @@ describe('useReleaseStore', () => {
await store.initialize()
expect(releaseService.getReleases).toHaveBeenCalledWith(
{
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows',
locale: 'en'
},
{ deployEnvironment: undefined }
)
expect(releaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows',
locale: 'en'
})
})
})
@@ -303,15 +300,12 @@ describe('useReleaseStore', () => {
await store.initialize()
expect(releaseService.getReleases).toHaveBeenCalledWith(
{
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows',
locale: 'en'
},
{ deployEnvironment: undefined }
)
expect(releaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows',
locale: 'en'
})
expect(store.releases).toEqual([mockRelease])
})
@@ -324,30 +318,12 @@ describe('useReleaseStore', () => {
await store.initialize()
expect(releaseService.getReleases).toHaveBeenCalledWith(
{
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'desktop-mac',
locale: 'en'
},
{ deployEnvironment: undefined }
)
})
it('should pass deploy_environment from system stats', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.deploy_environment = 'local-desktop'
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
await store.initialize()
expect(releaseService.getReleases).toHaveBeenCalledWith(
expect.anything(),
{ deployEnvironment: 'local-desktop' }
)
expect(releaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'desktop-mac',
locale: 'en'
})
})
it('should skip fetching when --disable-api-nodes is present', async () => {

View File

@@ -266,18 +266,12 @@ export const useReleaseStore = defineStore('release', () => {
await until(systemStatsStore.isInitialized)
}
const fetchedReleases = await releaseService.getReleases(
{
project: isCloud ? 'cloud' : 'comfyui',
current_version: currentVersion.value,
form_factor: systemStatsStore.getFormFactor(),
locale: stringToLocale(locale.value)
},
{
deployEnvironment:
systemStatsStore.systemStats?.system?.deploy_environment
}
)
const fetchedReleases = await releaseService.getReleases({
project: isCloud ? 'cloud' : 'comfyui',
current_version: currentVersion.value,
form_factor: systemStatsStore.getFormFactor(),
locale: stringToLocale(locale.value)
})
if (fetchedReleases !== null) {
releases.value = fetchedReleases

View File

@@ -51,9 +51,6 @@ const AudioPreviewPlayer = defineAsyncComponent(
const Load3D = defineAsyncComponent(
() => import('@/components/load3d/Load3D.vue')
)
const Load3DAdvanced = defineAsyncComponent(
() => import('@/components/load3d/Load3DAdvanced.vue')
)
const WidgetImageCrop = defineAsyncComponent(
() => import('@/components/imagecrop/WidgetImageCrop.vue')
)
@@ -172,14 +169,6 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
}
],
['load3D', { component: Load3D, aliases: ['LOAD_3D'], essential: false }],
[
'load3DAdvanced',
{
component: Load3DAdvanced,
aliases: ['LOAD_3D_ADVANCED'],
essential: false
}
],
[
'imagecrop',
{
@@ -254,7 +243,6 @@ const EXPANDING_TYPES = [
'textarea',
'markdown',
'load3D',
'load3DAdvanced',
'curve',
'painter',
'imagecompare',

View File

@@ -252,7 +252,6 @@ const zSystemStats = z.object({
python_version: z.string(),
embedded_python: z.boolean(),
comfyui_version: z.string(),
deploy_environment: z.string().optional(),
pytorch_version: z.string(),
required_frontend_version: z.string().optional(),
argv: z.array(z.string()),

View File

@@ -108,11 +108,6 @@ interface QueuePromptRequestBody {
* ```
*/
api_key_comfy_org?: string
/**
* Identifies the client submitting the prompt. Forwarded by the backend
* to API nodes' upstream requests via the Comfy-Usage-Source header.
*/
comfy_usage_source?: string
/**
* Override the preview method for this prompt execution.
* 'default' uses the server's CLI setting.
@@ -872,7 +867,6 @@ export class ComfyApi extends EventTarget {
extra_data: {
auth_token_comfy_org: this.authToken,
api_key_comfy_org: this.apiKey,
comfy_usage_source: 'comfyui-frontend',
extra_pnginfo: { workflow },
...(options?.previewMethod &&
options.previewMethod !== 'default' && {