Compare commits

..

3 Commits

Author SHA1 Message Date
Kelly Yang
e0986db81a test: make Preview3D camera restore deterministic
Set a non-default Load3D camera state through real canvas interaction before queueing so Preview3D receives camera info consistently in CI.
2026-04-10 00:30:05 -07:00
Kelly Yang
ea3c289a07 test: stabilize Preview3D camera state assertion
Use a polling assertion before reading Preview3D camera state so the test waits for async state propagation after execution and avoids null reads in CI.
2026-04-10 00:03:36 -07:00
Kelly Yang
fa7477b8c4 test: add Preview3D execution flow E2E tests 2026-04-09 22:13:30 -07:00
9 changed files with 450 additions and 230 deletions

View File

@@ -0,0 +1,88 @@
{
"last_node_id": 2,
"last_link_id": 1,
"nodes": [
{
"id": 1,
"type": "Load3D",
"pos": [50, 50],
"size": [400, 650],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
},
{
"name": "mesh_path",
"type": "STRING",
"links": null
},
{
"name": "normal",
"type": "IMAGE",
"links": null
},
{
"name": "camera_info",
"type": "LOAD3D_CAMERA",
"links": null
},
{
"name": "recording_video",
"type": "VIDEO",
"links": null
},
{
"name": "model_3d",
"type": "FILE_3D",
"links": [1]
}
],
"properties": {
"Node name for S&R": "Load3D"
},
"widgets_values": ["", 1024, 1024, "#000000"]
},
{
"id": 2,
"type": "Preview3D",
"pos": [520, 50],
"size": [450, 600],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "model_file",
"type": "FILE_3D",
"link": 1
}
],
"outputs": [],
"properties": {
"Node name for S&R": "Preview3D"
},
"widgets_values": []
}
],
"links": [[1, 1, 6, 2, 0, "*"]],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -0,0 +1,121 @@
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { Load3DHelper } from '@e2e/tests/load3d/Load3DHelper'
export async function waitForAppBootstrapped(
comfyPage: ComfyPage
): Promise<void> {
await comfyPage.page.waitForFunction(() =>
Boolean(window.app?.extensionManager)
)
await comfyPage.page.waitForSelector('.p-blockui-mask', { state: 'hidden' })
await comfyPage.nextFrame()
}
export function vecClose(
a: { x: number; y: number; z: number },
b: { x: number; y: number; z: number },
eps: number
): boolean {
return (
Math.abs(a.x - b.x) < eps &&
Math.abs(a.y - b.y) < eps &&
Math.abs(a.z - b.z) < eps
)
}
export function cameraStatesClose(
a: unknown,
b: unknown,
eps: number
): boolean {
if (
a === null ||
b === null ||
typeof a !== 'object' ||
typeof b !== 'object'
)
return false
const ca = a as {
position?: { x: number; y: number; z: number }
target?: { x: number; y: number; z: number }
zoom?: number
cameraType?: string
}
const cb = b as typeof ca
if (
!ca.position ||
!ca.target ||
!cb.position ||
!cb.target ||
ca.cameraType !== cb.cameraType
)
return false
if (Math.abs((ca.zoom ?? 0) - (cb.zoom ?? 0)) > eps) return false
return (
vecClose(ca.position, cb.position, eps) &&
vecClose(ca.target, cb.target, eps)
)
}
export class Preview3DPipelineContext {
static readonly loadNodeId = '1'
static readonly previewNodeId = '2'
readonly load3d: Load3DHelper
readonly preview3d: Load3DHelper
constructor(readonly comfyPage: ComfyPage) {
this.load3d = new Load3DHelper(
comfyPage.vueNodes.getNodeLocator(Preview3DPipelineContext.loadNodeId)
)
this.preview3d = new Load3DHelper(
comfyPage.vueNodes.getNodeLocator(Preview3DPipelineContext.previewNodeId)
)
}
async getModelFileWidgetValue(nodeId: string): Promise<string> {
return this.comfyPage.page.evaluate((id) => {
const n = window.app!.graph.getNodeById(Number(id))
const w = n?.widgets?.find((x) => x.name === 'model_file')
return typeof w?.value === 'string' ? w.value : ''
}, nodeId)
}
async getLastTimeModelFile(nodeId: string): Promise<string> {
return this.comfyPage.page.evaluate((id) => {
const n = window.app!.graph.getNodeById(Number(id))
const v = n?.properties?.['Last Time Model File']
return typeof v === 'string' ? v : ''
}, nodeId)
}
async getCameraStateFromProperties(nodeId: string): Promise<unknown> {
return this.comfyPage.page.evaluate((id) => {
const n = window.app!.graph.getNodeById(Number(id))
const cfg = n?.properties?.['Camera Config'] as
| { state?: unknown }
| undefined
return cfg?.state ?? null
}, nodeId)
}
}
export const preview3dPipelineTest = comfyPageFixture.extend<{
preview3dPipeline: Preview3DPipelineContext
}>({
preview3dPipeline: async ({ comfyPage }, use) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
await comfyPage.workflow.loadWorkflow('3d/preview3d_pipeline')
await comfyPage.vueNodes.waitForNodes()
const pipeline = new Preview3DPipelineContext(comfyPage)
await use(pipeline)
await comfyPage.workflow.setupWorkflowsDirectory({})
}
})

