Five tiers, four of which run in Node and need neither a build nor a browser.
| Tier | Command | Environment | Covers |
|---|---|---|---|
| Unit | npm run test:unit |
node |
The parsers in src/parse.ts, CSS_COLORS, and importing with no DOM |
| Elements | npm run test:elements |
jsdom |
Attribute and property surface, with no engine created |
| Integration | npm run test:integration |
jsdom |
A real AppBase on NullGraphicsDevice, via <pc-app backend="null"> |
| Examples | npm run test:examples |
node* |
examples/ as a set: the catalog, and the markup of every page |
| Browser | (not yet implemented) | Chromium | The built dist/ bundle and the example pages |
npm test runs the four Node tiers. npm run test:watch watches them, and
npm run test:coverage adds a v8 report under coverage/.
The Examples tier is the odd one out: it tests the examples rather than the library. npm test still
needs no build and no browser, but example-markup.test.ts needs a DOM and so carries a
// @vitest-environment jsdom docblock — hence the * above. example-list.test.ts, the rest of the
tier, still reads its sources as text and imports nothing.
example-list.test.ts exists because examples/js/example-list.mjs is the only name a reader sees in
the sidebar while each page carries its own <title>, and those two drifted apart across a third of
the examples before it was added. It also fails on an example page that the catalog never lists — a
page nothing imports, which no other tier can see.
example-markup.test.ts parses every page and checks it against the registered elements, because
nothing else does: npm run lint and npm run fmt are both scoped to examples/js and
examples/assets/scripts, so no tool reads examples/*.html or the inline <script type="module">
blocks in a dozen of them. Two classes of defect had accumulated unseen — an attribute the element
does not accept, which is dropped in silence, and a start tag that is never closed, which browsers
recover from by folding the closing tag into an attribute name. Both are asserted per page, so a
failure names the file and the attribute.
Its one hand-maintained list is READ_ONCE_ATTRIBUTES, for attributes an element reads in
connectedCallback rather than observing: those are absent from observedAttributes, and the
generated custom-elements manifest that does carry them is a build artifact this tier cannot use.
Adding such an attribute means adding it there too. A guard test asserts the attribute sets come back
populated, so a refactor that moved declarations off observedAttributes fails loudly instead of
passing vacuously.
<pc-app> accepts backend="null", which src/app.ts maps to PlayCanvas's NullGraphicsDevice —
a pure-JS GraphicsDevice that never calls getContext. A complete application, entity hierarchy
and component set therefore boots in jsdom in a few milliseconds with no GPU.
Always use backend="null". Any other backend makes createGraphicsDevice construct a
WebglGraphicsDevice, which throws synchronously because jsdom's getContext returns null. That
throw escapes the engine's per-attempt .catch() (the attempt is invoked as the argument to
Promise.resolve, before the handler is attached), so the promise rejects instead of falling back to
the null device, and connectedCallback turns it into an unhandled rejection. bootApp() enforces
this.
Nothing ever rejects. No AsyncElement rejects its ready promise. A misplaced element logs a
console.warn and returns a promise that never settles. Two consequences:
- Every wait goes through
readyWithin(), never a bareawait element.ready(). On timeout it reports connection state,closestApp,closestEntity,_hierarchyReadyand every warning seen so far, instead of a bare 10-second timeout. - Negative cases are asserted with the console guard or
expectNeverReady(), never withexpect(...).toThrow().
Warnings are assertions. Every suite that mounts anything calls useGuard(). Tests claim the
messages they expect with warnings.expect(pattern); anything left unclaimed fails the test. This is
what makes the misuse paths a contract — add a warning to the library and exactly the tests that
walk that path break. Use warnings.allow(...) for messages that are expected but not the point of
the test, and ignoreRest() only with a comment saying why.
The guard also records uncaught exceptions and unhandled rejections. Async connectedCallbacks and
custom element reactions are reported, not propagated, so guard.uncaught is the only way to see
them. Do not install a global handler that swallows them — one such rejection is how the
sound-slot.ts teardown bug was found.
Rejections are captured on process, not on window. jsdom does not dispatch
unhandledrejection on window — measured: a rejected promise reaches
process.on('unhandledRejection') and nothing else. A window-only listener records nothing, which
silently turns expect(uncaught.seen).toEqual([]) into a vacuous assertion. Synchronous throws
inside a custom element reaction are reported on window, so the guard listens on both.
One consequence: a test cannot expect an unhandled rejection. Vitest fails a file on any unhandled
rejection whether or not a test claims it, so a defect that only manifests as one (#313) is recorded
as an it.todo with an explanation rather than pinned executably.
The guard asserts from a cleanup function returned by beforeEach, not from an afterEach. Do
not "simplify" that. Vitest's measured hook order is: test body → afterEach registered in the
describe → afterEach registered at module scope (where dom.ts unmounts the DOM and destroys the
app) → cleanup functions returned from beforeEach. An afterEach would therefore assert before
teardown, so anything teardown emits would escape — and since beforeEach resets the recorders, a
teardown warning would vanish silently rather than fail. Teardown is where this library is most
fragile, so the assertion has to come last. Spy restoration is left to restoreMocks: true in
vitest.config.ts, which Vitest applies after the cleanup, keeping the spies live throughout
teardown.
Mount detached, insert once. mount() builds the subtree on a detached container and appends it
in a single operation, so every connectedCallback runs with the full subtree present. That matches
production, where pwc.mjs is a deferred module script and elements upgrade with their children
already parsed. Filling an already-connected container would connect <pc-app> before its children
and its boot queries would find nothing.
Settle before tearing down. <pc-app> resolves its own ready promise from inside the
app.preload() callback, before its descendants' async connectedCallbacks have finished. So
bootApp() awaits settle() over the whole subtree, not just the app. Removing a tree in that
window makes _addComponent and addSlot dereference nulls.
Per-file isolation is mandatory. src/ performs 27 customElements.define() calls at module
scope and a second define for the same tag throws. Vitest's default per-file isolation gives each
file a fresh realm and module graph; never run with --no-isolate. Isolation between tests is by
fresh DOM subtree, which test/helpers/dom.ts handles in afterEach.
Clean up completely. AssetElement.get, MaterialElement.get, getEntity and the picker's
pc-entity[name=...] reverse lookup are all document-global and string-keyed, so a leaked element
silently changes another test's result. mount() calls assertDocumentClean() on entry so a leak is
attributed to the test that caused it.
When a test documents a defect, pin the actual behavior in an it(...) whose title ends
(known bug #NNN), with a comment naming the file, line and root cause, and add an it.todo(...)
stating the intended behavior beside it.
Do not use it.fails() — it passes for any failure, so it silently absorbs an unrelated
regression on the same path, and it cannot see errors that are reported rather than thrown. Do not
use bare it.skip either; it rots invisibly. This convention keeps the suite green, describes the
defect executably, and makes the fix a one-line flip.
- One test module per source module, mirroring the path:
src/components/camera-component.ts→test/integration/components/camera-component.test.ts. - Outer
describeis the tag in angle brackets —describe('<pc-camera>')— because that is how users refer to it. Use the class name only for tests of the TypeScript API surface. - Second level is the member as the user writes it:
describe('[clear-color]')for attributes,describe('#component')for properties and methods. itreads as a sentence: "falls back to the default when the value is not a finite number", not "default is returned".- Explicit imports from
vitest—globalsis off invitest.config.ts, matchingsrc/'s style. - Arrow functions in
describe/itare fine. The engine repo forbids them because Mocha exposesthis; Vitest does not, so the ban would be cargo cult here. This is a deliberate divergence. test.forovertest.each, so the case arrives typed.expect.softinside table-driven cases, so one broken attribute reports every finding in a single run. Fail fast everywhere else.- Alphabetize members, except in table-driven suites where the order comes from
observedAttributes— the source of truth. Re-sorting would make failures harder to correlate.
test/setup/dom.ts fixes exactly one real jsdom gap and deliberately leaves others alone.
jsdom has no layout engine, so clientWidth, clientHeight and getBoundingClientRect are
constant zeros. AppElement feeds clientWidth to setCanvasResolution(RESOLUTION_AUTO), so
without a stub the device comes up 0×0 and every derived aspect ratio is NaN — which passes a
naive toBeDefined() assertion. The stub goes on HTMLCanvasElement.prototype rather than per
canvas (as the engine's own tests do) because AppElement creates its canvas internally, so no test
can reach it first. test/integration/environment.test.ts asserts the exact resulting dimensions; if
that file is red, treat every other integration result as unreliable.
Not polyfilled, on purpose: ResizeObserver (unused by the engine and by src/), navigator.xr
(absent is the correct headless answer; a stub sends XrManager down paths jsdom cannot honor), and
AudioContext (absent means SoundManager.context is null, and sound slots still work). The
canvas npm package is not a dependency — it implements 2D drawing, not layout, and the null
device never asks for a context.
Integration tests use no preload assets. app.preload() short-circuits synchronously when the
asset list is empty, so bootApp() performs no I/O. An unreachable asset URL does not fail, it hangs
<pc-app> forever. When a real load is needed, use a data: URI.