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
3 changed files with 418 additions and 0 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)
})
})