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.
Tree-shaking & packaging
True-ESM, sideEffects false, leaf modules — importing one helper ships half a kilobyte, and the repo's own test suite bundles the shipped dist to keep it that way.
Design lineage
The numbered design documents that decided the library's load-bearing shapes — what each locked in, and where to read the full reasoning.