hacifootprint
Reference

The drift harness

hcifootprint/testing catches graph↔app drift in dev and CI — static lint over the graph alone, and a browserless driver for your interaction logic.

The navigation graph is a second artifact you keep alongside the real app, so it drifts as the app changes: a button's disable rule moves, a page is removed, a handler starts writing different state. hcifootprint/testing catches that drift in dev and CI, before production. It adds zero dependencies, is tree-shakeable, and drives the real session (never a copy), in three layers.

1. lintGraph / checkGraph — static, no test code

Read the graph alone and report stale logic: a control gated on state nothing produces, a guard that can never be true, a journey that can never finish, a page nothing can reach. This is the cheap CI gate. It's advisory by default and only escalates to hard errors once you tell it the starting state — it never cries "dead" over a key it can't see.

import { lintGraph, checkGraph, expectNoStaleLogic } from 'hcifootprint/testing';

lintGraph(graph);                            // → findings you can inspect
expectNoStaleLogic(graph, { initialState }); // → throws in CI if the graph drifted

// Or the one-call health verdict — findings grouped by drift type + a printable report:
const health = checkGraph(graph, { initialState });
if (!health.ok) { console.error(health.summary); process.exit(1); }

checkGraph is static and pure — import it from hcifootprint/testing/lint for a CI step that loads no engine code at all (see Tree-shaking).

2. testApp — Playwright for your interaction logic, minus the browser

Write mock handlers (one per action, returning a state change), then drive the graph as a user (clicking) or as the agent (the real Mode B tool path). The library's own honesty marker, effectVerified, flips false when a handler no longer does what the graph declares — that IS the behavioral-drift alarm. Report by default; pass strict: true to fail the instant drift appears.

import { testApp } from 'hcifootprint/testing';

const app = testApp(graph, {
  initialState: { cartCount: 0 },
  resolvers: { 'add-to-cart': (_p, { state }) => ({ patch: { cartCount: state.cartCount + 1 } }) },
});

await app.user.fire('add-to-cart');                        // drive like a human
app.expectState({ cartCount: 1 });
await app.agent.journey('purchase', { step: 'go-to-cart' }); // drive like the LLM
app.expectOn('cart');
app.report();                                              // { ok, effectDrift, unevaluatedGuards, gaps }

3. conformSource — a source adapter cannot silently drop a declared field

The two layers above watch the graph and the handlers. Neither can see the drift a graph source introduces, because that one happens on the way in.

A source threads somebody else's declaration through copy points: fromRoutes reads a route table, fromJourneys a journey list, fromLiveStore an action store, and every one of them copies a declared field from your shape into the library's, by hand, one field at a time. So does the source you write for your own store. A dropped field does not error. It compiles, it serves, and it looks like a working integration — you declared verify, the agent never sees it, and nothing anywhere says so. From the field: one integration's seam was dropping four declared fields silently, and one of those had already been fixed at one copy point and was still being dropped at the next.

conformSource ends that. It feeds a fully-populated declaration through your source, runs it through the real compiler and the real serving port, and names every field that did not come out the other side. expectConformance is the same thing as a gate — it throws naming every dropped field, not the first one.

import { expectConformance, conformSource } from 'hcifootprint/testing';
import { fromLiveStore } from 'hcifootprint';

// The whole test, for any source:
expectConformance((fixture) => fromLiveStore(fixture.store));

// …or read the report yourself.
const report = conformSource((fixture) => myAdapter(fixture.store));
report.dropped; // [{ field: 'verify', seam: 'compile' }, { field: 'blockedBecause', seam: 'serve' }]

You hand it a one-line factory, not a finished source, and the asymmetry is the method. A source is a snapshot — it read your truth once and closed over it, and it has no input door afterwards — so the helper has to be the one holding the declaration. That is what the ConformanceFixture handed to your factory is: a fully-populated action, plus a ready-made input per source kind (fixture.store, fixture.routes, fixture.journeys). The type of what you pass is SourceUnderTest; ConformanceOptions carries the one setting, page, for a source that only speaks about one page.

The manifest, and why it cannot drift

DECLARABLE_ACTION_FIELDS is the canonical list of everything an action declaration may carry — ActionDef plus its two extension points, the root-level multi-attach on and the mount-time handler (the pair is the exported type FullActionDef, and one entry of the list is a DeclarableActionField). It is compile-locked: add a field at either door without listing it, and the build stops with the new field's own name in the error. A manifest that can fall behind is the same silence one level up.

Two seams, and one row per dropped field

A ConformanceSeam is either 'compile' — did the declaration reach the compiled record the graph holds for this action? — or 'serve' — does an agent-visible surface carry it, the row whats_here answers with and the available() edge behind it? A field can survive the first and die at the second, which is exactly the bug that had already been "fixed" once. Each dropped field is reported once, at the seam that lost it: a declaration that never compiled cannot reach a row either, and saying so twice would send you to two places for one fix.

The ConformanceReport has three lists, and the third is the honest one. dropped is the verdict. checked is the pass's denominator. excluded names every field/seam pair there was nothing to read at, each with its reason in words — because a checker whose pass is partly vacuous and does not say which part is the failure it was built to end. Five pairs are excluded today: an action's writes and verify never ride a served row, on is the root-attach extension no first-party source contributes, and a handler never crosses the wire (the row discloses only that one is mounted).

All three first-party sources are pinned by this in the suite. fromJourneys and fromLiveStore round-trip their declarable subsets losslessly. fromRoutes carries no action declaration at all, and that is its conformance: it refuses an action-shaped key by name rather than reading two keys and discarding the rest — which is the same silence one door down.

What conformance does not test is the library's own presence laws. blockedBecause is served only while a control is off, enabledWhen reaches a reader as one enabled: false stamp; the fixture puts each field in the state the library promises to serve it in, and then asks one question — did your adapter's threading survive.

The honest boundary

This tests interaction logic above the binding — guards, journeys, navigation, effect claims, typed rejections. It does not verify pixels, the DOM, or that a binding resolves to a real element — that stays Playwright's job, and this complements it rather than replacing it. A green lintGraph proves the graph is internally consistent, not that the app works: it reasons about which state keys move, never their values, so a right-key/wrong-value bug is the harness's job (effectVerified), not the linter's. And a mock is a simulation — if it diverges from the real handler, the test is green while prod is broken. For full fidelity, pass testApp({ session }) with your own wired session.

Both entry points are documented in the API Reference — the generator reads all four public entries, subpaths included.

On this page