@llui/test
Test harness for LLui components. Mount components in jsdom, send messages, and assert on state and DOM.
pnpm add -D @llui/test
Usage
import { testView } from '@llui/test'
import { counterDef } from './counter'
const harness = testView(counterDef, { count: 0 })
harness.click('[data-testid="increment"]')
harness.flush()
expect(harness.text('[data-testid="display"]')).toBe('1')
harness.unmount()
API
testComponent
testComponent(def) => { state, send, flush, effects }
Mount a component definition headlessly. Returns current state snapshot and message dispatch.
testView
testView(def, state?) => ViewHarness<M>
Mount a component into jsdom with full DOM. Returns a harness with DOM query and interaction methods.
| Method | Description |
|---|---|
.send(msg) |
Dispatch a message |
.flush() |
Force synchronous update (skip microtask queue) |
.click(selector) |
Simulate click on element |
.input(selector, val) |
Set input value and fire input event |
.text(selector) |
Get textContent of element |
.attr(selector, name) |
Get attribute value |
.query(selector) |
querySelector on mounted DOM |
.queryAll(selector) |
querySelectorAll on mounted DOM |
.fire(selector, event) |
Dispatch a custom event |
.unmount() |
Tear down the component and clean up |
assertEffects
assertEffects(effects, expected) => void
Deep-equal assertion on effect arrays. Provides clear diff output on mismatch.
propertyTest
propertyTest(def, config) => void
Property-based testing over a component definition. config.messageGenerators produce random messages, and each generated sequence is checked against config.invariants ((state, effects) => boolean). Tune runs and maxSequenceLength; pass a seed to make the pseudo-random stream deterministic (the seed is always printed on failure, so you can pin it to replay the exact run). On failure the offending message sequence is automatically shrunk (delta-debugging) to a minimal reproducer. An optional mount block additionally mounts the component into a real DOM container and dispatches the sequence through send/flush, asserting no dev-mode panic, no console.error, and an optional assertDom(state, container) after every commit.
replayTrace
replayTrace(def, trace) => void
Replay a recorded message trace against a component definition. Asserts state at each step.
emulateBlurOnRemoval / withBlurOnRemoval
emulateBlurOnRemoval(doc?) => () => void
withBlurOnRemoval(fn, doc?) => ReturnType<fn>
Browser-faithful blur emulation for jsdom. When a focused element (or an ancestor) is removed from the document, real browsers run the HTML "removing steps" focus fixup and synchronously fire blur then focusout; jsdom resets document.activeElement but fires no events. That gap makes the inline-edit-commit pattern — an <input> whose onBlur commits, inside a branch arm the commit itself swaps out — impossible to exercise on its real path. emulateBlurOnRemoval() patches removeChild / remove / replaceChild to dispatch the missing events synchronously, returning an uninstall function; withBlurOnRemoval(fn) scopes the patch around fn.
Functions
assertEffects()
Assert an effect list matches an expected list of partials. Length must be
equal; each effect at index i must partial-match expected[i]. See
{@link partialMatch} for the deep/array semantics (nested arrays match by
index with a length check; undefined fields are wildcards).
function assertEffects<E>(actual: E[], expected: Array<Partial<E>>): void
defineTestComponent()
function defineTestComponent<S, M extends { type: string }, E extends { type: string } = never>(input: DefineTestComponentInput<S, M, E>): SignalComponentDef<S, M, E>
emulateBlurOnRemoval()
Browser-faithful blur emulation for jsdom.
The HTML standard's node-removing steps run a "focus fixup": when the
currently-focused element (or an ancestor of it) is removed from the
document, the user agent resets focus to the viewport and fires blur then
focusout on the old focus target — SYNCHRONOUSLY, as part of the mutation.
Real apps depend on this: an inline-edit <input> whose onBlur commits,
sitting in a structural arm that the commit itself swaps out, fires that blur
mid-reconcile and re-enters the reducer.
jsdom resets document.activeElement to <body> on removal but fires NO
events, so that reentrancy is invisible in tests — the single most important
inline-edit interaction can't be exercised. emulateBlurOnRemoval closes the
gap by patching the removal-causing mutation methods to dispatch the missing
events synchronously, in browser order (blur, then the bubbling focusout).
Opt-in and reversible: returns an uninstall function (call it in afterEach),
or use {@link withBlurOnRemoval} for automatic scoping.
@param doc - document whose activeElement is consulted (defaults to the
ambient document). The patch is applied to the shared Node/Element
prototypes, matching the single jsdom document under test.
@returns an idempotent uninstall function restoring the native methods.
function emulateBlurOnRemoval(doc: Document = document): () => void
propertyTest()
function propertyTest<S, M, E>(def: SignalComponentDef<S, M, E>, config: PropertyTestConfig<S, M, E>): void
recordAgentSession()
Begin recording an agent session. Returns a recorder whose send
forwards to the handle and captures the message; stop() finalizes
the trace into a JSON-serializable fixture.
Typical usage:
const handle = mountApp(root, App)
const r = recordAgentSession(handle)
r.send({ type: 'Cloud/NewMatrix' })
r.send({ type: 'Matrix/AddCriteria', criteria: [...] })
r.send({ type: 'Cloud/Save' })
const fixture = r.stop()
// Persist `fixture` as JSON; replay in CI to assert the same
// sequence still produces the same final state.
The recorder relies on the handle's flush() after every send so
the snapshot in stop() reflects the drained-message-queue state.
For long-running async effects, snapshot only fires after the
synchronous reducer cycles complete; subsequent commits from
effect responses won't be captured. Apps that need full async
coverage can manually call await handle.flush() plus a microtask
sleep before stop(), or wrap individual sends in
await new Promise(r => setTimeout(r, 0)) between them.
function recordAgentSession(handle: SignalComponentHandle<unknown, AgentMsg>): AgentSessionRecorder
reducer()
Builds a view-less ComponentDef from an init + update pair so reducer
suites can drop a component definition into testComponent() without
padding a no-op view. Use when a test only exercises pure state
transitions (no DOM, no accessors).
The default name '__reducer__' is intentionally unergonomic — it
shows up in devtools/HMR registries if one ever leaks into a real
mount, flagging the mistake. Override via name when you want the
history trail to match your module.
function reducer<S, M extends { type: string }, E extends { type: string } = never>(opts: ReducerOptions<S, M, E>): SignalComponentDef<S, M, E>
replayAgentSession()
function replayAgentSession(handle: SignalComponentHandle<unknown, AgentMsg>, fixture: AgentSessionFixture, options: ReplayOptions = {}): ReplayResult
replayTrace()
function replayTrace<S, M, E>(def: SignalComponentDef<S, M, E>, trace: LluiTrace<S, M, E>): void
testComponent()
function testComponent<S, M, E>(def: SignalComponentDef<S, M, E>, options: TestComponentOptions = {}): TestHarness<S, M, E>
testView()
Mount a component against a fresh container and return an interactive harness. Simulates events + auto-flushes so tests can chain assertions naturally.
function testView<S, M, E>(def: SignalComponentDef<S, M, E>, state: S): ViewHarness<S, M>
withBlurOnRemoval()
Run fn with {@link emulateBlurOnRemoval} installed, uninstalling afterwards
even if fn throws. Returns whatever fn returns.
function withBlurOnRemoval<T>(fn: () => T, doc: Document = document): T
Interfaces
AgentSessionFixture
Captured trace of an agent-driven session: the sequence of
messages dispatched and the final state observed after the last
one. Serializable as JSON so test fixtures can live alongside
code (__fixtures__/login-flow.json) and replay deterministically
in CI.
export interface AgentSessionFixture {
/**
* State snapshot taken when recording started. Replay starts from
* here — if the new handle's initial state diverges, the harness
* reports the divergence so callers can decide whether to fail or
* normalize.
*/
initialState: unknown
/**
* Messages dispatched in order. Each is the raw msg the agent
* sent (or whatever the recorder's `send(msg)` was called with).
*/
msgs: Array<{ type: string; [k: string]: unknown }>
/** State after every `msg` has been dispatched + drained. */
finalState: unknown
}
AgentSessionRecorder
export interface AgentSessionRecorder {
/**
* Send a message through the wrapped channel. Forwards to the
* underlying `handle.send` and records the msg into the trace.
* Use this in place of `handle.send(msg)` for the duration of
* the session you want to capture.
*/
send(msg: { type: string; [k: string]: unknown }): void
/**
* Stop recording, snapshot the final state, return the fixture.
* After `stop()`, further `send()` calls throw.
*/
stop(): AgentSessionFixture
}
DefineTestComponentInput
export interface DefineTestComponentInput<
S,
M extends { type: string },
E extends { type: string } = never,
> {
name: string
init: () => [S, E[]] | S
update: (state: S, msg: M) => [S, E[]] | S
view: (bag: SignalViewBag<S, M>) => Renderable
onEffect?: SignalComponentDef<S, M, E>['onEffect']
}
ReducerOptions
export interface ReducerOptions<S, M extends { type: string }, E extends { type: string } = never> {
init: () => [S, E[]]
update: (state: S, msg: M) => [S, E[]]
name?: string
}
ReplayOptions
export interface ReplayOptions {
/**
* When true, also assert that the new handle's initial state
* matches `fixture.initialState`. Defaults to false — most apps
* have deterministic init, but ones that read time / random /
* environment shouldn't enforce this.
*/
assertInitial?: boolean
}
ReplayResult
Replay a previously-recorded session against a fresh handle.
Dispatches each msg in order, snapshots state after the last one,
and compares to fixture.finalState. Returns:
matches: true— bit-exact replay; nothing changed.matches: false, diff— final state differs;difflists the paths that diverged in the same JSON-Patch shape assend_message'sstateDiff. Use it in test assertions:expect(result.diff).toEqual([]). The harness deliberately ignores theinitialStatehalf of the fixture by default — replay starts from whatever the new handle'sinit()produced, so apps with deterministic init don't need to carry their initial state around in source control. PassassertInitial: trueto also enforce that the initial states match; useful when a test wants to catch init-effect drift.
export interface ReplayResult {
matches: boolean
/**
* Diff from fixture.finalState to the replay's actual final state.
* Empty when `matches: true`. Empty when `matches: false` only if
* the divergence was at the `initialState` level and `assertInitial`
* was true.
*/
diff: Array<{ op: 'add' | 'remove' | 'replace'; path: string; value?: unknown }>
}
TestComponentOptions
export interface TestComponentOptions {
/**
* Opt in to faithfully replicating the runtime's effect drain. In the default
* (pure-reducer) mode `testComponent` runs `update()` once per `send` and
* stops — effects are recorded but never dispatched. The real runtime instead
* dispatches every returned effect to `onEffect`, which commonly calls `send`
* synchronously; the terminal state after such a cascade differs from the
* pure-reducer state ("green tests lie").
*
* With `withEffects: true` the harness replicates the runtime loop exactly:
* a queue-based `send`, reducers run to quiescence, then the collected
* effects dispatch in order through the def's `onEffect` with a real
* {@link EffectApi} (including this mount's lifecycle `signal`); effect-driven
* `send`s re-enter the same queue, so a cascade settles to the same terminal
* state a real `mountApp` reaches.
*/
withEffects?: boolean
}
TestHarness
export interface TestHarness<S, M, E> {
/** Current state (after the most recent `send`/`sendAll`/`batch`). */
state: S
/**
* Effects produced by the MOST RECENT top-level `send` (or `batch`, or
* `init`). In `withEffects` mode a single `send` can run several reducers
* (the effect→send cascade); this holds every effect emitted across that
* whole drain, in emission order.
*/
effects: E[]
/** Every effect emitted since construction (init effects first). */
allEffects: E[]
/**
* One entry per reducer run, in order. In `withEffects` mode a cascade adds
* several entries under one `send`.
*/
history: Array<{ prevState: S; msg: M; nextState: S; effects: E[] }>
send: (msg: M) => void
sendAll: (msgs: M[]) => S
/**
* Coalesce a burst of `send`s (see the runtime handle's `batch`). Reducers
* and — in `withEffects` mode — effects still run per message in order; the
* harness has no DOM to commit, so `batch` here is a faithful structural
* mirror (it establishes one top-level `effects` window across the burst).
*/
batch: (fn: () => void) => void
/**
* Tear down the harness: aborts the per-mount lifecycle `AbortSignal` handed
* to `onEffect` (so effect handlers keyed off `api.signal` clean up) and runs
* any cleanups returned by `onEffect`. After dispose, `send`/`batch` are
* inert (matching the runtime's after-dispose drop). No-op in the default
* pure-reducer mode beyond aborting the signal.
*/
dispose: () => void
}