test: add Preview3D execution e2e and camera restore helpers

- Pipeline fixture with backend node check, workflow seeding, camera helpers
- Extract ComfyPage.waitForReady for post-reload readiness
- NodeReference helpers for widget and stored property reads
- WorkflowHelper waitForWorkflowIdle optional error message
- preview3dCameraState utilities and unit tests
- perfReporter: guard test-results readdir when missing
This commit is contained in:
Kelly Yang
2026-04-11 19:20:15 -07:00
parent 84413b2b73
commit 732731a8a3
8 changed files with 404 additions and 176 deletions

View File

@@ -315,13 +315,28 @@ export class ComfyPage {
await this.goto()
await this.page.waitForFunction(() => document.fonts.ready)
await this.waitForReady()
}
/**
* Wait until the graph view is interactive (extension manager + loading mask).
* Use after reload or any navigation that skips {@link setup}.
*/
async waitForReady(options: { timeout?: number } = {}): Promise<void> {
const waitOpts =
options.timeout !== undefined ? { timeout: options.timeout } : undefined
await this.page.waitForFunction(
() =>
// window.app => GraphCanvas ready
// window.app.extensionManager => GraphView ready
window.app && window.app.extensionManager
Boolean(window.app?.extensionManager),
undefined,
waitOpts
)
await this.page.locator('.p-blockui-mask').waitFor({ state: 'hidden' })
await this.page.locator('.p-blockui-mask').waitFor({
state: 'hidden',
...(options.timeout !== undefined ? { timeout: options.timeout } : {})
})
await this.nextFrame()
}

View File

