posthaste-testkit forward roadmap¶
Status: MOSTLY SHIPPED (forward plan). P0–P3d all landed (spec migration, shared testkit crate, runtime-in-harness + view-settlement recorder, declarative fixtures, mock Gmail IMAP fixture, flicker-diagnosis fixture). Remaining: P4 (posthastectl headless driver — note posthastectl itself now ships via the scripting track) and P5 (proptest invertible-diff laws + profiling in lab artifacts). Folds back into docs/testing/L1 as slices land.
Ephemeral plan. Folds back into docs/testing/L1 as each slice lands, then this
doc is deleted. The [::state planned] / [::state partial] markers in
docs/testing/L1 point here.
Roadmap¶
P0 — Spec migration (done 2026-06-26)¶
docs/testing/{L0,L1}.md created (superseding the legacy testing spec). The lab
domain migration is a separate slice.
P1 — Shared testkit extraction (done 2026-06-26)¶
posthaste-testkit crate with Harness, StalwartFixture, and paths,
lifted from stalwart_provider_parity. stalwart_provider_parity and
stalwart_identity_transport migrated to consume it. No behavior change;
parity tests behave as before.
P2 — Runtime-in-harness + view-settlement recorder (done 2026-06-26)¶
Harness::with_runtime() builds an in-process authority runtime against the
harness config root; ViewSettlement captures the ordered RuntimeFrame stream
a mutation settles through and asserts confirmed / recompute-present /
only-touched-view / session-seq-monotonic. First regression:
keyword_toggle_settles_and_fires_notification_without_re_serving_the_view.
Superseded for mail-list views by option iii (2026-06-27): the runtime no
longer recomputes + re-serves an evaluable mail-list view per event — the client
self-maintains it from the message.updated firehose
([single-source-view-membership]). The first regression now asserts the
option-iii contract (assert_message_updated_notification +
assert_view_not_recomputed); the recompute-present assertions
(assert_view_recomputed_*) remain valid for the still-server-recomputed view
kinds (detail, conversation, account).
Historical finding (pre-option-iii, characterized, not a bug): a run_mutation
keyword toggle on a live mock account recomputed the touched mail-list view
twice — optimistic (rev 2) then sync-confirmed (rev 3) — with a ~16-notification
fan-out between them. The notification fan-out breadth is still worth a future
profiling look.
P3a — StalwartFixture::inject + live scenario (done 2026-06-26)¶
StalwartFixture::inject(count) SMTP-delivers a burst to the dev mailbox via a
single pooled posthaste-imap transport (added send_smtp_messages).
RuntimeHarness::create_jmap_account(id, stalwart) creates a JMAP account
pointed at the fixture and syncs it; RuntimeHarness::watch_view + ViewWatch
is the sync-driven drain that stays subscribed across an external action and
waits until a snapshot satisfies a predicate.
The live scenario (twenty_injected_messages_converge_into_inbox_view) injects
20 messages and asserts they converge into the inbox view. Earlier flagged as
"blocked on a runtime read-path gap" — the actual cause was a test-form
mismatch: the test queried in:<account>/inbox (bare name), but a live-synced
mailbox's MailboxId is namespaced (e.g. jmap:<blob>), never the bare
"inbox" the mock path seeds. The app queries by the actual id
(apps/web/src/runtime/httpAdapter.ts scopeQuery); the test now resolves the
inbox's real MailboxId via list_mailboxes to match. The read path was never
broken; the scenario passes (initial 4 seeded + 20 injected = 24, converged).
P3b — Declarative fixtures (done 2026-06-26)¶
Typed Fixture / FixtureAccount / FixtureMessage TOML loaded via
RuntimeHarness::load_fixture_toml / load_fixture, driving
create_mock_account + a new typed seed_messages_typed (the tuple
seed_messages now delegates). FixtureMessage layers declared field
overrides (subject / from / preview / received_at / size / keywords /
thread_id / rfc_message_id) on a shared default_message baseline (single
source of truth). Only driver = "mock" is supported; driver = "jmap"
returns a typed FixtureError::UnsupportedDriver and lands with the live
read-path (the P3a gap). First test:
declarative_fixture_loads_accounts_and_messages_into_the_view.
P3c — Mock Gmail IMAP fixture (increments 1–3 done 2026-06-27)¶
A hand-rolled TCP mock Gmail IMAP server (matching the spawn_fake_smtp_server
pattern) that advertises X-GM-EXT-1 + CONDSTORE + QRESYNC + IDLE +
UIDPLUS + ENABLE, so the real imap-client negotiates them end-to-end.
This closes the gap the canned-data gmail_labels tests leave: those feed parsed
structures into the batch builders and never exercise capability negotiation,
FETCH parsing, or CONDSTORE/QRESYNC deltas at the protocol layer.
- Increment 1 (done): mock server core (greeting +
CAPABILITY+AUTHENTICATE PLAIN+LIST) + a test asserting the realimap-clientpicks upX-GM-EXT-1/CONDSTORE/QRESYNC,ProviderKind::Gmailis selected, and Gmail mailboxes list with correct roles ([Gmail]/All Mail→ Archive). Hand-written IMAP responses; lives inposthaste-imapdiscovery tests. - Increment 2a (done):
SELECT/EXAMINE(QRESYNC state:UIDVALIDITY+HIGHESTMODSEQ+EXISTS) +UID FETCHencodingUID+RFC822.SIZE+RFC822.HEADER(literal) +X-GM-MSGID+X-GM-THRID+X-GM-LABELSvia the forkedimap-codecResponseCodec. A protocol-layer test drives the realimap-client→examine_selected_mailbox→fetch_selected_mailbox_headers(fetch_gmail_metadata=true)and asserts theX-GM-LABELS(incl. a quoted multi-word label"Project Alpha") round-trip ontoImapMappedHeader.gmail_labels. Still lives inposthaste-imapdiscovery tests (not yet promoted toposthaste-testkit). - Increment 2b (done): a stateful, multi-connection
GmailImapFixtureinposthaste-testkit(src/gmail.rs) that answers the full discovery + sync command set (CAPABILITY/AUTHENTICATE/LIST/STATUS/EXAMINE/UID SEARCH/UID FETCH) the real gateway drives across its discovery, then sync, connections.RuntimeHarness::create_gmail_accountwires anImapSmtpaccount at it, enables it (discovery), and syncs; a self-contained test (tests/gmail_inbox_sync.rs) asserts the seeded INBOX message lands in the store and surfaces in themailListview (queried by the inbox's real namespacedMailboxId, like the JMAP live test). The fixture deliberately omitsIDLEfrom its caps so the gateway skips the background push stream and the explicitsync_accountdrives a deterministic full-snapshot fetch; data model is one Gmail-labeled message in INBOX, other mailboxes empty. IDLE-push parity is later scope. (The single-shot protocol mock for increments 1/2a stays inposthaste-imapdiscovery tests — a different test level;posthaste-imapcannot depend onposthaste-testkit.) - Increment 3 (done):
CONDSTORE/QRESYNCdeltas.GmailImapFixturenow holds a shared, mutableInboxModel;vanish_inbox_and_deliver(subject)expunges the live message and delivers a new one, advancingHIGHESTMODSEQ.RuntimeHarness::sync_accountre-syncs; the gateway'sSTATUSpreflight sees the changed MODSEQ, then takes the QRESYNC delta path (ENABLE QRESYNC→UID FETCH 1:* (CHANGEDSINCE <modseq> VANISHED)). The mock answers with a changedFETCH+VANISHED, and the test asserts the view replaces the vanished message with the delivered one and that aCHANGEDSINCEfetch was actually issued (changedsince_fetch_count), proving the delta path — not a full re-snapshot — drove the convergence.
The forked imap-codec/imap-types (vendored under vendor/, built from the
GitHub rev — vendor/ is reference-only) drive FETCH encoding. Quirks the
fixture had to work around, in increasing severity:
Textencodes raw bytes with no quoting — multi-word X-GM-LABELS must be pre-quoted in the mock.ModSeqencoder bug (encoder-only, not a production risk): the encoder emitsMODSEQ <v>but the decoder (correctly, per RFC 7162) requiresMODSEQ (<v>). Production only decodes (client side) and real Gmail sends the parenthesized form, so this never bites production — but our mock, which encodes responses, must spliceMODSEQ (<v>)in by hand (the per-message MODSEQ is required: the stored HIGHESTMODSEQ watermark is derived from it, so without it the next sync never takes the delta path).VANISHEDdecoder bug (likely a real production defect): the fork'sresponse_dataonly reaches itsVANISHEDarm throughmessage_data, which requires a leading<nz-number> SP. A standard* VANISHED (EARLIER) <uids>(no number — per RFC 7162 and the fork's own encoder) therefore fails to decode and drops the connection ("stream error"). The mock works around it by emitting a dummy leading sequence number (* 1 VANISHED (EARLIER) <uids>), which the QRESYNC task ignores. This means QRESYNC expunge sync against real Gmail likely errors on the firstVANISHEDresponse. Verify against a real Gmail account (or extend the Stalwart parity test to drive QRESYNC), then fix the fork decoder to accept the number-lessVANISHEDform and bump the patched rev.
P3d — Flicker diagnosis fixture (done 2026-06-28; flicker resolved .23/.24)¶
A runtime-layer flicker harness: drive a real mutation through the runtime while
a provider sync re-serves the view, capture the emitted RuntimeFrame stream
(Layer A, RuntimeHarness::open_capture → FrameCapture::drain), and replay
it through the real posthaste_link_replica::EntityStore reconciliation
(Layer B, ReplicaProbe — the same optimism/absorption code the browser runs
via WASM), recording the per-row render trajectory. FlickerLog::assert_no_flicker
catches any observable field reverting or a row disappearing-then-reappearing.
No browser, no posthastectl — the flicker-prone logic (EntityStore + runtime
frame emission) is all Rust.
tests/mutation_flicker.rs: flag + mark-read on the mock driver's canned dataset, each interleaved with the driver's background poll re-serving the whole view. The mark-read case is the sharpest: the re-serve carries the message unread (stale) on every poll, yet the optimism holds (absorption- gated retire never retires against a base that lacks the effect). Finding: both are flicker-free — the runtime + EntityStore reconciliation does not flicker for flag/read during a stale re-serve. This narrows the residual nightly flicker to either (a) scenarios not yet reproduced here (real-async provider lag, delete row reappear, multi-message reorder, count/badge), or (b) Layer C — the TSentityStoreAdapterwiring / the legacy REST domain-cache path racing the WASM replica / React render timing.- Layer C (done 2026-06-27, root cause found):
buntests driving the real WASMEntityStore(apps/web/test/replicaAbsorptionRetire.test.ts) caught the flicker withoutposthastectl/Playwright. Two coupled bugs inlink-replica/link-core(see theflicker-root-causememory): (1) no staleness guard oningest_batch— a late provider-sync re-serve (snapshot pre-dating the mutation) clobbers a confirmed+retired op's base with nothing to fold it back → the row reverts → flicker (true->false->trueonce the next sync corrects it); (2) absorption-retire broken for real projections —retire_absorbedcomparesMessageFoldStatekeywordVecs order-sensitively, butfold_state_from_projectionkeeps provider order whileapply_message_assertionsorts viaBTreeSet, so ops on any message carrying$seen(≥2 keywords) never retire (stuck-folding = protective). That makes the flicker intermittent: only messages where absorption works (single/sorted keywords, e.g. flagging an UNREAD message) retire and become vulnerable to (1). Fix (validated by rebuilding wasm with sorted keywords): sort keywords infold_state_from_projection(or set-insensitiveMessageFoldStateeq) and add an ingest staleness guard — (1) is unmasked on more messages if only the absorption fix lands. Owned by views-stability; not landed here. - Note: the existing fake-handle adapter test + the Rust unit tests miss both bugs (fake handle mirrors only minimal-keyword cases; unit tests use ≤1 keyword). The gap was the real adapter + real WASM + realistic projection integration — exactly what the bun real-WASM probe exercises.
- Mock-driver notes surfaced:
AccountDriver::Mockserves a canned dataset (em-001..em-003inmb-inbox/mb-archive) and re-serves it on every sync plus a ~500ms background poll; it rejects mutations on unknown message ids (operation.settled: failed). A directseed_messagesbatch is wiped by the first mock sync. - Layer D (done 2026-06-28): a React render-layer probe —
RenderProbe/RenderLog(apps/web/test/renderProbe.tsx, designdocs/eph/DESIGN-L2-render-flicker-tracker.md) — drives the realcreateEntityStoreAdapter+ real WASM + realuseUndoRedo+ real React render (no hand-ported adapter glue: "instrument, don't duplicate"). 9 tests; verified the equal-version stale re-serve fix end-to-end at the render layer. - Playwright e2e (done 2026-06-28):
apps/web/e2e/archive-flicker.mjs+undo-flicker.mjs— rAF (painted states) + MutationObserver (childList add/remove) capture against the real dev stack (just dev web). The honest tool for real React commits (act-wrapped tests structurally can't reproduce a transient commit). Confirmed the archive/undo flickers do NOT reproduce in .23; caught the "All Inboxes" smart-mailbox staleness bug (fixed via theclient_self_maintainedflag, shipped .23). - P3d complete: all reported nightly flickers resolved in .23/.24 (the
version-gated retire + archive role resolution + absorption fixes + option-iii
client_self_maintained). The fixture suite (Layers A–D + Playwright) is the regression guard.
P4 — posthastectl headless driver¶
Dev/lab-only client of the daemon API: health wait, settings get/patch,
accounts list, events wait, fixture load, state dump. Composes with
posthaste-lab (lab invokes ctl as a runner). Distinct from lab: ctl drives a
running app's API; lab orchestrates runs and collects artifacts.
P5 — Property tests + profiling in lab artifacts¶
proptest for the invertible-diff laws (apply(apply(s,d), d.inverse()) == s)
and replica convergence under reordered assertions. Wire posthaste-bench
artifacts into lab run manifests.
Open findings (not blockers)¶
stalwart_provider_parity::stalwart_jmap_and_imap_sync_project_equivalent_fixture_messagesis currently red underPOSTHASTE_STALWART_INTEGRATION=1: JMAP lists the trashed "Build failure on obsolete branch" (.Deleted Items); IMAP does not. Reproduces onmain(pre-P1), so it is a pre-existing provider-parity bug hidden by the integration gate, not a testkit regression. The settlement recorder (P2) and a focused Deleted-Items parity case are the right tools to diagnose it.