Adapter Conformance

The runnable contract that pins every adapter, and the fixture that pins the contract.

A conformance contract asserts an adapter behaves; a fixture asserts the contract still contains those assertions; a ledger asserts every one of those assertions still ran.

Diagram

๐Ÿ”Œ adapter

๐Ÿ“œ conformance contract

๐Ÿงช broken fixture

๐Ÿงพ assertion ledger

โ“ every assertion still ran?

Quick Start

  1. Run the contracts against your adapter

    Each port ships a runnable contract. Register your adapter and both the fake and the shipping adapter are held to the same cases.

    ts
    import { describeSecretsContract } from "praisonai-mobile/adapters/conformance/secrets-contract";
    
    describeSecretsContract("my adapter", () => createMyAdapter());
  2. Prove the contract can still fail

    The fixture spawns the real contract against a deliberately broken adapter, one break at a time. It takes a single mode string.

    bash
    node adapters/src/conformance/contract-fixture.ts <mode>

    The break modes plus the none control:

    secrets_slot_only
    secrets_empty_is_absent
    storage_missing_is_undefined
    storage_namespaces_collide
    storage_empty_is_absent
    storage_torn_write
    storage_forgets_on_relaunch
    time_every_fires_once
    time_clear_does_nothing
    time_unsubscribe_does_nothing
    shell_scheme_case_sensitive
    shell_negative_insets
    none
  3. Check the floor and the control

    contracts.test.ts counts passing cases from a real none run and asserts a floor per contract: secrets โ‰ฅ 14, storage โ‰ฅ 15, time โ‰ฅ 8, shell โ‰ฅ 35. The none control must pass green, so a fixture that failed for an unrelated reason cannot masquerade as proof.

  4. Wire the assertion ledger

    Each contract counts every assertion it makes and checks the exact total in its last case, so a deleted assertion turns the run red by name.

    ts
    import test from "node:test";
    import rawAssert from "node:assert/strict";
    import { ledger, type Ledger } from "./assert-ledger.ts";
    
    // Update this deliberately when you add or remove an assertion below.
    const EXPECTED_ASSERTIONS = 16;
    
    export function describeSecretsContract(name, make) {
      const counting = ledger();
      const assert: Ledger["assert"] = counting.assert;
      const made = counting.made;
    
      test(`${name}: โ€ฆ your case โ€ฆ`, async () => {
        // use `assert.equal(...)`, `assert.deepEqual(...)`, etc. as usual
      });
    
      // Registered LAST โ€” every case above has already run by the time it does.
      test(`${name}: this contract made every assertion it is supposed to make`, () => {
        rawAssert.equal(made(), EXPECTED_ASSERTIONS,
          `${name}: ran ${made()} assertions, not ${EXPECTED_ASSERTIONS}. โ€ฆ`);
      });
    }

Why Three Layers

The contract asserts the adapter behaves; the fixture asserts the contract still catches a break; the ledger asserts every assertion still ran.

Diagram

Layer 3 โ€” every assertion still ran

๐Ÿงพ assertion ledger

๐Ÿ“œ count == EXPECTED_ASSERTIONS?

Layer 2 โ€” the case is still there

๐Ÿงช break-mode fixture

๐Ÿ“œ does the case still go red by name?

Layer 1 โ€” behaviour

๐Ÿ“œ contract

๐Ÿ”Œ does the adapter behave?

A contract with no broken-implementation test is documentation with a test() around it. Deleting assert.ok(fired >= 2) from the time contract โ€” the one assertion that catches setInterval becoming setTimeout, which stops every polling loop in the app after a single tick โ€” left the suite at 1035 pass, 0 fail. Fourteen assertions across all four contracts could be deleted for free.

The hollowed case

A break-mode table catches a case that has been deleted. It does not catch a case that still runs and asserts nothing. Seven assertions across the conformance suites could be gutted to assert nothing and the build stayed green โ€” a deletion guard never fires when the test() block is still there.

