Testing your integration
XenolithGraph renders through WebGL, and jsdom has no WebGL. Historically that meant hosts could
not unit-test anything that touches the editor — the component failed to mount before the first
assertion ran. @xenolithengine/graph-test-utils closes that gap: it stubs the browser
boundary (canvas contexts, ResizeObserver, requestAnimationFrame), so the real editor —
real XenolithEditor.init, real PIXI renderer init, real command bus — boots inside jsdom.
Two levels of testing are supported:
- Real editor —
renderEditorToDOM()/renderXenolithToDOM(): full boot, graph mutations, events, serialization. What you assert on is the same code your users run. - Logic level —
FakeEditor(subpath/fake): real core primitives (Graph, CommandBus, EventEmitter, NodeRegistry) with zero rendering. Fast suites for graph logic that never need a DOM.
Pointer/keyboard interaction and rendered pixels still belong to the Playwright e2e suite — the stubs prove boot and state plumbing, not visuals.
Install
pnpm add -D @xenolithengine/graph-test-utilsThe kit has no runtime dependencies of its own; @xenolithengine/graph-editor is a peer, and
react / react-dom / @xenolithengine/graph-react are optional peers used only by the
/react subpath.
Boot the real editor
Set the jsdom environment on the files that mount the editor — a docblock keeps the rest of your suite fast:
// @vitest-environment jsdomimport { renderEditorToDOM } from '@xenolithengine/graph-test-utils'
it('connects two nodes', async () => { const { editor, unmount } = await renderEditorToDOM()
editor.registry.register({ type: 'Number', title: 'Number', category: 'data', pins: [ { kind: 'data', direction: 'in', type: 'float', label: 'In' }, { kind: 'data', direction: 'out', type: 'float', label: 'Out' }, ], }) const a = editor.insertNode('Number', { x: 0, y: 0 })! const b = editor.insertNode('Number', { x: 200, y: 0 })! editor.connect(a, 1, b, 0)
expect(editor.toJSON().edges).toHaveLength(1) unmount()})renderEditorToDOM() installs mockPixi() for you and undoes it on unmount(). Pass a
host element if your test needs its own container.
Manual mock control
When you need deterministic frame control, install the mock yourself — renderEditorToDOM()
reuses it instead of installing its own:
import { mockPixi, renderEditorToDOM } from '@xenolithengine/graph-test-utils'
const mock = mockPixi()try { const { editor, unmount } = await renderEditorToDOM() editor.insertNode('Number', { x: 0, y: 0 })
mock.flushFrames() // run queued rAF callbacks (render ticks) on demand expect(mock.stats['2d']).toBeGreaterThan(0) // introspection: contexts handed out
unmount() // does NOT restore a mock it doesn't own} finally { mock.restore()}requestAnimationFrame is a manual queue by default: callbacks wait until
mock.flushFrames() runs them (one generation per call), so the ticker never churns in the
background. Pass { raf: 'native' } to keep the environment’s own rAF.
One ordering rule matters: install the mock before the first editor boot in a test file. PIXI memoizes its WebGL-support probe per worker; if an unmocked boot runs first, the cached “no WebGL” verdict sticks and later boots silently fall back to the Canvas2D renderer.
React components
The /react subpath mounts the real <XenolithGraph> adapter — no createEditorBinding
mocks, the actual component with the actual editor underneath:
// @vitest-environment jsdomimport { renderXenolithToDOM } from '@xenolithengine/graph-test-utils/react'
it('mounts the adapter and reports the editor', async () => { const { editor, container, unmount } = await renderXenolithToDOM() expect(container.querySelector('canvas')).toBeTruthy() // editor is a real XenolithEditor — drive it like the host does unmount()})It uses react-dom/client + React’s act() directly, so there is no @testing-library
dependency at runtime — wrap it in your own helpers if you use one. Pass
{ strictMode: true } to mount under <React.StrictMode> and exercise double-invoke
behaviour.
Logic-level tests with FakeEditor
For graph logic that needs neither DOM nor rendering, /fake gives you real core objects with
undo/redo semantics included:
import { FakeEditor } from '@xenolithengine/graph-test-utils/fake'
it('remove cascades edges and undo restores both', () => { const ed = new FakeEditor().register({ type: 'Number', title: 'Number', pins: [ { kind: 'data', direction: 'in', type: 'float', label: 'In' }, { kind: 'data', direction: 'out', type: 'float', label: 'Out' }, ], }) const a = ed.insertNode('Number', { x: 0, y: 0 })! const b = ed.insertNode('Number', { x: 1, y: 0 })! ed.connect(a, 1, b, 0)
ed.removeNode(b.id) expect([...ed.graph.edges()]).toHaveLength(0)
ed.history.undo() expect([...ed.graph.edges()]).toHaveLength(1)})FakeEditor.toJSON() returns a plain snapshot for assertions — it is not the
xenolith.v1 interchange format and must not be fed to editor.loadJSON.
What the stubs do and don’t prove
| Concern | Covered |
|---|---|
| Editor boot, schema registration, mutations, undo/redo, events, serialization | ✅ real code paths |
| WebGL renderer selection & init sequence | ✅ (stubbed GL context) |
| Rendered output, hit-testing, pointer gestures | ❌ use Playwright e2e |
| GPU-side behaviour (filters, texture atlases) | ❌ stubbed as no-ops |
The GL stub is permissive by design — it answers every query with a plausible value. That is what makes a real boot possible; it also means a test can’t assert on visual correctness. Keep pixel-level trust in the e2e suite.