@@ -1,65 +1,31 @@
import { expect } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { assetPath } from '@e2e/fixtures/utils/paths'
import type { Preview3dE2eCameraState as CameraState } from '@e2e/fixtures/utils/preview3dCameraState'
import {
isPreview3dE2eCameraState as isCameraState,
preview3dCameraStatesDiffer as cameraStatesDiffer
} from '@e2e/fixtures/utils/preview3dCameraState'
import { Load3DHelper } from '@e2e/tests/load3d/Load3DHelper'
export async function waitForAppBootstrapped(
export type { CameraState }
export { isCameraState }
export async function backendHasPreview3DNodes(
comfyPage: ComfyPage
): Promise<void> {
await comfyPage.page.waitForFunction(
() => Boolean(window.app?.extensionManager),
undefined,
{ timeout: 30_000 }
)
await comfyPage.page.waitForSelector('.p-blockui-mask', {
state: 'hidden',
timeout: 30_000
): Promise<boolean> {
const resp = await comfyPage.request.get(`${comfyPage.url}/object_info`, {
failOnStatusCode: false
})
await comfyPage.nextFrame()
}
export function vecClose(
a: { x: number; y: number; z: number },
b: { x: number; y: number; z: number },
eps: number
): boolean {
if (!resp.ok()) return false
const data: unknown = await resp.json()
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)
typeof data === 'object' &&
data !== null &&
'Load3D' in data &&
'Preview3D' in data
)
}
@@ -80,41 +46,133 @@ export class Preview3DPipelineContext {
}
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)
const ref = await this.comfyPage.nodeOps.getNodeRefById(nodeId)
const v = await ref.getWidgetValueByName('model_file')
return typeof v === 'string' ? v : ''
}
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)
const ref = await this.comfyPage.nodeOps.getNodeRefById(nodeId)
const v = await ref.getStoredPropertyValue('Last Time Model File')
return typeof v === 'string' ? v : ''
}
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)
const ref = await this.comfyPage.nodeOps.getNodeRefById(nodeId)
const cfg = await ref.getStoredPropertyValue('Camera Config')
if (cfg === null || typeof cfg !== 'object') return null
const state = Reflect.get(cfg, 'state')
return state ?? null
}
}
export 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)
)
.toContain('cube.obj')
await load3d.waitForModelLoaded()
await comfyPage.nextFrame()
}
export async function setNonDefaultLoad3dCameraState(
pipeline: Preview3DPipelineContext
): Promise<void> {
const { comfyPage, load3d } = pipeline
const initialCamera = await pipeline.getCameraStateFromProperties(
Preview3DPipelineContext.loadNodeId
)
const box = await load3d.canvas.boundingBox()
expect(box, 'Load3D canvas bounding box should exist').not.toBeNull()
const w = box!.width
const h = box!.height
await load3d.canvas.dragTo(load3d.canvas, {
sourcePosition: { x: w / 2, y: h / 2 },
targetPosition: { x: w / 2 + 80, y: h / 2 + 20 },
steps: 10,
force: true
})
await comfyPage.nextFrame()
await expect
.poll(
async () => {
const current = await pipeline.getCameraStateFromProperties(
Preview3DPipelineContext.loadNodeId
)
if (current === null) return false
if (initialCamera === null) return true
return cameraStatesDiffer(current, initialCamera, 1e-4)
},
{
timeout: 10_000,
message:
'Load3D camera state should change after orbit drag (see cameraStatesDiffer)'
}
)
.toBe(true)
}
export async function nudgePreview3dCameraIntoProperties(
pipeline: Preview3DPipelineContext
): Promise<void> {
const { comfyPage, preview3d } = pipeline
const box = await preview3d.canvas.boundingBox()
expect(box, 'Preview3D canvas bounding box should exist').not.toBeNull()
const w = box!.width
const h = box!.height
await preview3d.canvas.dragTo(preview3d.canvas, {
sourcePosition: { x: w / 2, y: h / 2 },
targetPosition: { x: w / 2 - 60, y: h / 2 + 20 },
steps: 10,
force: true
})
await comfyPage.nextFrame()
}
export 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'
)
}
export const preview3dPipelineTest = comfyPageFixture.extend<{
preview3dPipeline: Preview3DPipelineContext
}>({
preview3dPipeline: async ({ comfyPage }, use) => {
preview3dPipeline: async ({ comfyPage }, use, testInfo) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
const hasPreview3dNodes = await backendHasPreview3DNodes(comfyPage)
if (!hasPreview3dNodes) {
testInfo.skip(
true,
'Requires ComfyUI backend with Load3D and Preview3D nodes (object_info)'
)
await use(new Preview3DPipelineContext(comfyPage))
await comfyPage.workflow.setupWorkflowsDirectory({})
return
}
await comfyPage.workflow.loadWorkflow('3d/preview3d_pipeline')
await comfyPage.vueNodes.waitForNodes()

View File

@@ -165,14 +165,27 @@ export class WorkflowHelper {
})
}
async waitForWorkflowIdle(timeout = 5000): Promise<void> {
await this.comfyPage.page.waitForFunction(
() =>
!(window.app?.extensionManager as WorkspaceStore | undefined)?.workflow
?.isBusy,
undefined,
{ timeout }
)
async waitForWorkflowIdle(
timeout = 5000,
options?: { message?: string }
): Promise<void> {
try {
await this.comfyPage.page.waitForFunction(
() =>
!(window.app?.extensionManager as WorkspaceStore | undefined)
?.workflow?.isBusy,
undefined,
{ timeout }
)
} catch (err) {
if (options?.message) {
throw new Error(
`${options.message} (timed out after ${timeout}ms waiting for workflow idle)`,
{ cause: err }
)
}
throw err
}
}
async getExportedWorkflow(options: { api: true }): Promise<ComfyApiWorkflow>

View File

@@ -345,6 +345,32 @@ export class NodeReference {
[this.id, prop] as const
)
}
async getWidgetValueByName(name: string): Promise<unknown> {
return await this.comfyPage.page.evaluate(
([id, name]) => {
const node = window.app!.graph!.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
const widget = node.widgets?.find((w) => w.name === name)
return widget?.value
},
[this.id, name] as const
)
}
async getStoredPropertyValue(key: string): Promise<unknown> {
return await this.comfyPage.page.evaluate(
([id, key]) => {
const node = window.app!.graph!.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
const bag = node.properties
if (bag === null || typeof bag !== 'object') return undefined
return Reflect.get(bag, key)
},
[this.id, key] as const
)
}
async getOutput(index: number) {
return new NodeSlotReference('output', index, this)
}

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import {
PREVIEW3D_CAMERA_AXIS_RESTORE_EPS,
preview3dCameraStatesDiffer,
preview3dRestoreCameraStatesMatch
} from '@e2e/fixtures/utils/preview3dCameraState'
const base = {
cameraType: 'perspective',
position: { x: 1, y: 2, z: 3 },
target: { x: 0, y: 0, z: 0 },
zoom: 1
}
describe('preview3dRestoreCameraStatesMatch', () => {
it('returns true for identical states', () => {
expect(preview3dRestoreCameraStatesMatch(base, { ...base })).toBe(true)
})
it('returns false for invalid payloads', () => {
expect(preview3dRestoreCameraStatesMatch(null, base)).toBe(false)
expect(preview3dRestoreCameraStatesMatch(base, { position: 'nope' })).toBe(
false
)
})
it('returns false when cameraType differs', () => {
expect(
preview3dRestoreCameraStatesMatch(base, { ...base, cameraType: 'ortho' })
).toBe(false)
})
it('accepts axis drift within PREVIEW3D_CAMERA_AXIS_RESTORE_EPS', () => {
const drifted = {
...base,
position: {
x: base.position.x + PREVIEW3D_CAMERA_AXIS_RESTORE_EPS * 0.9,
y: base.position.y,
z: base.position.z
}
}
expect(preview3dRestoreCameraStatesMatch(base, drifted)).toBe(true)
})
it('rejects axis drift beyond PREVIEW3D_CAMERA_AXIS_RESTORE_EPS', () => {
const drifted = {
...base,
position: {
x: base.position.x + PREVIEW3D_CAMERA_AXIS_RESTORE_EPS * 1.1,
y: base.position.y,
z: base.position.z
}
}
expect(preview3dRestoreCameraStatesMatch(base, drifted)).toBe(false)
})
})
describe('preview3dCameraStatesDiffer', () => {
it('treats missing typed state as different', () => {
expect(preview3dCameraStatesDiffer(null, base, 1e-4)).toBe(true)
})
it('returns false when states are equal within eps', () => {
expect(preview3dCameraStatesDiffer(base, { ...base }, 1e-4)).toBe(false)
})
})

View File

@@ -0,0 +1,91 @@
export interface Preview3dE2eCameraState {
position: { x: number; y: number; z: number }
target: { x: number; y: number; z: number }
zoom?: number
cameraType?: string
}
function isVec3(v: unknown): v is { x: number; y: number; z: number } {
if (v === null || typeof v !== 'object') return false
const x = Reflect.get(v, 'x')
const y = Reflect.get(v, 'y')
const z = Reflect.get(v, 'z')
return typeof x === 'number' && typeof y === 'number' && typeof z === 'number'
}
export function isPreview3dE2eCameraState(
v: unknown
): v is Preview3dE2eCameraState {
if (v === null || typeof v !== 'object') return false
const position = Reflect.get(v, 'position')
const target = Reflect.get(v, 'target')
return isVec3(position) && isVec3(target)
}
function vecMaxAbsDelta(
a: { x: number; y: number; z: number },
b: { x: number; y: number; z: number }
): number {
return Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y), Math.abs(a.z - b.z))
}
/**
* Max abs error per position/target axis when comparing restored Preview3D
* camera state (same order of magnitude as the former 2e-2 poll tolerance).
*/
export const PREVIEW3D_CAMERA_AXIS_RESTORE_EPS = 0.02
/**
* Max abs zoom error when comparing restored Preview3D state (aligned with
* Playwright `toBeCloseTo(..., 5)`-style checks on typical zoom magnitudes).
*/
export const PREVIEW3D_CAMERA_ZOOM_RESTORE_EPS = 1e-4
export function preview3dRestoreCameraStatesMatch(
a: unknown,
b: unknown
): boolean {
if (!isPreview3dE2eCameraState(a) || !isPreview3dE2eCameraState(b)) {
return false
}
if (a.cameraType !== b.cameraType) return false
const zoomA = typeof a.zoom === 'number' ? a.zoom : 0
const zoomB = typeof b.zoom === 'number' ? b.zoom : 0
if (Math.abs(zoomA - zoomB) > PREVIEW3D_CAMERA_ZOOM_RESTORE_EPS) {
return false
}
return (
vecMaxAbsDelta(a.position, b.position) <=
PREVIEW3D_CAMERA_AXIS_RESTORE_EPS &&
vecMaxAbsDelta(a.target, b.target) <= PREVIEW3D_CAMERA_AXIS_RESTORE_EPS
)
}
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 preview3dCameraStatesDiffer(
a: unknown,
b: unknown,
eps: number
): boolean {
if (!isPreview3dE2eCameraState(a) || !isPreview3dE2eCameraState(b)) {
return true
}
if (a.cameraType !== b.cameraType) return true
const zoomA = typeof a.zoom === 'number' ? a.zoom : 0
const zoomB = typeof b.zoom === 'number' ? b.zoom : 0
if (Math.abs(zoomA - zoomB) > eps) return true
return !(
vecClose(a.position, b.position, eps) && vecClose(a.target, b.target, eps)
)
}