One break mode per hollowable case is what closes that, because a break mode reddens the case by name. The order of defence is behaviour โ†’ contract โ†’ break mode per hollowable case: the contract asserts the adapter behaves, the fixture asserts the contract still catches a break, and the break mode ties each named case to a defect that must redden it.

The in-case hole the ledger closes

The break-mode fixture protects exactly one assertion per case โ€” the first one to trip. Sweeping the four contracts one assertion at a time, 62 of 73 could be deleted with a fully green run: thirteen break modes were protecting only eleven assertions. The ledger closes that hole with a counting proxy over node:assert/strict and an exact count checked in the contract’s last case. Exact rather than a floor: a floor lets an assertion be swapped for a weaker one at the same arity, which is the same hollowing wearing a different hat.

The ledger does not claim an assertion still asserts something useful โ€” only that it is still there and still ran. contract-fixture.ts remains the thing that proves an assertion has teeth. The two are complementary; neither subsumes the other.

beforeafter
assertions deletable with a green run62 / 730 / 73
break modes1313
assertions actually protected1173

The Shell Reads What It Forwarded

The shell contract now asserts on the value the adapter handed the OS, not just whether the promise settled.

ShellHarness carries forwarded(): readonly string[] โ€” the URLs openExternal actually handed the OS, in order. A shell can validate one string and forward another, and doesNotReject cannot see the difference, so the harness surfaces the forwarded value and the contract reads it. forwarded() is required, not optional, so a shell cannot opt out of being checked.

ts
export interface ShellHarness {
  readonly shell: ShellPort;
  // ...
  forwarded(): readonly string[]; // required โ€” every real shell supplies it
}

A padded-URL case then reads it directly: a URL that passes the allowlist must reach the OS trimmed ("https://ok.example", never the padded input), and a padded javascript: must still be refused before anything is forwarded.


Host presence is part of conformance

An adapter that detects its host โ€” the Tauri bridge’s isPresent() in adapters/src/tauri/bridge.ts โ€” must apply a both, not either discipline: it reads present only when every function the host contract requires is present, and absent when any one is missing.

__TAURI_INTERNALS__ carriesisPresent()
both invoke and transformCallbacktrue
only one of the twofalse (half-present = absent)

A half-present global read as present makes listen call a missing transformCallback, which throws, is caught, and turns every subscription into a silent no-op โ€” so any future adapter with a “detect the host” step owes the same rule. Pinned by "a HALF-present Tauri global is treated as absent" and its pair "a FULLY present Tauri global is treated as present -- the pair". See Native Shell โ†’ Presence detection.


Keyboard height

Two cases in the shared contract pin the Tauri shell’s viewport fallback and the single-writer handover to the native event; a third pin covers the Android insets global taking over from the viewport reading.

The tests live at the tail of adapters/src/conformance/contracts.test.ts and drive createTauriShell({ view }) with a createFakeWindow().

ts
import { createFakeWindow } from "praisonai-mobile/adapters/web/fake-window";
import { createTauriShell } from "praisonai-mobile/adapters/tauri";

const window = createFakeWindow();
const shell = createTauriShell({ bridge: probe.bridge, view: window.window });

window.setKeyboardHeight(320);
// shell.keyboardHeightPx === 320 โ€” reported without any native event

probe.emit("keyboard-height", 291);
window.setKeyboardHeight(400);
// shell.keyboardHeightPx === 291 โ€” native wins, the viewport no longer fights it
TestWhereAsserts
"the TAURI shell reports the keyboard height, without any native event"contracts.test.tsThe viewport fallback reports the height and fires subscribers exactly once, on open and on close.
"a native keyboard height takes over from the viewport reading"contracts.test.tsThe first native height becomes authoritative and the viewport listener is dropped, so the two sources never race.
the Android insets global takes over from the viewport readingadapters/src/tauri/native-insets.test.ts + tools/shell-seam.test.mjsDriven with a fake Window that has no visualViewport โ€” the Android reality โ€” the global installs, publishes both the insets and keyboard channels, drops non-object payloads, coerces via toPx, and retires the viewport listener on first fire. shell-seam.test.mjs pins the Kotlin INSETS_GLOBAL to the TypeScript NATIVE_INSETS_GLOBAL.

