Testing DSL
Ignite ships a scenario-style testing helper built on the headless runtime. It is exported as test from both ignite-element and ignite-element/xstate (and the other adapter entrypoints); import it under any name you like.
import { test as igniteTest } from 'ignite-element/xstate';import { igniteCore } from 'ignite-element/xstate';
const counter = igniteCore({ source: counterMachine, view: ({ snapshot }) => ({ isOn: snapshot.matches('on'), }), commands: ({ actor }) => ({ toggle: () => actor.send({ type: 'TOGGLE' }), }), events: (event) => ({ toggled: event<{ isOn: boolean }>(), }), effects: ({ emit, select }) => { const isOn = select((snapshot) => snapshot.matches('on')); if (!isOn.changed) return; emit({ type: 'toggled', isOn: isOn.current }); },});Scenarios
Section titled “Scenarios”Call igniteTest(component) with a runtime to get a chainable scenario. given(...) asserts the starting snapshot, when({ command, input? }) executes a command (it is async — await it), and the expect* methods assert the result of the most recent when(...).
(await igniteTest(counter).given({ value: 'off' }).when({ command: 'toggle' })) .expectSnapshot({ value: 'on' }) .expectEvent({ type: 'toggled', isOn: true });Scenario methods — every given/expect* returns the scenario so calls chain; when(...) returns a Promise of the scenario:
given(expected)— assert the current snapshot before acting. Accepts a deep-partial snapshot match or a predicate.when({ command, input? })— execute a command;inputis required for one-argument commands, omitted for no-argument commands, and optional when the command’s first argument is optional. Returns aPromiseof the scenario.expectSnapshot(expected)— assert the post-command snapshot.expectView(expected)— assert the projected view (mirrorsgetView()). Accepts a deep-partial object or a predicate.expectEvent({ type, ...fields })— assert an event was emitted, optionally matching its fields.expectEvents(expected)— assert several events by an array of{ type, ...fields }objects.expectNoEvents()— assert the command emitted nothing.getResult()— return the raw{ snapshot, events }of the lastwhen(...).
Snapshot, view, and event expectations accept a deep-partial object or a predicate function. Event expectations match flat { type, ...fields } members.
Narratives
Section titled “Narratives”When you need more than one intent in the same falsifiable user story, use narrative(name, async (narrative) => ...).
A narrative is the expected experience claim: the sequence you think a user can actually live through. It reuses the runtime’s existing record(name) story to capture observed execution evidence, and it returns the same serializable IgniteStorySnapshot that snapshotStory(story) produces as a portable receipt. There is no second recorder, alternate trace schema, graph engine, or inspection surface.
const receipt = await igniteTest(counter).narrative( 'counter recovery', async (narrative) => { narrative.given({ snapshot: { counter: { count: 0 } }, view: { count: 0, canDecrement: false }, canExecute: { decrement: false }, });
await narrative.intent({ command: 'increment', input: 2 }); narrative.checkpoint('after increment', { snapshot: { counter: { count: 2 } }, view: { count: 2, canDecrement: true }, events: [{ type: 'counter-incremented', count: 2 }], canExecute: { decrement: true }, });
// Consumer-owned facts stay ordinary test code between intents. await someExternalFixture.retry();
await narrative.intent({ command: 'decrement' }); narrative.checkpoint('after decrement', { snapshot: { counter: { count: 1 } }, }); },);Narrative methods:
narrative(name, run)— create a named expected-experience claim, run multiple ordered command steps, and return the existing{ name, trace, lifecycle, summary }receipt.narrative.given(expected)— assertion-only precondition check against the currentsnapshot, projectedview, andcanExecute(...)state.await narrative.intent({ command, input? })— run one intent through the existing Storyexecute(...)path and capture its last-step events for the next checkpoint.narrative.checkpoint(name, expected)— assert the currentsnapshot, projectedview,canExecute(...), and the emittedevent/events(ornoEvents) from the most recent intent.
If a narrative fails, the error includes the narrative name, the failing phase or checkpoint, and the serialized Story evidence gathered so far.
Stories
Section titled “Stories”A story records command, snapshot, view, and event activity as one ordered behavior trace. Start one from the runtime’s record(name):
const story = counter.record('toggles on');
await story.execute({ command: 'toggle' });await story.until((view) => view.isOn, async () => { await story.execute({ command: 'toggle' });});
const trace = story.trace();const lifecycle = story.lifecycle();const summary = story.summary();
story.stop();Story methods:
execute({ command, input? })— run a command and record it; resolves to the same{ snapshot, events }as the runtime.until(viewPredicate, action, options?)— repeatedly runactionuntil the projected view satisfiesviewPredicate.options.maxStepsbounds the loop. Returns aPromise<View>(the satisfying view).trace()— returnsIgniteStoryTraceEntry[]: the ordered behavior trace ofcommand,snapshot,view, andevententries withkind,sequence, andstep.lifecycle()— returnsIgniteStoryLifecycleEntry[]: DOM lifecycle evidence (registration, connection, render, disconnection, cleanup) when a custom element or accessibility bridge participates; otherwise an empty array.summary()— returns{ name, finalSnapshot, finalView, events, commandCount, traceCount, lifecycleCount }.stop()— releases the story’s runtime subscriptions; returns nothing.
record() is the low-level evidence boundary. story.trace() holds only observed behavior evidence; DOM evidence stays on the separate story.lifecycle() channel, so logic stays inspectable without a DOM.
Trace and snapshot helpers
Section titled “Trace and snapshot helpers”The test export also carries helpers as properties:
serializeTrace(trace)— convertstory.trace()into a deterministic JSON-safe snapshot (the same ordered shape, deep-cloned).snapshotStory(story)— produce{ name, trace, lifecycle, summary }for inline snapshot tests and portable receipts.expectTrace(trace, expected, options?)— assert ordered checkpoints against a trace. By default it matches a subsequence; pass{ exact: true }to require a one-to-one match. Each expectation is a deep-partial entry or a predicate. Returns nothing; throws on mismatch.
const story = counter.record('toggles on');await story.execute({ command: 'toggle' });
const snapshot = igniteTest.serializeTrace(story.trace());
igniteTest.expectTrace(story.trace(), [ { kind: 'command', sequence: 1, step: 1, command: 'toggle' }, { kind: 'snapshot', sequence: 2, step: 1, phase: 'before', snapshot: { value: 'off' }, }, { kind: 'view', sequence: 3, step: 1, phase: 'before', view: { isOn: false }, }, { kind: 'event', sequence: 4, step: 1, event: 'toggled', payload: { isOn: true }, },]);
const full = igniteTest.snapshotStory(story);DOM accessibility bridge
Section titled “DOM accessibility bridge”For projection proof, mount the same runtime behind a test-only DOM bridge and assert rendered controls by role and accessible name. The bridge renders against the same runtime state the story mutates, so behavior assertions stay headless while DOM assertions stay focused.
import { test as igniteTest } from 'ignite-element/xstate';
const story = counter.record('reaches limit');const bridge = igniteTest.accessibilityBridge( counter, ({ count, limit, isLimited, increment, setLimit }) => ( <section> <output role="status" aria-label="Counter status"> {count} / {limit} </output> <button type="button" onClick={() => increment()}> Increment </button> {isLimited && <p role="status">Limit reached</p>} </section> ), { elementName: 'counter-accessibility-bridge' },);
await story.execute({ command: 'increment' });
igniteTest.expectControls(bridge, [ { role: 'status', name: 'Counter status' }, { role: 'button', name: 'Increment' },]);
bridge.stop();story.stop();Bridge surface:
accessibilityBridge(component, renderer, options?)— render the runtime into a shadow root and return a bridge.bridge.getByRole(role, options?)/bridge.queryByRole(role, options?)— find a control by role, optionally narrowing by accessiblename,text, orvalue(string,RegExp, or predicate).getByRolereturns the matching control (throws if none/ambiguous);queryByRolereturns the control ornull.bridge.expectControls(expected)(origniteTest.expectControls(bridge, expected)) — assert a set of controls by role, name, text, or value. Returns nothing; throws on mismatch.bridge.host/bridge.root— the host element and its shadow root.bridge.stop()— tear the bridge down; returns nothing.
Related
Section titled “Related”- Testing guide — project setup, machine tests, and DOM tests.
- Build for agents — the behavior-first workflow.
- Headless runtime — the runtime these helpers wrap.