Skip to content

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 });
},
});

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; input is required for one-argument commands, omitted for no-argument commands, and optional when the command’s first argument is optional. Returns a Promise of the scenario.
  • expectSnapshot(expected) — assert the post-command snapshot.
  • expectView(expected) — assert the projected view (mirrors getView()). 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 last when(...).

Snapshot, view, and event expectations accept a deep-partial object or a predicate function. Event expectations match flat { type, ...fields } members.

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 current snapshot, projected view, and canExecute(...) state.
  • await narrative.intent({ command, input? }) — run one intent through the existing Story execute(...) path and capture its last-step events for the next checkpoint.
  • narrative.checkpoint(name, expected) — assert the current snapshot, projected view, canExecute(...), and the emitted event/events (or noEvents) 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.

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 run action until the projected view satisfies viewPredicate. options.maxSteps bounds the loop. Returns a Promise<View> (the satisfying view).
  • trace() — returns IgniteStoryTraceEntry[]: the ordered behavior trace of command, snapshot, view, and event entries with kind, sequence, and step.
  • lifecycle() — returns IgniteStoryLifecycleEntry[]: 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.

The test export also carries helpers as properties:

  • serializeTrace(trace) — convert story.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);

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 accessible name, text, or value (string, RegExp, or predicate). getByRole returns the matching control (throws if none/ambiguous); queryByRole returns the control or null.
  • bridge.expectControls(expected) (or igniteTest.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.