See Shell & Adapters โ†’ The three-source keyboard model.


The Break Modes

Each mode breaks an adapter in one named way, and the matching contract case must go red by name. Two of the modes are shell modes โ€” the shell contract now spawns its own break modes, on top of the case-count floor that still complements them.

break modemust redden the case named
secrets_slot_onlytwo ACCOUNTS in one slot are two different secrets
secrets_empty_is_absentan empty string is a stored value, not an absence
storage_missing_is_undefineda missing key reads as null, never undefined
storage_namespaces_collidenamespaces are isolated
storage_empty_is_absentan empty string is a value, not an absence
storage_torn_writea concurrent read never sees a torn value
storage_forgets_on_relauncha written value survives a relaunch
time_every_fires_onceevery() repeats, rather than firing once
time_clear_does_nothinga cleared timer does not fire
time_unsubscribe_does_nothingthe unsubscribe actually stops it
shell_scheme_case_sensitivean uppercase JAVASCRIPT: URL is refused
shell_negative_insetsno inset is negative

A none control runs unbroken, so a fixture that failed for an unrelated reason โ€” a syntax error, a missing import โ€” cannot masquerade as proof.


The secrets contract now has durability and presence branches

The SecretsPort contract grew two optional branches, supplied only by an adapter that can back them โ€” a durable native store (reopen) and one that can count reads (readsOf). The web adapter, a module-scoped Map, claims neither, so it is not asked to.

BranchSupplied viaCasesWhat it catches
durabilityreopen(secrets) โ€” a second port over the same store4A keychain that forgets when the process ends is not a keychain: the user re-types the key on every launch.
presencereadsOf(secrets) โ€” how often a value was read out1has() implemented as get(ref) !== null returns the right booleans and copies the key into the webview heap on every repaint.

The four durability cases (adapters/src/conformance/secrets-contract.ts):

  1. a stored secret survives a relaunch
  2. presence survives a relaunch, not just the value โ€” the settings row reads presence, so a store that kept the value but rebuilt its index would show “Not set” beside a stored key.
  3. a deletion survives a relaunch too โ€” a store that persisted writes but forgot deletes would resurrect a credential the user removed.
  4. a FRESH store has none of another store's secrets โ€” the control that stops a single process-wide Map from passing “survives a relaunch” while proving nothing.

The one presence case:

  • has() answers without reading the value โ€” asserted on both a hit and a miss, so an adapter that short-circuits a hit but reads on a miss is still caught.

The storage contract now has atomicity and relaunch cases

The two new storage break modes have teeth because the contract itself grew two new dimensions (adapters/src/conformance/storage-contract.ts):

Break modeWhat it doesThe case it must redden
storage_torn_writeTruncate โ†’ yield โ†’ fill โ€” exactly what fs::write doesa concurrent read never sees a torn value
storage_forgets_on_relaunchThe store loses all data across a “relaunch”a written value survives a relaunch
  • Atomicity. Concurrent writers against a live reader; every observation must be a whole payload or absent, never a prefix. This is what a durable adapter must pass to be drop-in for the web adapter. A control assertion requires the reader to have observed something โ€” a reader that saw nothing would report no tearing while proving nothing.
  • Relaunch. Three cases โ€” a value, the chat list, and a deletion each survive a second port over the same backing store, supplied by an optional reopen. A store that persisted writes but forgot deletes would resurrect a conversation the user deliberately removed, which is worse than losing one. This is what a durable adapter must pass to be durable. The in-memory fake does not claim durability, so it is not asked to reopen.

The ledger count follows time-contract.ts‘s conditional-branch precedent: EXPECTED_ASSERTIONS = 17, plus DURABLE_ASSERTIONS = 3 when the adapter supplies reopen โ€” 17 + 3 when durable, up from 15. A contract cannot quietly shrink: the last case asserts the exact total and reddens by name if an assertion is deleted.