View File

@@ -0,0 +1,209 @@
import { expect } from '@playwright/test'
import {
cameraStatesClose,
preview3dPipelineTest as test,
Preview3DPipelineContext,
waitForAppBootstrapped
} from '@e2e/fixtures/helpers/Preview3DPipelineFixture'
import { assetPath } from '@e2e/fixtures/utils/paths'
async function seedLoad3dWithCubeObj(
pipeline: Preview3DPipelineContext
): Promise<void> {
const { comfyPage, load3d } = pipeline
const fileChooserPromise = comfyPage.page.waitForEvent('filechooser')
await load3d.getUploadButton('upload 3d model').click()
const fileChooser = await fileChooserPromise
await fileChooser.setFiles(assetPath('cube.obj'))
await expect
.poll(
() =>
pipeline.getModelFileWidgetValue(Preview3DPipelineContext.loadNodeId),
{ timeout: 15_000 }
)
.toContain('cube.obj')
await load3d.waitForModelLoaded()
await comfyPage.nextFrame()
}
async function setNonDefaultLoad3dCameraState(
pipeline: Preview3DPipelineContext
): Promise<void> {
const { comfyPage, load3d } = pipeline
const box = await load3d.canvas.boundingBox()
expect(box, 'Load3D canvas bounding box should exist').not.toBeNull()
const startX = box!.x + box!.width * 0.5
const startY = box!.y + box!.height * 0.5
await comfyPage.page.mouse.move(startX, startY)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(startX + 80, startY + 20, {
steps: 10
})
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
await expect
.poll(
() =>
pipeline.getCameraStateFromProperties(
Preview3DPipelineContext.loadNodeId
),
{ timeout: 10_000 }
)
.not.toBeNull()
}
async function alignPreview3dWorkflowUiSettings(
pipeline: Preview3DPipelineContext
): Promise<void> {
await pipeline.comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await pipeline.comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
}
test.describe('Preview3D execution flow', { tag: ['@node', '@slow'] }, () => {
test('Preview3D loads model from execution output', async ({
preview3dPipeline: pipeline
}) => {
test.setTimeout(120_000)
await seedLoad3dWithCubeObj(pipeline)
await pipeline.comfyPage.command.executeCommand('Comfy.QueuePrompt')
await expect
.poll(
() =>
pipeline.getModelFileWidgetValue(
Preview3DPipelineContext.previewNodeId
),
{ timeout: 90_000 }
)
.not.toBe('')
const modelPath = await pipeline.getModelFileWidgetValue(
Preview3DPipelineContext.previewNodeId
)
await expect(
modelPath.length,
'Preview3D model path populated'
).toBeGreaterThan(4)
await expect
.poll(
() =>
pipeline.getLastTimeModelFile(Preview3DPipelineContext.previewNodeId),
{ timeout: 5000 }
)
.toBe(modelPath)
await pipeline.preview3d.waitForModelLoaded()
await expect
.poll(async () => {
const b = await pipeline.preview3d.canvas.boundingBox()
return (b?.width ?? 0) > 0 && (b?.height ?? 0) > 0
})
.toBe(true)
})
test('Preview3D restores last model and camera after save and full reload', async ({
preview3dPipeline: pipeline
}) => {
test.setTimeout(180_000)
await seedLoad3dWithCubeObj(pipeline)
await setNonDefaultLoad3dCameraState(pipeline)
await pipeline.comfyPage.command.executeCommand('Comfy.QueuePrompt')
await expect
.poll(
() =>
pipeline.getModelFileWidgetValue(
Preview3DPipelineContext.previewNodeId
),
{ timeout: 90_000 }
)
.not.toBe('')
await pipeline.preview3d.waitForModelLoaded()
const savedPath = await pipeline.getModelFileWidgetValue(
Preview3DPipelineContext.previewNodeId
)
await expect
.poll(
() =>
pipeline.getCameraStateFromProperties(
Preview3DPipelineContext.previewNodeId
),
{ timeout: 15_000 }
)
.not.toBeNull()
const savedCamera = await pipeline.getCameraStateFromProperties(
Preview3DPipelineContext.previewNodeId
)
const workflowName = `p3d-restore-${Date.now().toString(36)}`
await pipeline.comfyPage.menu.workflowsTab.open()
await pipeline.comfyPage.menu.topbar.saveWorkflow(workflowName)
await pipeline.comfyPage.page.reload({ waitUntil: 'networkidle' })
await waitForAppBootstrapped(pipeline.comfyPage)
await alignPreview3dWorkflowUiSettings(pipeline)
const tab = pipeline.comfyPage.menu.workflowsTab
await tab.open()
await tab.getPersistedItem(workflowName).click()
await pipeline.comfyPage.workflow.waitForWorkflowIdle(15_000)
await pipeline.comfyPage.vueNodes.waitForNodes()
await expect
.poll(
() =>
pipeline.getModelFileWidgetValue(
Preview3DPipelineContext.previewNodeId
),
{ timeout: 30_000 }
)
.toBe(savedPath)
await expect
.poll(
() =>
pipeline.getLastTimeModelFile(Preview3DPipelineContext.previewNodeId),
{ timeout: 5000 }
)
.toBe(savedPath)
await pipeline.preview3d.waitForModelLoaded()
await expect
.poll(async () => {
const b = await pipeline.preview3d.canvas.boundingBox()
return (b?.width ?? 0) > 0 && (b?.height ?? 0) > 0
})
.toBe(true)
await expect
.poll(async () =>
cameraStatesClose(
await pipeline.getCameraStateFromProperties(
Preview3DPipelineContext.previewNodeId
),
savedCamera,
2e-2
)
)
.toBe(true)
})
})

