Compare commits

...

3 Commits

Author SHA1 Message Date
Terry Jia
23cee5434b Merge branch 'main' into test/load3d-e2e-levels-10-12 2026-04-18 09:12:21 -04:00
Kelly Yang
d7b360c9fe fix: persist default scene and camera config to node properties on creation
New Load3D nodes had Scene Config and Camera Config applied to the 3D
scene but never written to node.properties, so the defaults were not
serialized with the workflow. On reload, loadSceneConfig/loadCameraConfig
would fall back to the current global setting instead of the value
active when the node was created, breaking per-node config isolation.

Write each config to node.properties immediately after applying it,
but only when the property is absent (new nodes), so existing saved
configs from loaded workflows are never overwritten.
2026-04-17 21:35:22 -07:00
Kelly Yang
d07599024a test: add E2E tests for Load3D config persistence, settings integration, and splat/PLY model loading (levels 10-12)
Add three new E2E spec files covering the 3D node test plan:
- load3dConfiguration.spec.ts: scene/camera/model config persists through
  workflow save and reload
- load3dSettings.spec.ts: Comfy.Load3D.* settings applied to newly created
  nodes; 3DViewerEnable controls button visibility
- load3dSplatPLY.spec.ts: PLY model loads with threejs engine; splat model
  hides Light and Export categories in the controls menu

Also extend Load3DHelper with menu interaction methods (selectMenuCategory,
clickGridToggle, switchCameraType, selectMaterialMode) and export
getNodeConfig<T> for reading node properties in tests. Add
load3dVueEnabledTest fixture and minimal test assets (test_model.ply,
test_model.splat).
2026-04-17 12:14:23 -07:00
9 changed files with 526 additions and 1 deletions

View File

@@ -0,0 +1,10 @@
ply
format ascii 1.0
element vertex 3
property float x
property float y
property float z
end_header
0.0 0.0 0.0
1.0 0.0 0.0
0.0 1.0 0.0

Binary file not shown.

View File

@@ -23,3 +23,15 @@ export const load3dViewerTest = load3dTest.extend<{
await use(new Load3DViewerHelper(comfyPage.page))
}
})
export const load3dVueEnabledTest = comfyPageFixture.extend<{
enableVueNodes: void
}>({
enableVueNodes: [
async ({ comfyPage }, use) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await use()
},
{ auto: true }
]
})

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { TestIds } from '@e2e/fixtures/selectors'
@@ -38,6 +38,23 @@ export class Load3DHelper {
await this.menuButton.click()
}
async selectMenuCategory(name: string): Promise<void> {
await this.getMenuCategory(name).click()
}
async clickGridToggle(): Promise<void> {
await this.node.getByRole('button', { name: /show grid/i }).click()
}
async switchCameraType(): Promise<void> {
await this.node.getByRole('button', { name: /switch camera/i }).click()
}
async selectMaterialMode(mode: string): Promise<void> {
await this.node.getByRole('button', { name: /material mode/i }).click()
await this.node.getByRole('button', { name: mode, exact: true }).click()
}
async setBackgroundColor(hex: string): Promise<void> {
await this.colorInput.evaluate((el, value) => {
;(el as HTMLInputElement).value = value
@@ -51,3 +68,20 @@ export class Load3DHelper {
})
}
}
export async function getNodeConfig<T>(
page: Page,
nodeId: string,
configKey: string
): Promise<T | null> {
return page.evaluate(
({ id, key }) => {
const node = window.app!.graph.getNodeById(Number(id))
if (!node?.properties) return null
return (
((node.properties as Record<string, unknown>)[key] as T | null) ?? null
)
},
{ id: nodeId, key: configKey }
)
}

View File

