Testing a 3D Editor: E2E in a WebGL World
How we built reliable Playwright tests for a canvas-based CAD tool.
End-to-end testing for web apps usually means clicking buttons and asserting on DOM elements. But what happens when your entire UI is a WebGL canvas? There are no buttons to click, no text to assert on. Just triangles and shaders.
This is the testing challenge we face with Modelcore. Here's how we built a reliable E2E test suite for a browser-based 3D editor.
The fundamental problem: no DOM to query
In a typical web app, you'd write something like:
await page.click('[data-testid="submit-button"]')
await expect(page.locator('.success-message')).toBeVisible()
In a 3D editor, the user clicks on a face, drags an edge, or draws a rectangle. None of these are DOM elements. They're rendered geometry inside a <canvas> tag.
We can't use CSS selectors. We can't use getByText. We can't even use screenshot diffing reliably because camera angle, lighting, and anti-aliasing all affect the output.
The bridge: exposing 3D internals to Playwright
Our solution is the E2ETestBridge: a React component that exposes rendering internals to the test runner via a global object.
This gives tests the ability to:
- Project 3D positions to screen coordinates. "Click at world position (2, 0, 3)" becomes a specific canvas pixel.
- Query geometry by ID. "Find the center of face X" returns its world position.
- Set deterministic camera positions. Tests start with a known view so coordinates are predictable.
- Query selection state. Assert on faces, edges, and nodes selected, not on visual appearance.
In practice we also include a fallback path: if window.__E2E__ isn't available for some reason, tests can still project points using the live camera + renderer exposed via app state. The bridge is the preferred interface, but the tests stay resilient if it fails to mount.
Deterministic camera positioning
One of the biggest sources of flakiness was camera state. Tests would pass locally but fail in CI because the viewport size differed, or because the default camera position changed.
We solved this by adding a helper that positions the camera in a known orientation. Screen coordinates become predictable across environments.
State-based assertions over timeouts
The naive approach to testing is:
await clickCanvas(page, 100, 200)
await page.waitForTimeout(500) // hope the operation finished
expect(await getEdgeCount(page)).toBe(4)
This is slow and flaky. Instead, we use state-based waits:
await clickCanvas(page, 100, 200)
await waitForEdgeCount(page, 4, { timeout: 5000 })
The test waits until the condition is true or times out. No arbitrary delays, no race conditions.
We have these for most observable state:
waitForEdgeCount,waitForFaceCount,waitForSolidCountwaitForSelectedFaceCount,waitForContextDepthwaitForActiveTool
World-coordinate clicking
The core helper for interaction is clickWorldPoint:
export async function clickWorldPoint(page: Page, world: { x: number; y: number; z: number }) {
const screen = await page.evaluate((w) => {
return window.__E2E__?.projectToScreen?.(w.x, w.y, w.z)
}, world)
await clickCanvas(page, screen.x, screen.y)
}
Tests read like geometry operations:
// Draw a rectangle from (0,0,0) to (2,0,2)
await selectTool(page, 'rectangle')
await clickWorldPoint(page, { x: 0, y: 0, z: 0 })
await clickWorldPoint(page, { x: 2, y: 0, z: 2 })
await waitForFaceCount(page, 1)
The test suite today
We went from 4 tests to 15 spec files with 30+ helpers:
basic-modeling.spec.ts: Line and rectangle drawingselection.spec.ts: Click-to-select, drag selectionundo-redo.spec.ts: Command historycontext-editing.spec.ts: Component and solid edit modesboolean-ops.spec.ts: Union, subtract, intersectpush-pull-hole.spec.ts: Smart Extrude workflowsinference-workflow.spec.ts: Snapping behaviorzoom-to-cursor.spec.ts: Camera/zoom correctness under interaction
Each test follows the same pattern: set up camera, interact via world coordinates, assert on geometry state.
What this enables
With this foundation in place:
- CI catches regressions. Every PR runs the full test suite.
- Refactors are safe. We can restructure internal code and verify behavior doesn't change.
- New features get tested. Each spec becomes documentation of expected behavior.
The investment in E2E infrastructure pays dividends. It's not glamorous work, but it's what makes a 3D editor reliable.
Lessons learned
- Don't screenshot-diff 3D. It's tempting but fragile. Assert on state, not pixels.
- Expose internals explicitly. The test bridge is ugly but necessary. Own it.
- Determinism is everything. Camera, viewport, and timing must be controlled.
- State-based waits beat timeouts. Always wait for a condition, not a duration.
Building E2E tests for a canvas-based app isn't conventional. But with the right bridge between your rendering engine and your test runner, it's entirely tractable.
For more on how we use Playwright and how determinism makes testing reliable, see Deterministic IDs: Why Command Replay Works.