View File

@@ -323,38 +323,6 @@ describe('useGLSLPreview', () => {
expect(mockRendererFactory.compileFragment).not.toHaveBeenCalled()
})
it('uses custom resolution when size_mode is custom', async () => {
const store = fromAny<WidgetValueStoreStub, unknown>(
useWidgetValueStore()
)
store._widgetMap.set('size_mode', { value: 'custom' })
store._widgetMap.set('size_mode.width', { value: 800 })
store._widgetMap.set('size_mode.height', { value: 600 })
const node = createMockNode()
await setupAndRender(node)
expect(mockRendererFactory.setResolution).toHaveBeenCalledWith(800, 600)
store._widgetMap.delete('size_mode')
store._widgetMap.delete('size_mode.width')
store._widgetMap.delete('size_mode.height')
})
it('uses default resolution when size_mode is not custom', async () => {
const store = fromAny<WidgetValueStoreStub, unknown>(
useWidgetValueStore()
)
store._widgetMap.set('size_mode', { value: 'from_input' })
const node = createMockNode()
await setupAndRender(node)
expect(mockRendererFactory.setResolution).toHaveBeenCalledWith(512, 512)
store._widgetMap.delete('size_mode')
})
it('disposes renderer and cancels debounce on cleanup', async () => {
const node = createMockNode()
const { dispose } = await setupAndRender(node)

View File

@@ -282,44 +282,7 @@ function createInnerPreview(
}
}
function getCustomResolution(): [number, number] | null {
const gId = graphId.value
if (!gId) return null
const sizeModeNodeId = innerGLSLNode
? (innerGLSLNode.id as NodeId)
: nodeId.value
if (sizeModeNodeId == null) return null
const sizeMode = widgetValueStore.getWidget(
gId,
sizeModeNodeId,
'size_mode'
)
if (sizeMode?.value !== 'custom') return null
const widthWidget = widgetValueStore.getWidget(
gId,
sizeModeNodeId,
'size_mode.width'
)
const heightWidget = widgetValueStore.getWidget(
gId,
sizeModeNodeId,
'size_mode.height'
)
if (!widthWidget || !heightWidget) return null
return clampResolution(
normalizeDimension(widthWidget.value),
normalizeDimension(heightWidget.value)
)
}
function getResolution(): [number, number] {
const custom = getCustomResolution()
if (custom) return custom
const node = nodeRef.value
if (!node?.inputs) return [DEFAULT_SIZE, DEFAULT_SIZE]
@@ -362,6 +325,27 @@ function createInnerPreview(
}
}
const gId = graphId.value
const nId = nodeId.value
if (gId && nId != null) {
const widthWidget = widgetValueStore.getWidget(
gId,
nId,
'size_mode.width'
)
const heightWidget = widgetValueStore.getWidget(
gId,
nId,
'size_mode.height'
)
if (widthWidget && heightWidget) {
return clampResolution(
normalizeDimension(widthWidget.value),
normalizeDimension(heightWidget.value)
)
}
}
return [DEFAULT_SIZE, DEFAULT_SIZE]
}