View File

@@ -1,4 +1,10 @@
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs'
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
writeFileSync
} from 'fs'
import { join } from 'path'
import type { PerfMeasurement } from '@e2e/fixtures/helpers/PerformanceHelper'
@@ -56,7 +62,12 @@ export function writePerfReport(
gitSha = process.env.GITHUB_SHA ?? 'local',
branch = process.env.GITHUB_HEAD_REF ?? 'local'
) {
if (!readdirSync('test-results', { withFileTypes: true }).length) return
if (
!existsSync('test-results') ||
!readdirSync('test-results', { withFileTypes: true }).length
) {
return
}
let tempFiles: string[]
try {

View File

@@ -1,85 +1,20 @@
import { expect } from '@playwright/test'
import {
cameraStatesClose,
alignPreview3dWorkflowUiSettings,
nudgePreview3dCameraIntoProperties,
preview3dPipelineTest as test,
Preview3DPipelineContext,
waitForAppBootstrapped
seedLoad3dWithCubeObj,
setNonDefaultLoad3dCameraState
} from '@e2e/fixtures/helpers/Preview3DPipelineFixture'
import { assetPath } from '@e2e/fixtures/utils/paths'
import {
PREVIEW3D_CAMERA_AXIS_RESTORE_EPS,
PREVIEW3D_CAMERA_ZOOM_RESTORE_EPS,
preview3dRestoreCameraStatesMatch
} from '@e2e/fixtures/utils/preview3dCameraState'
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)
)
.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 w = box!.width
const h = box!.height
await load3d.canvas.dragTo(load3d.canvas, {
sourcePosition: { x: w / 2, y: h / 2 },
targetPosition: { x: w / 2 + 80, y: h / 2 + 20 },
steps: 10,
force: true
})
await comfyPage.nextFrame()
await expect
.poll(() =>
pipeline.getCameraStateFromProperties(Preview3DPipelineContext.loadNodeId)
)
.not.toBeNull()
}
async function nudgePreview3dCameraIntoProperties(
pipeline: Preview3DPipelineContext
): Promise<void> {
const { comfyPage, preview3d } = pipeline
const box = await preview3d.canvas.boundingBox()
expect(box, 'Preview3D canvas bounding box should exist').not.toBeNull()
const w = box!.width
const h = box!.height
await preview3d.canvas.dragTo(preview3d.canvas, {
sourcePosition: { x: w / 2, y: h / 2 },
targetPosition: { x: w / 2 - 60, y: h / 2 + 20 },
steps: 10,
force: true
})
await comfyPage.nextFrame()
}
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.describe('Preview3D execution flow', { tag: ['@slow'] }, () => {
test('Preview3D loads model from execution output', async ({
preview3dPipeline: pipeline
}) => {
@@ -88,7 +23,10 @@ test.describe('Preview3D execution flow', { tag: ['@node', '@slow'] }, () => {
await seedLoad3dWithCubeObj(pipeline)
await pipeline.comfyPage.command.executeCommand('Comfy.QueuePrompt')
await pipeline.comfyPage.workflow.waitForWorkflowIdle(90_000)
await pipeline.comfyPage.workflow.waitForWorkflowIdle(90_000, {
message:
'QueuePrompt did not finish: backend needs Load3D and Preview3D nodes and a working execution environment'
})
await expect
.poll(() =>
@@ -99,10 +37,9 @@ test.describe('Preview3D execution flow', { tag: ['@node', '@slow'] }, () => {
const modelPath = await pipeline.getModelFileWidgetValue(
Preview3DPipelineContext.previewNodeId
)
await expect(
modelPath.length,
'Preview3D model path populated'
).toBeGreaterThan(4)
expect(modelPath.length, 'Preview3D model path populated').toBeGreaterThan(
4
)
await expect
.poll(() =>
@@ -129,7 +66,10 @@ test.describe('Preview3D execution flow', { tag: ['@node', '@slow'] }, () => {
await setNonDefaultLoad3dCameraState(pipeline)
await pipeline.comfyPage.command.executeCommand('Comfy.QueuePrompt')
await pipeline.comfyPage.workflow.waitForWorkflowIdle(90_000)
await pipeline.comfyPage.workflow.waitForWorkflowIdle(90_000, {
message:
'QueuePrompt did not finish: backend needs Load3D and Preview3D nodes and a working execution environment'
})
await expect
.poll(() =>
@@ -164,15 +104,18 @@ test.describe('Preview3D execution flow', { tag: ['@node', '@slow'] }, () => {
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 pipeline.comfyPage.page.reload({ waitUntil: 'domcontentloaded' })
await pipeline.comfyPage.waitForReady({ timeout: 30_000 })
await alignPreview3dWorkflowUiSettings(pipeline)
const tab = pipeline.comfyPage.menu.workflowsTab
await tab.open()
await tab.getPersistedItem(workflowName).click()
await pipeline.comfyPage.workflow.waitForWorkflowIdle(30_000)
await pipeline.comfyPage.workflow.waitForWorkflowIdle(30_000, {
message:
'Workflow did not settle after opening saved workflow from sidebar (Preview3D restore test)'
})
await pipeline.comfyPage.vueNodes.waitForNodes()
await expect
@@ -197,14 +140,18 @@ test.describe('Preview3D execution flow', { tag: ['@node', '@slow'] }, () => {
.toBe(true)
await expect
.poll(async () =>
cameraStatesClose(
await pipeline.getCameraStateFromProperties(
Preview3DPipelineContext.previewNodeId
.poll(
async () =>
preview3dRestoreCameraStatesMatch(
await pipeline.getCameraStateFromProperties(
Preview3DPipelineContext.previewNodeId
),
savedCamera
),
savedCamera,
2e-2
)
{
timeout: 15_000,
message: `Preview3D camera after reload should match saved state (axis max delta ≤ ${PREVIEW3D_CAMERA_AXIS_RESTORE_EPS}, zoom delta ≤ ${PREVIEW3D_CAMERA_ZOOM_RESTORE_EPS}; see preview3dCameraState.test.ts)`
}
)
.toBe(true)
})