@@ -0,0 +1,149 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
import type { Load3DHelper } from '@e2e/tests/load3d/Load3DHelper'
import { getNodeConfig } from '@e2e/tests/load3d/Load3DHelper'
const nodeId = '1'
async function saveAndReload(comfyPage: ComfyPage): Promise<void> {
const name = `load3d-config-${Date.now().toString(36)}`
await comfyPage.menu.workflowsTab.open()
await comfyPage.menu.topbar.saveWorkflow(name)
await comfyPage.page.reload({ waitUntil: 'domcontentloaded' })
await comfyPage.page.waitForFunction(
() => window.app && window.app.extensionManager,
{ timeout: 30_000 }
)
await comfyPage.page.locator('.p-blockui-mask').waitFor({
state: 'hidden',
timeout: 30_000
})
await comfyPage.nextFrame()
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
const tab = comfyPage.menu.workflowsTab
await tab.open()
await tab.getPersistedItem(name).click()
await comfyPage.workflow.waitForWorkflowIdle(30_000)
await comfyPage.vueNodes.waitForNodes()
}
async function openMenuAtCategory(
load3d: Load3DHelper,
category: string
): Promise<void> {
await load3d.openMenu()
await load3d.selectMenuCategory(category)
}
test.describe(
'Load3D configuration persistence',
{ tag: ['@workflow', '@slow'] },
() => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.workflow.setupWorkflowsDirectory({})
})
test('scene config (grid + background color) persists across save and reload', async ({
comfyPage,
load3d
}) => {
test.setTimeout(120_000)
await openMenuAtCategory(load3d, 'Scene')
await load3d.clickGridToggle()
await load3d.setBackgroundColor('#ff4400')
await expect
.poll(() =>
getNodeConfig<Record<string, unknown>>(
comfyPage.page,
nodeId,
'Scene Config'
).then((c) => c?.showGrid)
)
.toBe(false)
await saveAndReload(comfyPage)
await expect
.poll(() =>
getNodeConfig<Record<string, unknown>>(
comfyPage.page,
nodeId,
'Scene Config'
)
)
.toMatchObject({ showGrid: false, backgroundColor: '#ff4400' })
})
test('camera config (orthographic) persists across save and reload', async ({
comfyPage,
load3d
}) => {
test.setTimeout(120_000)
await openMenuAtCategory(load3d, 'Camera')
await load3d.switchCameraType()
await expect
.poll(() =>
getNodeConfig<Record<string, unknown>>(
comfyPage.page,
nodeId,
'Camera Config'
).then((c) => c?.cameraType)
)
.toBe('orthographic')
await saveAndReload(comfyPage)
await expect
.poll(() =>
getNodeConfig<Record<string, unknown>>(
comfyPage.page,
nodeId,
'Camera Config'
).then((c) => c?.cameraType)
)
.toBe('orthographic')
})
test('model config (wireframe material) persists across save and reload', async ({
comfyPage,
load3d
}) => {
test.setTimeout(120_000)
await openMenuAtCategory(load3d, 'Model')
await load3d.selectMaterialMode('Wireframe')
await expect
.poll(() =>
getNodeConfig<Record<string, unknown>>(
comfyPage.page,
nodeId,
'Model Config'
).then((c) => c?.materialMode)
)
.toBe('wireframe')
await saveAndReload(comfyPage)
await expect
.poll(() =>
getNodeConfig<Record<string, unknown>>(
comfyPage.page,
nodeId,
'Model Config'
).then((c) => c?.materialMode)
)
.toBe('wireframe')
})
}
)

View File