View File

@@ -1,115 +0,0 @@
import { fromAny } from '@total-typescript/shoehorn'
import { describe, expect, it } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import {
extractUniformSources,
toNumber
} from '@/renderer/glsl/useGLSLUniforms'
function createMockSubgraph(
links: Record<number, { origin_id: number; origin_slot: number }>,
nodes: Record<
number,
{ id: number; widgets: Array<{ name: string; value: unknown }> }
>
) {
return fromAny<Subgraph, unknown>({
getLink: (id: number) => links[id] ?? null,
getNodeById: (id: number) => nodes[id] ?? null
})
}
describe('extractUniformSources', () => {
it('uses origin_slot to select the correct widget from source node', () => {
const glslNode = fromAny<LGraphNode, unknown>({
inputs: [
{ name: 'ints.u_int0', link: 1 },
{ name: 'ints.u_int1', link: 2 }
]
})
const subgraph = createMockSubgraph(
{
1: { origin_id: 10, origin_slot: 1 },
2: { origin_id: 20, origin_slot: 0 }
},
{
10: {
id: 10,
widgets: [
{ name: 'choice', value: 'Master' },
{ name: 'index', value: 0 }
]
},
20: {
id: 20,
widgets: [{ name: 'value', value: 42 }]
}
}
)
const result = extractUniformSources(glslNode, subgraph)
expect(result.ints[0].widgetName).toBe('index')
expect(result.ints[1].widgetName).toBe('value')
})
it('skips source when origin_slot exceeds widget count', () => {
const glslNode = fromAny<LGraphNode, unknown>({
inputs: [{ name: 'floats.u_float0', link: 1 }]
})
const subgraph = createMockSubgraph(
{ 1: { origin_id: 10, origin_slot: 5 } },
{ 10: { id: 10, widgets: [{ name: 'value', value: 3.14 }] } }
)
const result = extractUniformSources(glslNode, subgraph)
expect(result.floats).toHaveLength(0)
})
it('provides directValue getter that reads from the widget', () => {
const indexWidget = {
name: 'index',
get value() {
return choiceWidget.value === 'Reds' ? 1 : 0
}
}
const choiceWidget = { name: 'choice', value: 'Master' }
const glslNode = fromAny<LGraphNode, unknown>({
inputs: [{ name: 'ints.u_int0', link: 1 }]
})
const subgraph = createMockSubgraph(
{ 1: { origin_id: 10, origin_slot: 1 } },
{ 10: { id: 10, widgets: [choiceWidget, indexWidget] } }
)
const result = extractUniformSources(glslNode, subgraph)
expect(result.ints[0].directValue()).toBe(0)
choiceWidget.value = 'Reds'
expect(result.ints[0].directValue()).toBe(1)
})
})
describe('toNumber', () => {
it('coerces hex color strings via hexToInt', () => {
expect(toNumber('#45edf5')).toBe(0x45edf5)
})
it('coerces plain numeric values', () => {
expect(toNumber(42)).toBe(42)
expect(toNumber('10')).toBe(10)
})
it('returns 0 for non-numeric strings', () => {
expect(toNumber('Master')).toBe(0)
})
})

View File

