@llui/test

Current package version: 0.13.0

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?, options?) => ViewHarness<M>

Mount a component into jsdom with full DOM. Returns a harness with DOM query and interaction methods. options is forwarded to mountApp unchanged, minus the two options that would shadow testView's own seed and container (initialState — the state argument is the seed — and hydrate), so a test can run in the mode the app really runs in: with { scheduler: 'raf' } a burst of harness.handle.send(...) coalesces into ONE commit, which harness.handle.flush() forces synchronously. Omitted, the runtime default scheduler: 'sync' applies and every send commits on its own.

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, options?) => void

Partial-match assertion on effect arrays. The lists must be the same length, and each effect must contain everything its expectation names — unspecified keys are ignored, nested arrays match by index with a length check. Provides clear diff output on mismatch.

An expected undefined is an assertion, not a wildcard: { url: undefined } demands the effect carry a url key holding undefined, and fails both for a url with a value and for an effect with no url key at all. To leave a field unconstrained, omit its key.

Pass { exact: true } to also reject keys the expectation does not name (at every level it reaches). Callback fields and keys holding undefined are exempt — they are outside the effect's JSON data. Exact mode is how you assert a key is absent.

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. mount.options is forwarded to mountApp unchanged, so the run can exercise a non-default mount mode (e.g. { scheduler: 'raf' }).

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; an expected undefined asserts the actual value IS undefined — leave a field unconstrained by omitting its key, not by writing undefined).

function assertEffects<E>(actual: E[], expected: Array<Partial<E>>, options: AssertEffectsOptions = {}): 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()

Replay a recorded message trace against a component definition, asserting the state and effects at every step.

The trace's version and recording component are validated FIRST — before init() and the first update() — so a mismatched trace fails with a version/identity error instead of a state diff that blames the reducer.

An absent component field is accepted with a warning rather than rejected: traces predate the field and hand-written ones legitimately omit it, so rejecting would break working traces to buy nothing. The warning is what makes the unverified identity visible; only a component that is present and DIFFERENT is an error.

function replayTrace<S, M, E>(def: SignalComponentDef<S, M, E>, trace: ReplayableTrace<S, M, E>): void

testComponent()

Drive a component definition without mounting its view.

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. opts reaches mountApp untouched (see {@link TestViewOptions}); omitting it keeps the runtime defaults — notably scheduler: 'sync', where every send commits on its own.

function testView<S, M, E>(def: SignalComponentDef<S, M, E>, state: S, opts?: TestViewOptions<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

Types

TestViewOptions

Mount options testView forwards verbatim to mountApp — the whole bag, so a consumer can exercise the mode their app really runs in (notably scheduler: 'raf', where handle.flush() forces the coalesced commit).

The one exclusion rule: an option that competes with what testView itself owns is dropped. mountSignalComponent resolves the seed as hydrate.serverStateinitialStateinit(), and testView seeds through init(), so either of the first two would silently outrank the positional state argument and leave it reading as dead code. That rules out initialState (the state argument IS the seed) and hydrate (which re-seeds AND expects server-rendered HTML to take over, in a container testView just created empty — @llui/dom's own suite covers hydration, against real server markup). Everything else — scheduler, contexts, devtools, and any option added later — flows through untouched.

export type TestViewOptions<S> = Omit<MountSignalOptions<S>, 'initialState' | 'hydrate'>

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
}

AssertEffectsOptions

Options for {@link assertEffects}.

export interface AssertEffectsOptions {
  /**
   * Opt in to EXACT matching: each effect may carry no data key beyond those
   * its expectation names, at every level the expectation reaches. Keys whose
   * value is a function or `undefined` are exempt — they are outside the JSON
   * projection of the effect (an http effect's `onSuccess` callback is a fresh
   * closure every `update()` and cannot be written in an expected literal).
   *
   * Defaults to `false`: ignoring unspecified keys is what partial matching
   * MEANS, and every existing expectation relies on it. Exact mode is also how
   * you assert a key is ABSENT — name the keys that must exist and nothing
   * else — since `{ key: undefined }` asserts the opposite (present, holding
   * `undefined`).
   *
   * The handling of `undefined` is asymmetric on purpose: an EXTRA
   * `undefined`-valued key on the effect is tolerated (it is not data), while
   * an `undefined` you WRITE in the expectation still demands the key be there.
   */
  readonly exact?: boolean
}

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; diff lists the paths that diverged in the same JSON-Patch shape as send_message's stateDiff. Use it in test assertions: expect(result.diff).toEqual([]).

The harness deliberately ignores the initialState half of the fixture by default — replay starts from whatever the new handle's init() produced, so apps with deterministic init don't need to carry their initial state around in source control. Pass assertInitial: true to 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 the shared runtime's effect drain. In the default pure-reducer
   * mode `testComponent` runs `update()` once per `send` and stops — effects are
   * recorded but not interpreted. With `withEffects: true`, returned effects
   * run through `onEffect`; synchronous effect-driven sends re-enter the shared
   * queue and settle before the top-level send returns.
   */
  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` establishes one top-level
   * `effects` window across the burst.
   */
  batch: (fn: () => void) => void
  /**
   * Tear down the harness: aborts the per-driver lifecycle `AbortSignal` handed
   * to `onEffect` and runs cleanups returned by `onEffect`. After dispose,
   * `send`/`batch` are inert.
   */
  dispose: () => void
}