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.
Quick Start
- 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()); - 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
nonecontrol: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 - Check the floor and the control
contracts.test.tscounts passing cases from a realnonerun and asserts a floor per contract: secrets โฅ 14, storage โฅ 15, time โฅ 8, shell โฅ 35. Thenonecontrol must pass green, so a fixture that failed for an unrelated reason cannot masquerade as proof. - 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.
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.
| before | after | |
|---|---|---|
| assertions deletable with a green run | 62 / 73 | 0 / 73 |
| break modes | 13 | 13 |
| assertions actually protected | 11 | 73 |
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.
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__ carries | isPresent() |
|---|---|
both invoke and transformCallback | true |
| only one of the two | false (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().
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| Test | Where | Asserts |
|---|---|---|
"the TAURI shell reports the keyboard height, without any native event" | contracts.test.ts | The 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.ts | The 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 reading | adapters/src/tauri/native-insets.test.ts + tools/shell-seam.test.mjs | Driven 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 mode | must redden the case named |
|---|---|
secrets_slot_only | two ACCOUNTS in one slot are two different secrets |
secrets_empty_is_absent | an empty string is a stored value, not an absence |
storage_missing_is_undefined | a missing key reads as null, never undefined |
storage_namespaces_collide | namespaces are isolated |
storage_empty_is_absent | an empty string is a value, not an absence |
storage_torn_write | a concurrent read never sees a torn value |
storage_forgets_on_relaunch | a written value survives a relaunch |
time_every_fires_once | every() repeats, rather than firing once |
time_clear_does_nothing | a cleared timer does not fire |
time_unsubscribe_does_nothing | the unsubscribe actually stops it |
shell_scheme_case_sensitive | an uppercase JAVASCRIPT: URL is refused |
shell_negative_insets | no 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.
| Branch | Supplied via | Cases | What it catches |
|---|---|---|---|
| durability | reopen(secrets) โ a second port over the same store | 4 | A keychain that forgets when the process ends is not a keychain: the user re-types the key on every launch. |
| presence | readsOf(secrets) โ how often a value was read out | 1 | has() 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):
a stored secret survives a relaunchpresence 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.a deletion survives a relaunch tooโ a store that persisted writes but forgot deletes would resurrect a credential the user removed.a FRESH store has none of another store's secretsโ the control that stops a single process-wideMapfrom 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 mode | What it does | The case it must redden |
|---|---|---|
storage_torn_write | Truncate โ yield โ fill โ exactly what fs::write does | a concurrent read never sees a torn value |
storage_forgets_on_relaunch | The 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 namespace | The 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.
| contract | floor |
|---|---|
| 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.
| contract | expected assertions | notes |
|---|---|---|
| secrets | 16 (+ 6 durable, + 4 presence) | A durable adapter (supplies reopen) adds six relaunch assertions; one that can count reads (supplies readsOf) adds four presence assertions |
| storage | 17 (+ 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 |
| time | 8 (+ 2 real-clock) | Real-clock adapters add two: nowMs !== epochMs-shaped cases |
| shell | 69 | โ |
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, spawnrunAdapterFixture("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 afinally. - A paired test asserts the untouched
nonerun 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.
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
- Add the contract case with a stable name
The name is matched by regex, so keep it stable once other code depends on it.
- 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}`; - Add the pair to the ADAPTER_BREAKS table
contracts.test.tsmaps each mode to the case name it must redden.ts { mode: "storage_namespaces_collide", expects: /namespaces are isolated/ }, - Bump the shrink floor for that contract by one
A new case raises the floor by one, so a later deletion is caught.
- 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.
import { createFakeHttp, streamOf } from "praisonai-mobile/testing/fake-http";
const http = createFakeHttp({
"/chat": () => ({ status: 200, body: "chat" }),
"/chats": () => ({ status: 200, body: "chats" }),
});| Behaviour | Contract |
|---|---|
| Longest matching suffix wins | The 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 404 | A path no route matches returns status: 404, never a stray handler. |
http.sent is ordered | The recorded requests appear in the order they were sent. |
streamOf chunks deliberately | streamOf(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-byte | Concatenating 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.