@@ -10,7 +10,6 @@ import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { isCurveData } from '@/components/curve/curveUtils'
import type { CurveData } from '@/components/curve/types'
import type { GLSLRendererConfig } from '@/renderer/glsl/useGLSLRenderer'
import { hexToInt } from '@/utils/colorUtil'
interface AutogrowGroup {
max: number
@@ -21,8 +20,6 @@ interface AutogrowGroup {
interface UniformSource {
nodeId: NodeId
widgetName: string
/** Fallback getter for widgets not registered in widgetValueStore (e.g. hidden computed widgets). */
directValue: () => unknown
}
interface UniformSources {
@@ -81,19 +78,16 @@ export function extractUniformSources(
if (!link || link.origin_id === SUBGRAPH_INPUT_ID) continue
const sourceNode = subgraph.getNodeById(link.origin_id)
if (!sourceNode?.widgets?.length) continue
if (!sourceNode?.widgets?.[0]) continue
const inputName = input.name ?? ''
const dotIndex = inputName.indexOf('.')
if (dotIndex === -1) continue
const prefix = inputName.slice(0, dotIndex)
if (link.origin_slot >= sourceNode.widgets.length) continue
const widget = sourceNode.widgets[link.origin_slot]
const source: UniformSource = {
nodeId: sourceNode.id as NodeId,
widgetName: widget.name,
directValue: () => widget.value
widgetName: sourceNode.widgets[0].name
}
if (prefix === 'floats') floats.push(source)
@@ -105,11 +99,6 @@ export function extractUniformSources(
return { floats, ints, bools, curves }
}
export function toNumber(v: unknown): number {
if (typeof v === 'string' && v.startsWith('#')) return hexToInt(v)
return Number(v) || 0
}
export function useGLSLUniforms(
graphId: ComputedRef<UUID | undefined>,
nodeId: ComputedRef<NodeId | undefined>,
@@ -131,9 +120,9 @@ export function useGLSLUniforms(
if (!gId) return []
if (subgraphSources) {
return subgraphSources.map(({ nodeId: nId, widgetName, directValue }) => {
return subgraphSources.map(({ nodeId: nId, widgetName }) => {
const widget = widgetValueStore.getWidget(gId, nId, widgetName)
return coerce(widget?.value ?? directValue() ?? defaultValue)
return coerce(widget?.value ?? defaultValue)
})
}
@@ -153,24 +142,19 @@ export function useGLSLUniforms(
const slot = node.inputs?.findIndex((inp) => inp.name === inputName)
if (slot == null || slot < 0) break
const link = node.getInputLink(slot)
if (!link) break
const upstreamNode = node.getInputNode(slot)
if (!upstreamNode) break
const upstreamWidgets = widgetValueStore.getNodeWidgets(
gId,
upstreamNode.id as NodeId
)
if (
upstreamWidgets.length === 0 ||
link.origin_slot >= upstreamWidgets.length
)
break
values.push(coerce(upstreamWidgets[link.origin_slot].value))
if (upstreamWidgets.length === 0) break
values.push(coerce(upstreamWidgets[0].value))
}
return values
}
const toNumber = (v: unknown): number => Number(v) || 0
const toBool = (v: unknown): boolean => Boolean(v)
const floatValues = computed(() =>
@@ -213,10 +197,11 @@ export function useGLSLUniforms(
const sources = uniformSources.value?.curves
if (sources && sources.length > 0) {
return sources
.map(({ nodeId: nId, widgetName, directValue }) => {
.map(({ nodeId: nId, widgetName }) => {
const widget = widgetValueStore.getWidget(gId, nId, widgetName)
const value = widget?.value ?? directValue()
return isCurveData(value) ? (value as CurveData) : null
return widget && isCurveData(widget.value)
? (widget.value as CurveData)
: null
})
.filter((v): v is CurveData => v !== null)
}

View File

@@ -4,7 +4,6 @@ import type { ColorAdjustOptions } from '@/utils/colorUtil'
import {
adjustColor,
hexToHsva,
hexToInt,
hexToRgb,
hsbToRgb,
hsvaToHex,
@@ -96,20 +95,6 @@ describe('colorUtil conversions', () => {
})
})
describe('hexToInt', () => {
it('converts 6-digit hex to packed integer', () => {
expect(hexToInt('#ff0000')).toBe(0xff0000)
expect(hexToInt('#00ff00')).toBe(0x00ff00)
expect(hexToInt('#45edf5')).toBe(0x45edf5)
expect(hexToInt('#000000')).toBe(0)
})
it('converts 3-digit hex to packed integer', () => {
expect(hexToInt('#fff')).toBe(0xffffff)
expect(hexToInt('#f00')).toBe(0xff0000)
})
})
describe('parseToRgb', () => {
it('parses #hex', () => {
expect(parseToRgb('#ff0000')).toEqual({ r: 255, g: 0, b: 0 })

View File

@@ -82,11 +82,6 @@ export function hexToRgb(hex: string): RGB {
return { r, g, b }
}
export function hexToInt(hex: string): number {
const { r, g, b } = hexToRgb(hex)
return (r << 16) | (g << 8) | b
}
export function rgbToHex({ r, g, b }: RGB): string {
const toHex = (n: number) =>
Math.max(0, Math.min(255, Math.round(n)))