@@ -0,0 +1,116 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import {
load3dTest,
load3dVueEnabledTest
} from '@e2e/fixtures/helpers/Load3DFixtures'
import { getNodeConfig } from '@e2e/tests/load3d/Load3DHelper'
async function addLoad3dNode(page: Page): Promise<string> {
const nodeId = await page.evaluate(() => {
const node = window.app!.graph.add(
window.LiteGraph!.createNode('Load3D', undefined, {})
)
return String(node!.id)
})
await page.waitForFunction(
(id) => document.querySelector(`[data-node-id="${id}"]`) !== null,
nodeId
)
return nodeId
}
load3dVueEnabledTest.describe(
'Load3D settings integration',
{ tag: '@ui' },
() => {
load3dVueEnabledTest(
'default grid visibility is applied to new nodes from setting',
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Load3D.ShowGrid', false)
const nodeId = await addLoad3dNode(comfyPage.page)
await comfyPage.nextFrame()
await expect
.poll(() =>
getNodeConfig<Record<string, unknown>>(
comfyPage.page,
nodeId,
'Scene Config'
).then((c) => c?.showGrid)
)
.toBe(false)
}
)
load3dVueEnabledTest(
'default background color is applied to new nodes from setting',
async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Load3D.BackgroundColor',
'ff4400'
)
const nodeId = await addLoad3dNode(comfyPage.page)
await comfyPage.nextFrame()
await expect
.poll(() =>
getNodeConfig<Record<string, unknown>>(
comfyPage.page,
nodeId,
'Scene Config'
).then((c) => c?.backgroundColor)
)
.toBe('#ff4400')
}
)
load3dVueEnabledTest(
'default camera type is applied to new nodes from setting',
async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Load3D.CameraType',
'orthographic'
)
const nodeId = await addLoad3dNode(comfyPage.page)
await comfyPage.nextFrame()
await expect
.poll(() =>
getNodeConfig<Record<string, unknown>>(
comfyPage.page,
nodeId,
'Camera Config'
).then((c) => c?.cameraType)
)
.toBe('orthographic')
}
)
}
)
load3dTest.describe(
'3D Viewer button visibility controlled by setting',
{ tag: '@ui' },
() => {
load3dTest(
'open viewer button visible when 3DViewerEnable is true',
async ({ comfyPage, load3d }) => {
await comfyPage.settings.setSetting('Comfy.Load3D.3DViewerEnable', true)
await expect(load3d.openViewerButton).toBeVisible()
}
)
load3dTest(
'open viewer button hidden when 3DViewerEnable is false',
async ({ comfyPage, load3d }) => {
await comfyPage.settings.setSetting(
'Comfy.Load3D.3DViewerEnable',
false
)
await expect(load3d.openViewerButton).toBeHidden()
}
)
}
)

View File

@@ -0,0 +1,79 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { assetPath } from '@e2e/fixtures/utils/paths'
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
import type { Load3DHelper } from '@e2e/tests/load3d/Load3DHelper'
async function uploadModel(
comfyPage: ComfyPage,
load3d: Load3DHelper,
filePath: string
): Promise<void> {
const uploadDone = comfyPage.page.waitForResponse(
(r) => r.url().includes('/upload/') && r.status() === 200,
{ timeout: 15_000 }
)
const chooser = comfyPage.page.waitForEvent('filechooser')
await load3d.getUploadButton('upload 3d model').click()
const fc = await chooser
await fc.setFiles(filePath)
await uploadDone
}
test.describe('Load3D PLY model loading', { tag: ['@node'] }, () => {
test('loads a PLY model with the threejs engine', async ({
comfyPage,
load3d
}) => {
await comfyPage.settings.setSetting('Comfy.Load3D.PLYEngine', 'threejs')
await uploadModel(comfyPage, load3d, assetPath('3d/test_model.ply'))
const nodeRef = await comfyPage.nodeOps.getNodeRefById(1)
const modelFileWidget = await nodeRef.getWidget(0)
await expect
.poll(() => modelFileWidget.getValue())
.toContain('test_model.ply')
await load3d.waitForModelLoaded()
const canvasBox = await load3d.canvas.boundingBox()
expect(
canvasBox,
'canvas bounding box must exist after model load'
).not.toBeNull()
expect(canvasBox!.width).toBeGreaterThan(0)
expect(canvasBox!.height).toBeGreaterThan(0)
})
})
test.describe(
'Load3D splat model — controls menu',
{ tag: ['@node', '@slow'] },
() => {
test('Light and Export categories are hidden after loading a splat model', async ({
comfyPage,
load3d
}) => {
test.setTimeout(90_000)
await uploadModel(comfyPage, load3d, assetPath('3d/test_model.splat'))
const nodeRef = await comfyPage.nodeOps.getNodeRefById(1)
const modelFileWidget = await nodeRef.getWidget(0)
await expect
.poll(() => modelFileWidget.getValue())
.toContain('test_model.splat')
await load3d.waitForModelLoaded()
await load3d.openMenu()
await expect(load3d.getMenuCategory('Scene')).toBeVisible()
await expect(load3d.getMenuCategory('Model')).toBeVisible()
await expect(load3d.getMenuCategory('Camera')).toBeVisible()
await expect(load3d.getMenuCategory('Light')).toBeHidden()
await expect(load3d.getMenuCategory('Export')).toBeHidden()
})
}
)

View File

