Tests

The harness ships with the framework, and assertions are synchronous:

import { render } from 'lemonadejs/test';     // any DOM: browser or jsdom

const t = render(Counter, { start: 5 });
t.query('button').click();
t.query('p').textContent === '6';   // assert on the NEXT LINE: no act(),
t.unmount();                        // no nextTick(), no waitFor()

But a unit harness is half the story. This chapter documents the discipline the project actually practices: jsdom for state and DOM logic, real Chrome for geometry, because the second half is where test suites overstate what they verified.

Why two layers

  • jsdom has no layout engine. Every getBoundingClientRect() returns zeros; nothing has a width, nothing overlaps, nothing overflows, nothing anchors. A dropdown panel rendered 400px away from its input passes every test written for it.
  • Scheduled updates require flush calls. When the engine schedules DOM updates, assertions must wait for them to settle. The common case is usually handled automatically; flakiness clusters at the edges: timers, transitions, manually scheduled work.
  • “Renders without crashing” is not conformance. A published component promises props, events, a bindable value, an api. Almost no test suite mechanically checks the promise.
  • Agents make this acute. An agent cannot eyeball a browser. If its feedback loop cannot see layout, it ships geometry bugs with a green test suite, and both real bugs below did exactly that.

The test layers

Layer 1, lemonadejs/test, on any DOM. render() mounts into a fresh container and returns a handle: query/queryAll (scoped querySelector), text(), snapshot() (deterministic, attribute-sorted, diffable text rendering of the DOM tree), inspect() (the live component tree as plain JSON: { component, contract, states, children }), and unmount(). Because state updates are synchronous, there is no flush step: click, then assert.

Two helpers cover what synchronous updates cannot. flush() drains microtasks and zero-delay timers, the awaiting step for genuinely async work like a resource() response landing: await flush() then assert. setRect(el, { left, width, ... }) plants geometry on an element in jsdom, where no layout engine exists and every rectangle is zero, so a modal’s auto-adjust, a tooltip’s anchoring or a drag’s hit-testing become logic-testable in jsdom, with the real-pixel truth still owned by the probe layer below.

verify() is the conformance layer: it reads the component’s contract and exercises the promise. It mounts with defaults, every prop as a plain value and as a live state, every event, bind, and the declared api actually exposed through props.ref. Any LJS-* dev warning during any check fails that check. The Studio registry refuses blocks whose verify() fails.

Layer 2, real Chrome, for geometry. The probe pattern, zero dependencies:

  • .probe/*.ts are in-page assertion scripts: mount the block, drive it (open, scroll, type, drag), measure with real getBoundingClientRect(), and write PASS/FAIL lines into a <pre id="lm-probe">.
  • scripts/chrome-probe.mjs (96 lines, no packages) spawns the installed Chrome headless with a DevTools port, connects over Node’s global WebSocket, polls the page for the #lm-probe block, prints it, and exits non-zero on any FAIL.
  • npm run probe bundles every probe page and runs them all against the dev server.

An assertion from the dropdown probe, the kind jsdom structurally cannot make:

api.open();
await frame();
const panelRect = panel.getBoundingClientRect();
log('panel-anchored-under-input',
    Math.abs(panelRect.top - (inputRect.bottom + 1)) <= 2 &&
    Math.abs(panelRect.left - inputRect.left) <= 2,
    { panelTop: Math.round(panelRect.top), inputBottom: Math.round(inputRect.bottom) });

In practice

The receipts: at the time of writing, the repository runs 1,049 behavior tests (engine + all 43 Studio blocks, jsdom) and 62 real-Chrome probe assertions across eight probe pages (modal, dropdown, grid, menu, calendar, schedule, gantt, router) on every change. Two bugs that suite structure caught, and jsdom alone provably could not:

The Modal anchor bug. Anchored panels read their top/left props once, at construction (setup runs once by design), so a panel fed live anchor coordinates opened wherever the coordinates were before the caller measured them. Every jsdom test stayed green: in jsdom all rectangles are zero, so “panel at 0,0” is indistinguishable from correct. The probe assertion above (panel top within 2px of the input’s bottom) failed in real Chrome; the fix re-reads position props per open. This bug class later motivated computed().

The dropdown focus race. In a real browser, a mousedown’s default action focuses the label; opening the panel swaps a branch, the renderer disposes the focused node, and the browser fires focusout exactly as if the user clicked away, closing the panel in the same tick it opened. jsdom never produces that event sequence. The probe drives the genuine flow (field.focus() then a coordinate-carrying mousedown) and asserts the panel survives; the fix is the isDisposing() guard (see Destroy).

And the conformance loop an agent runs after generating a component:

import { verify } from 'lemonadejs/test';

const report = verify(Switch);
report.pass;     // true, or:
report.checks;   // [{ name: 'prop label (live state)', pass: false, detail: '...' }]

Generate the component and its proof in one breath; a failing check names the broken promise.

Reference

import { render, verify } from 'lemonadejs/test';

const t = render(Component, props?);   // TestHandle
t.root            // the mounted container
t.query(sel)      // querySelector, scoped: HTMLElement | null
t.queryAll(sel)   // real array of elements
t.text()          // full visible text
t.snapshot()      // deterministic indented DOM tree (attrs sorted), diffable
t.inspect()       // { component, contract, states, children } as plain JSON
t.unmount()       // dispose everything

verify(C)         // { component, pass, checks: [{ name, pass, detail? }] }
                  // requires a contract; any LJS-* warning fails a check

import { flush, setRect } from 'lemonadejs/test';
await flush()     // drain microtasks + zero-delay timers (async work landed)
setRect(el, { left: 10, width: 200, height: 24 })
                  // plant geometry in jsdom (no layout there): modal,
                  // tooltip and drag logic become testable

Probe commands (library repo):

npm test                   # vitest: engine + every block, jsdom
npm run dev                # serves the playground + probe pages on :3000
npm run probe              # bundle .probe/*.ts, run each page in headless Chrome
node scripts/chrome-probe.mjs http://localhost:3000/.probe/probe-grid.html
# CHROME=/path/to/chrome to point at a different binary

The division of labor, memorized: state, events, contract conformance go to jsdom, synchronous, fast. Anything with a coordinate in it goes to a probe, in the real engine, with the assertion in the page.