Files
ComfyUI_frontend/browser_tests/fixtures/utils/preview3dCameraState.ts
Kelly Yang 29d6263fb9 test: add Preview3D execution flow E2E tests (#11014)
## Summary
Adds Playwright coverage for `Preview3D execution` and persistence :
real queue execution against a `Load3D → Preview3D` workflow, plus `save
/ full reload / reopen` from the sidebar.

## What these tests do
**Fixture** (every test)
Turns on Vue Nodes, uses the sidebar for workflows, loads a Load3D →
Preview3D workflow, waits for nodes, then clears saved workflows after
the test so runs stay isolated.

**Test 1 — execution updates Preview3D**
Uploads `cube.obj`(the existing test file in the merged version) to
Load3D, runs `Queue Prompt`, then checks that Preview3D’s model_file and
Last Time Model File match and the canvas has non-zero size. No 3D
screenshots (GPU flakiness).

**Test 2 — persistence after reload**
Same upload + queue, then saves the workflow, reloads the page,
re-applies the same UI settings, opens the saved workflow, and checks
the same model path and camera state (with a small numeric tolerance).

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Adds new slow, WebGL-dependent E2E tests and fixtures, which can
increase CI runtime and introduce flakiness due to timing/graphics
variability, but does not change production logic.
> 
> **Overview**
> Adds a new `Load3D → Preview3D` workflow asset and a dedicated
Playwright fixture (`Preview3DPipelineFixture`) to drive real queue
execution, upload a 3D model, and interact with the 3D canvases (orbit
drags) while asserting `model_file`/`Last Time Model File` and camera
state via node properties.
> 
> Introduces camera-state comparison helpers with explicit numeric
tolerances, and adds a new `preview3dExecution.spec.ts` suite that
validates (1) Preview3D updates from execution output and (2) model +
camera persistence across save, full page reload, and reopening the
workflow from the sidebar.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
5f54b0f650. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11014-test-add-Preview3D-execution-flow-E2E-tests-33e6d73d3650811fa298c364ae196606)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-04-16 18:08:04 -04:00

88 lines
2.6 KiB
TypeScript

interface Preview3dCameraStatePayload {
position: { x: number; y: number; z: number }
target: { x: number; y: number; z: number }
zoom?: number
cameraType?: string
}
type Vec3 = { x: number; y: number; z: number }
function isVec3(v: unknown): v is Vec3 {
if (v === null || typeof v !== 'object') return false
const r = v as Record<string, unknown>
return (
'x' in v &&
typeof r.x === 'number' &&
'y' in v &&
typeof r.y === 'number' &&
'z' in v &&
typeof r.z === 'number'
)
}
function isPreview3dCameraStatePayload(
v: unknown
): v is Preview3dCameraStatePayload {
if (v === null || typeof v !== 'object') return false
if (!('position' in v) || !('target' in v)) return false
const r = v as Record<string, unknown>
return isVec3(r.position) && isVec3(r.target)
}
function vecMaxAbsDelta(a: Vec3, b: Vec3): number {
return Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y), Math.abs(a.z - b.z))
}
function vecWithinEps(a: Vec3, b: Vec3, eps: number): boolean {
return vecMaxAbsDelta(a, b) <= eps
}
/**
* 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 (!isPreview3dCameraStatePayload(a) || !isPreview3dCameraStatePayload(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 (
vecWithinEps(a.position, b.position, PREVIEW3D_CAMERA_AXIS_RESTORE_EPS) &&
vecWithinEps(a.target, b.target, PREVIEW3D_CAMERA_AXIS_RESTORE_EPS)
)
}
export function preview3dCameraStatesDiffer(
a: unknown,
b: unknown,
eps: number
): boolean {
if (!isPreview3dCameraStatePayload(a) || !isPreview3dCameraStatePayload(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 !(
vecWithinEps(a.position, b.position, eps) &&
vecWithinEps(a.target, b.target, eps)
)
}