@@ -0,0 +1,119 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import type {
CameraConfig,
SceneConfig
} from '@/extensions/core/load3d/interfaces'
import type { Dictionary } from '@/lib/litegraph/src/interfaces'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
const mockGet = vi.hoisted(() => vi.fn())
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({ get: mockGet })
}))
vi.mock('@/scripts/api', () => ({
api: { apiURL: vi.fn((p: string) => p) }
}))
vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
default: {
getResourceURL: vi.fn(() => '/test'),
splitFilePath: vi.fn(() => ['', 'test.glb']),
getFilenameExtension: vi.fn(() => 'glb')
}
}))
function makeLoad3dMock() {
return {
toggleGrid: vi.fn(),
setBackgroundColor: vi.fn(),
toggleCamera: vi.fn(),
setFOV: vi.fn(),
setLightIntensity: vi.fn()
} as unknown as InstanceType<
typeof import('@/extensions/core/load3d/Load3d').default
>
}
function defaultSettings(overrides: Record<string, unknown> = {}) {
const base: Record<string, unknown> = {
'Comfy.Load3D.ShowGrid': true,
'Comfy.Load3D.BackgroundColor': '000000',
'Comfy.Load3D.CameraType': 'perspective',
'Comfy.Load3D.LightIntensity': 1
}
return { ...base, ...overrides }
}
describe('Load3DConfiguration — setupDefaultProperties persistence', () => {
beforeEach(() => {
vi.clearAllMocks()
})
function configure(
properties: Dictionary<NodeProperty | undefined>,
settingOverrides: Record<string, unknown> = {}
) {
const settings = defaultSettings(settingOverrides)
mockGet.mockImplementation((key: string) => settings[key])
const config = new Load3DConfiguration(makeLoad3dMock(), properties)
config.configureForSaveMesh('input', '')
}
describe('Scene Config', () => {
it('writes Scene Config from settings when property is absent', () => {
const properties: Dictionary<NodeProperty | undefined> = {}
configure(properties, {
'Comfy.Load3D.ShowGrid': false,
'Comfy.Load3D.BackgroundColor': 'ff4400'
})
const scene = properties['Scene Config'] as SceneConfig
expect(scene).toBeDefined()
expect(scene.showGrid).toBe(false)
expect(scene.backgroundColor).toBe('#ff4400')
})
it('does not overwrite existing Scene Config', () => {
const existing: SceneConfig = {
showGrid: true,
backgroundColor: '#aabbcc',
backgroundImage: ''
}
const properties: Dictionary<NodeProperty | undefined> = {
'Scene Config': existing
}
configure(properties, { 'Comfy.Load3D.ShowGrid': false })
expect(properties['Scene Config']).toBe(existing)
expect((properties['Scene Config'] as SceneConfig).showGrid).toBe(true)
})
})
describe('Camera Config', () => {
it('writes Camera Config from settings when property is absent', () => {
const properties: Dictionary<NodeProperty | undefined> = {}
configure(properties, { 'Comfy.Load3D.CameraType': 'orthographic' })
const camera = properties['Camera Config'] as CameraConfig
expect(camera).toBeDefined()
expect(camera.cameraType).toBe('orthographic')
})
it('does not overwrite existing Camera Config', () => {
const existing: CameraConfig = { cameraType: 'perspective', fov: 60 }
const properties: Dictionary<NodeProperty | undefined> = {
'Camera Config': existing
}
configure(properties, { 'Comfy.Load3D.CameraType': 'orthographic' })
expect(properties['Camera Config']).toBe(existing)
expect((properties['Camera Config'] as CameraConfig).cameraType).toBe(
'perspective'
)
})
})
})

View File

@@ -108,9 +108,15 @@ class Load3DConfiguration {
private setupDefaultProperties(bgImagePath?: string) {
const sceneConfig = this.loadSceneConfig()
this.applySceneConfig(sceneConfig, bgImagePath)
if (this.properties && !('Scene Config' in this.properties)) {
this.properties['Scene Config'] = sceneConfig
}
const cameraConfig = this.loadCameraConfig()
this.applyCameraConfig(cameraConfig)
if (this.properties && !('Camera Config' in this.properties)) {
this.properties['Camera Config'] = cameraConfig
}
const lightConfig = this.loadLightConfig()
this.applyLightConfig(lightConfig)