The Tauri storage adapter {#the-tauri-storage-adapter}

createTauriStorage is run against the same StoragePort conformance contract as the web adapter, through a strict stand-in host that throws on an unknown command or a missing argument. That proves the adapter โ€” command names, argument names, reply handling, namespacing โ€” and not that the filesystem is atomic. The filesystem is store.rs‘s own tests, where there is a real disk to interrupt.

Proved by the contract (a stubbed strict host)Proved by store.rs tests (a real disk)
A written value reads back exactly; listIds returns only its own namespaceThe four-step atomic write path, torn-write behaviour, relaunch durability
The five command names and argument names agree across the seam (tools/storage-seam.test.mjs)Id encoding, namespace allowlist, case-insensitive-filesystem safety

A native host stores chats in the native store, not localStorage โ€” pinned by a native host stores chats in the NATIVE store, not localStorage, and by the paired tauri storage: a written value reads back exactly with listIds returns only its own namespace.


The Shrink Floor

A case with no break mode could still be deleted, so contracts.test.ts counts passing cases from a real run of the fixture โ€” not a regex over source text โ€” and asserts a floor.

contractfloor
secretsโ‰ฅ 14 cases
storageโ‰ฅ 15 cases
timeโ‰ฅ 8 cases
shellโ‰ฅ 35 cases

Raise these numbers when you add cases. A drop means a contract lost coverage, and that is exactly the event worth a red build. Re-derive each per-contract floor from a real none run rather than adding by hand โ€” a new break mode targets an existing case in some contracts and a fresh case in others.

A separate guard pins the break-mode table itself: ADAPTER_BREAKS.length >= 18, raised as new modes land (>= 15 before the secrets durability and presence branches, >= 13 before the two storage durability modes). This counts break-mode rows, not passing cases per contract, so it moves independently of the per-contract floors above.

The shell contract now carries its own spawned break modes (shell_scheme_case_sensitive, shell_negative_insets) as well as this case-count floor, so the Break Modes table above covers every contract, shell included.


The Assertion Ledger

Where the shrink floor pins the number of cases, the ledger pins the number of assertions, so an assertion deleted inside a surviving case still turns the run red.

Each describeXContract takes its own ledger() and uses counting.assert in place of the bare node:assert/strict. The contract’s last registered case reads made() and checks it against EXPECTED_ASSERTIONS. Delete one assertion โ†’ the count is short and the case fails by name. Add one โ†’ the constant must be updated deliberately, which is where you notice the new assertion probably also wants a break mode in contract-fixture.ts.

contractexpected assertionsnotes
secrets16 (+ 6 durable, + 4 presence)A durable adapter (supplies reopen) adds six relaunch assertions; one that can count reads (supplies readsOf) adds four presence assertions
storage17 (+ 3 when durable)A durable adapter (one that supplies reopen) adds three relaunch assertions: a value, the chat list, and a deletion each survive a second port
time8 (+ 2 real-clock)Real-clock adapters add two: nowMs !== epochMs-shaped cases
shell69โ€”

Two contracts branch on a capability. The time contract’s realClock branch adds two assertions (REAL_CLOCK_ASSERTIONS = 2); the storage contract’s durable branch adds three (DURABLE_ASSERTIONS = 3), each checking EXPECTED_ASSERTIONS + (capable ? EXTRA : 0).

The ledger defends itself

The ledger’s own rawAssert.equal(made(), expected) is now the single point of failure, so contracts.test.ts proves it fails when it should.

  • Four tests, one per ledgered contract (secrets, storage, time, shell), read the contract file, remove its first single-line assertion, spawn runAdapterFixture("none"), and require it to go red on the ledger โ€” matched by /made every assertion it is supposed to make/, not incidentally โ€” then restore the file byte-for-byte in a finally.
  • A paired test asserts the untouched none run is green, so a runner that failed regardless could not masquerade as proof.

Why The Fixture Builds Broken Adapters Inline

adapters may not import testing โ€” enforced by tools/depgraph.mjs โ€” so the fixture builds its broken adapters inline, the same way engines/src/contract-fixture.ts does.

ts
function secrets(): SecretsPort {
  const store = new Map<string, string>();
  // The defect: keying by slot alone, so two accounts share one credential.
  const key = (ref: { slot: string; account: string }): string =>
    mode === "secrets_slot_only" ? ref.slot : `${ref.slot}:${ref.account}`;
  // ...
}

Adding A New Case Or Break Mode

  1. Add the contract case with a stable name

    The name is matched by regex, so keep it stable once other code depends on it.

  2. Add the break mode in contract-fixture.ts

    Guard the defect behind a mode === "..." branch.

    ts
    const key = (ref: { namespace: string; id: string }): string =>
      mode === "storage_namespaces_collide" ? ref.id : `${ref.namespace}/${ref.id}`;
  3. Add the pair to the ADAPTER_BREAKS table

    contracts.test.ts maps each mode to the case name it must redden.

    ts
    { mode: "storage_namespaces_collide", expects: /namespaces are isolated/ },
  4. Bump the shrink floor for that contract by one

    A new case raises the floor by one, so a later deletion is caught.

  5. Update EXPECTED_ASSERTIONS by the number of assert.* calls you added

    Do it deliberately. The count is where you notice the new assertion probably also needs a break mode in contract-fixture.ts โ€” the ledger only proves the assertion still runs; the break mode is what proves it has teeth.


Testing utilities โ€” the fake HTTP transport

The testing/ package ships the fakes engine and adapter tests drive against, and two of createFakeHttp‘s behaviours are load-bearing enough that engine tests silently depend on them.

ts
import { createFakeHttp, streamOf } from "praisonai-mobile/testing/fake-http";

const http = createFakeHttp({
  "/chat":  () => ({ status: 200, body: "chat" }),
  "/chats": () => ({ status: 200, body: "chats" }),
});
BehaviourContract
Longest matching suffix winsThe route table picks the longest matching suffix, so /chats does not shadow /chat. Inverting it silently mis-routes a test to the wrong handler while the test still passes.
Unmatched routes are a 404A path no route matches returns status: 404, never a stray handler.
http.sent is orderedThe recorded requests appear in the order they were sent.
streamOf chunks deliberatelystreamOf(text, size?) chunks a body at exactly size (7 by default) and emits no zero-length chunks โ€” a frame split across chunks is the normal case on a real network, and the SSE reader’s pending-CR logic is sensitive to where boundaries fall.
streamOf reassembles byte-for-byteConcatenating the chunks returns the input exactly.

Best Practices

Pin every contract with a break mode, not just a re-implemented assertion

An inline broken adapter that re-implements the assertion proves an assertion of that shape would catch the defect โ€” not that the contract still contains it. Spawn the real contract instead.

Keep the none control green

Without the unbroken control, a fixture that failed everything โ€” a syntax error, a runner that cannot start โ€” would satisfy every break mode while proving nothing.

Count the floor from a real run, not from source text

A regex over test( is satisfied by a case that asserts nothing. Counting passing cases from a real none run pins behaviour, not shape.

Force the reporter

Spawn the fixture with --test-reporter=tap so a test grepping for not ok behaves identically on Node 22 and Node 24.

Prefer an exact assertion count over a floor

A floor lets an assertion be swapped for a weaker one at the same arity โ€” the same hollowing wearing a different hat. Update EXPECTED_ASSERTIONS deliberately when you add or remove an assertion, and while you’re there, decide whether the new one also needs a break mode in contract-fixture.ts.

A contract asserts only what every implementation owes

Behaviour that one adapter alone owes โ€” the Tauri shell deduping identical safe-area payloads, for instance โ€” needs its own test in that adapter’s suite, not the shared contract: the fake and web shells republish, so a shared “does not republish” assertion fails two of three. And a shared harness whose emitInsets takes a full SafeAreaInsets cannot express a partial payload, so the guard for partial payloads is unreachable from the shared harness and its test lives in the Tauri suite. Write it in the shared contract first, and move it the moment the harness cannot carry the input.


Related