Skip to content

Latest commit

 

History

History
413 lines (328 loc) · 24.6 KB

TESTING_COVERAGE.md

File metadata and controls

413 lines (328 loc) · 24.6 KB

Testing coverage map

What is tested, at which layer, and — the part that matters — what is not.

This exists because the protocol is far better tested than the glue around it, and that imbalance is invisible from a passing CI run. Every production bug in device sync so far has been in a layer this document lists as uncovered.

Keep it current. When you add a test, add the row. When you find a blind spot, write it down even if you are not fixing it today — an admitted gap is worth more than a forgotten one. When you fix a bug, ask which row would have caught it, and if the answer is "none", that is the row to add.


How to read this

Coverage is split by layer, because the same flow can be well covered in one and absent in another:

Layer Meaning
protocol atomic_lib sync engine — the bytes on the wire
glue the code wrapping the protocol: HTTP handlers, the Flutter bridge, browser helpers
flow what a user actually does, end to end, through a UI

A flow is only genuinely safe when all three are covered.

Playwright light vs full

Only the browser suite splits. Lint, Rust, vitest, JS integration, and Flutter run on every CI job.

Trigger Playwright
Feature-branch push light (@smoke), required
develop push full, required (staging)
stable v* tag full, required (production)
workflow_dispatch e2e_mode=full, [full-e2e] in the commit, or PR label full-e2e full

Tag a new journey @smoke (smoke from browser/e2e/tests/test-utils.ts) only if a failure means the first-hour demo is dead. Extra operators, templates, and offline variants stay in the full suite. Policy: planning/e2e-light-heavy.md.


Where the suites live

Suite Command CI job
atomic_lib unit + integration cargo nextest run -p atomic_lib --features db-redb,iroh,ws rustTest
Server integration cargo test -p atomic-server --test it <module> rustTest
Browser unit (vitest) cd browser && pnpm run -r test jsTest
Browser integration (vitest + real server) cd browser/lib && pnpm run test:integration jsTestIntegration
Browser e2e light (@smoke) cd browser && pnpm run test-e2e:light endToEnd on feature branches
Browser e2e full cd browser && pnpm run test-e2e endToEnd on develop and v* tags
Flutter Dart cd flutter && flutter test flutterTest
Flutter Rust bridge cargo test --manifest-path flutter/rust/Cargo.toml flutterTest

CI runs cargo nextest run --workspace --exclude atomic-server-tauri --no-default-features --features light. Feature unification pulls in db-redb + iroh, so feature-gated sync tests do run there.

Two things worth knowing about the runners:

  • flutter/rust is excluded from the workspace (root Cargo.toml), so --workspace never compiles it. It is covered only by the explicit --manifest-path step in flutterTest.
  • .config/nextest.toml sets retries = 2. A flaky test passes CI silently. Check for FLAKY in nextest output, not just the summary line.

Sync and pairing

Protocol — well covered

Flow Where
Two Iroh nodes reconcile (bulk + live) lib/src/sync/iroh_e2e.rs (13 tests, real QUIC)
Stroke appended after sync propagates lib/src/sync/iroh_e2e.rs
A peer only receives what its agent may read iroh_e2e.rs, peer.rs
A peer cannot forge a third agent's resource iroh_e2e.rs
Relayed write accepted only for a drive we own and dialled peer.rs
Iroh accept side refuses any frame before AUTH (ERROR + closed stream), binds AUTH.requestedSubject to the handshake drive peer.rs (accept_gate_tests, raw QUIC stream)
Rejected SYNC_PUSH answers ERROR SYNC_REJECTED, never SYNC_OK peer.rs (accept_gate_tests), server/tests/it/ws_auth_gate.rs
WS: writes and identity-bearing subscriptions need AUTH; anonymous SUB on a public drive still works; unreadable subscriptions answer ERROR UNAUTHORIZED_READ server/tests/it/ws_auth_gate.rs
Missing-drive bootstrap (OQ5): Public never creates a drive, Owner mode enrolls only the owner, open node admits an authenticated first-sync lib/src/sync/engine.rs (bootstrap_and_sub_tests), peer.rs (live_write_admission_tests)
Engine-owned SUB/UNSUB: granted SUB is a session command, unreadable SUB answers ERROR UNAUTHORIZED_READ lib/src/sync/engine.rs (bootstrap_and_sub_tests)
Signed SYNC_DIFF.removeCommits: envelope applies regardless of connection agent, tampered envelope does not delete, envelope only handed to drive readers, replay after re-creation refused lib/src/sync/peer.rs (initiator_trust_tests), engine.rs (bootstrap_and_sub_tests), tombstones.rs, protocol.rs
SyncSession over an in-process AtomicTransport holds AUTH across frames lib/src/sync/session.rs
Signed envelopes per resource: latest/all retention, time order, not indexed, verified attribution per Loro token, tampered envelope unverified, two writers, destroy fold lib/src/envelopes.rs
GET /history-attribution names the verified signer and is read-gated server/tests/it/history_attribution.rs
Attribution parse / version lookup / server+local merge browser/lib/src/history-attribution.test.ts
Engine-level two-store sync, private drives, blobs, live push lib/src/sync/tests.rs
RBSR reconciliation, drive hashing lib/src/sync/rbsr.rs, tests.rs
RBSR finds a remote-only subject sorting below every local one lib/src/sync/rbsr.rs and browser/lib/src/rbsr.test.ts (regression, see below)
Remote update merge, drive-spoof rejection, tombstones lib/src/sync/ws_apply.rs, tombstones.rs
Pairing envelope encode/decode browser/lib/src/pairing.test.ts

Cross-process — covered since 2026-07

Both matter because iroh_transport holds the router and node identity in process globals; anything sharing a process shares one node.

Flow Where
Drive reconciles across a real OS process boundary lib/tests/cross_process_sync.rs
Iroh NodeID survives an unclean kill (abort(), no flush) lib/tests/identity_durability.rs
Paired peer + its relay/direct addresses survive a kill lib/tests/identity_durability.rs
Two whole servers pair via POST /iroh-sync and reconcile server/tests/it/iroh_pairing.rs
/iroh-sync refuses malformed node ids with a UI-showable error server/tests/it/iroh_pairing.rs

Glue

Flow Where Note
Browser records a peer and calls /iroh-sync data-browser/src/helpers/pairing.test.ts stubbed fetch
Known-peer store: labels, dedupe, corrupt data, quota data-browser/src/helpers/knownPeers.test.ts
forgetServerPeer signs the exact ?node= URL, and fails soft data-browser/src/helpers/managedServer.test.ts mocked signRequest
Opening a foreign HTTP drive does not move serverUrl browser/lib/src/store.set-drive.test.ts bare origin still switches the server; path-bearing HTTP is a drive
Canvas editing session merges a peer's stroke flutter/rust/src/api/simple/tests.rs
Whole-list rewrite (erase/undo) keeps a peer's stroke flutter/rust/src/api/simple/tests.rs
Bridge start_peer → add_known_peer → peer_sync pushes a drawing to a real remote process flutter/rust/src/api/simple/peer_tests.rs receiving side writes the receipt
Bridge known-peer bookkeeping (add / rename / dedupe / forget) flutter/rust/src/api/simple/peer_tests.rs
Bridge peer_sync to an unreachable node errors rather than hanging flutter/rust/src/api/simple/peer_tests.rs
POST /iroh-sync request shape, both sides testdata/pairing-request.json + pairing.test.ts + iroh_pairing.rs shared fixture binds them
Dart pairing-code parser, peer-sync result formatting flutter/test/atomic/ pure parsers
Rotation does not treat a metrics-change pop as "back to gallery" flutter/test/canvas/rotation_pop_test.dart
AtomicNode: mutate on one node, apply_commit(IngestPolicy::Peer) on another, query + DbEvent reflect it lib/src/runtime/node.rs in-process, no transport; LocalCache skips signature check, Peer does not

Local full-text search

Flow Layer Where
Exact title, prefix typeahead, 1-edit typo (avacado→avocado) protocol lib/src/search/tests.rs
Title ranks above description; parent/drive scope; Loro body text protocol lib/src/search/tests.rs
Update replaces old title; delete drops postings; commits skipped protocol lib/src/search/tests.rs
Tokenizer + prefix-Levenshtein protocol lib/src/search/tokenize.rs, fuzzy.rs
Query latency vs N (1k / 10k / 50k) protocol lib/benches/search_bench.rs (--features db-redb)
Store.search offline hits ClientDb.search JS browser/lib/src/store.test.ts

Not covered: table contains; Playwright search overlay on the KV path; Flutter bridge search. Hosted /search is atomic_lib::search (Tantivy and MiniSearch are gone). Offline E2E polls ClientDb.search. Filters (isA, tags) are covered by lib/src/search/tests.rs and server/tests/it/file_search_repro.rs.

Flow — the thin layer

Flow Where
Pairing code renders, is a routable envelope, carries no secret browser/e2e/tests/sync-devices.spec.ts
Pasting a code: form gated to the app, malformed refused without dialling, node's refusal shown, success reports what synced, peer remembered browser/e2e/tests/pairing-dialog.spec.ts
Paired-device cards render, expose a way to forget, and hide undialable entries browser/e2e/tests/pairing-dialog.spec.ts
Copy pairing code sync-devices.spec.ts
Add-a-device form validation sync-devices.spec.ts
Sync page status renders browser/e2e/tests/sync.spec.ts
Offline edits persist and sync on reconnect sync.spec.ts
Second device cold-loads a drive from the server second-device-load.spec.ts

Blind spots

Ordered by how much they would hurt.

1. No cross-runtime peer test above the bridge

Canvas's sync is now covered against a real remote process (peer_tests.rs), which is Canvas ↔ Desktop at the code level — both sides run the same atomic_lib peer, and there is only one Iroh implementation, so the wire protocol between any two surfaces is the same well-tested code.

What is still untested is everything above the bridge: the Dart call sites, Flutter's lifecycle, the Tauri wrapper, and the browser driving a real node. Android-specific behaviour (backgrounding, process death, 16 KB pages) has no automated coverage at all and is still hand-verified on devices.

2. peer_announce and discovery from the bridge

peer_announce and peer_discover_sync remain untested — they depend on pkarr relay reachability, which the bridge tests deliberately short-circuit by handing addresses over directly. pkarr_discovery_and_iroh_sync covers discovery in atomic_lib, but not through the bridge.

3. Remaining one-sided contracts

POST /iroh-sync is now bound by a shared fixture (testdata/pairing-request.json): the browser test asserts it sends that body, the server test asserts it accepts it, and renaming a field fails both.

/forget-peer is covered on both sides now — iroh_pairing.rs for the handler (unsigned refused, full pair → listed → forget → gone lifecycle) and managedServer.test.ts for the client (signs the exact ?node= URL). They are not bound by a shared fixture the way /iroh-sync is, so a rename would still pass both; the query-parameter name is asserted literally in each.

Unbound: the nodeId property on /server as consumed by the browser — the replacement for /iroh-node-id.

4. Tauri-gated UI

ConnectToDeviceForm (paste a code) and the pairing dialog are now covered — isRunningInTauri() only checks for window.__TAURI_INTERNALS__, so page.addInitScript reaching it is enough, and nothing on that path calls invoke. See pairing-dialog.spec.ts.

Paired-peer cards are covered too, by seeding atomic-peers in an init script.

Still uncovered: PairingLinkHandler's deep-link entry (the system camera launching the app) and IdentityReconcileGate. Anything that genuinely calls invoke needs a real desktop harness, not a faked global.

Known wart, not a test gap: PairingLinkHandler drops input that does not start with atomic:// or did:ad:node:, so pasting something that is not a URI reports nothing at all — no dialog, no error. Only malformed input that is URI-shaped reaches the flow and gets a message.

5. QR camera path

scanPairingCode.ts and the camera flow: untested at every layer.

6. Ephemeral / presence over Iroh

No producer or consumer exists (EPHEMERAL 0x40 is WS-only), so there is nothing to test yet. Listed so it is not mistaken for covered.

7. Flutter integration_test is effectively dead

One 13-line smoke test, never run in CI — the pipeline has no emulator.

8. Known residual races

None outstanding. The concurrent-writer bug that lived here — a local edit racing a peer update lost ~⅓ of all operations, because both paths read-modify-write the same Loro snapshot and end in a replace — was fixed 2026-07-20 with a per-subject lock (lib/src/subject_lock.rs). Regression test: lib/tests/concurrent_commit_and_peer_apply.rs, which lost 53–56 of 80 operations before the fix and now keeps all of them, with a sequential control that isolates concurrency as the cause.

No known flaky tests. The one that was (rbsr_reduced_matches_full_sync_vv) turned out to be a genuine RBSR bug, not test noise — see below.


Things that are not what they look like

Recorded because each one cost real debugging time.

  • push_stroke + save_locally cannot lose a peer's op. The commit is imported into a freshly-read store doc and Loro import never removes ops. The damage from a stale editing session comes from reads — index-based deletes and whole-list rewrites — not from the append. A test written the obvious way passes with and without the fix.

  • A test child process must not drop its Db. redb's Database::drop closes cleanly and makes pending Durability::None commits durable, so a durability test that lets the store drop is testing a graceful shutdown. std::mem::forget it before abort().

  • Servers in one process share an Iroh node. They all advertise the same node id regardless of whose store holds the data. Multi-server Iroh tests must use subprocesses, and the test process itself must run no server.

  • --exact filters need the module path in the single-binary it suite (iroh_pairing::child_runs_a_second_server, not the bare name).

  • A leaked child server silently corrupts later runs. Own it with a Drop guard so a panicking assertion still kills it.

  • The bridge's tests share one drive. DB is a OnceLock and every test works in the same drive, so "find a canvas with strokes" matches a neighbour's drawing. Assert on a specific subject, and never change the active drive from a test.

  • Known peers are stored under a normalised node id, not the did:ad:node: form the UI passes in. Look them up with normalize_node_id, or the lookup silently finds nothing.

  • A lock keyed only by subject couples unrelated stores. populate() seeds well-known subjects that are byte-identical in every store, so a global registry makes two independent Db instances — including two tests sharing a process — wait on each other for no reason. SubjectLocks therefore lives on the Db, and every clone of a store shares one registry.

  • Measure a suspected regression on a quiet machine. A test that looked newly flaky right after a four-minute stress run was passing 20/20 once the machine was idle. Compare against a stashed baseline under the same conditions before concluding you caused something.

  • A flaky test can be a real bug wearing a costume. rbsr_reduced_matches_full_sync_vv failed ~1 run in 3. It was not noise: reconcile_range anchored its first child range at the first local key instead of the range's own lo, leaving [lo, first_local) covered by no child at all. A subject the remote had and we lacked, sorting below everything we held, was dropped from the diff and would never have synced. It looked intermittent only because the test's subjects are content-derived DIDs, so whether one landed in the dead zone varied per run — and retries = 2 meant CI almost never showed it.

    The TypeScript port (browser/lib/src/rbsr.ts) had the same off-by-one, and it is live: websockets.ts uses it to compute the subjects filter a browser client sends the server, so an affected resource was never pulled. Both were fixed 2026-07-20, each with two deterministic regression tests.

    Treat a flake as an unread bug report until proven otherwise — and when an algorithm is ported, check the port for the same defect.

  • An empty local-DB collection page used to drop its aggregates. Count=0 is a real statistic (and sum=null is too). Leaving collection.aggregates unset made dashboard/table totals render an em-dash forever, because the follow-up ResourceUpdated never came — the rows were already in the JS store. Guard: collection-empty-trust.test.ts, plus the dashboard e2e that waits for 946.5 / 4 rather than the placeholder.

  • Opening a filled table (and the sidebar) flashed as if order changed. Two independent paints: (1) WASM parent= queries are unsorted; hydrating each member notifies ResourceUpdated, and useCollection optimistic-added them in arrival order before client-side sort wrote the page. Guard: collection-page-assemble.test.ts. (2) The sidebar fetched children while isA was still empty, so every table row appeared in the tree until the class arrived and hid them. The ResourceSideBar now treats unknown class as hide-children. OPFS cold-load could also shuffle array props (requires/recommends) by seeding a new LoroList from JSON-AD then merging the snapshot; importLoroUpdate(snapshot, true) replaces instead. Guard: resource.test.ts ("importing a snapshot over a cache-seeded doc").

Algorithms mirrored in two languages

lib/src/sync/rbsr.rs ↔ browser/lib/src/rbsr.ts are line-for-line ports and must compute the same differing set on either end of the wire. Both carry the same test names. A fix to one is a fix to the other; the golden-vector tests (item_fingerprint_matches_golden_vector) pin the hashing, but the traversal is only kept in step by mirroring the tests, so do that deliberately.

lib/src/genesis.rs ↔ browser/lib/src/genesis.ts also share a personal-drive derivation (personal_drive_subject / personalDriveSubject). The cross-lang vector (personal_drive_cross_lang_vector) pins the nonce, signature, and DID.

Unified actions

Flow Layer Where
⌘K action section: prefix/keyword match, cap, available/disabled, no mid-word / resource-name hits glue browser/data-browser/src/actions/matchActions.test.ts
Shortcuts overlay / /app/shortcuts list equals appActions + resourceActions that carry a shortcut glue browser/data-browser/src/actions/catalog.test.ts
asTool verbs derive AI tools; execute calls run and respects available glue browser/data-browser/src/actions/deriveTools.test.ts
⌘K shows a matching action and runs it; a resource-name query shows none flow browser/e2e/tests/command-palette-actions.spec.ts
? overlay lists registry shortcuts; \ toggles the sidebar flow browser/e2e/tests/shortcuts.spec.ts
⌘M searchable menu + ⌘↑ parent from the registry flow browser/e2e/tests/resource-context-menu.spec.ts
Parent action stays available on a non-drive stub and fetches parent at run glue browser/data-browser/src/actions/resourceActions.parent.test.ts

Not covered: derived AI tools invoked through a real model; MCP protocol projection (no Atomic MCP server yet); collapsing specialized destroy() call sites (table rows, views, tags) onto the resource delete action.

Documents

Flow Layer Where
V1 element list + paragraph markdown (+ resource embed) → TipTap JSON; leftover Yjs XmlFragment walker; { type: 'ydoc' } detection without loading yjs glue browser/data-browser/src/views/Document/documentMigrationUtils.test.ts
Opening a writable v1 document migrates it silently into the Loro editor (no "Update Document" button) flow browser/e2e/tests/documents.spec.ts

Not covered: leftover Yjs-era DocumentV2 bodies end-to-end (needs a stored { type: 'ydoc' } fixture); read-only v1 documents stay on the element list and have no e2e.

Commits as envelopes

Flow Layer Where
LoroDoc values are not KV-index keys protocol lib/src/values.rs::loro_doc_is_not_indexed
Content commits are not stored; genesis/ACL/destroy are protocol lib/src/db/test.rs::content_commits_are_not_stored
Sequential saves do not chain previousCommit; commit DIDs are not store resources glue browser/lib/src/commit.test.ts

Personal drive identity

Flow Where
Same agent key → same personal-drive DID lib/src/genesis.rs, browser/lib/src/genesis.test.ts
Cross-language personal-drive vector genesis.rs + genesis.test.ts
Repeat genesis for that DID merges Loro state lib/src/commit.rs::repeat_personal_drive_genesis_merges
Repeat genesis without a cert is still rejected lib/src/commit.rs::repeat_genesis_without_cert_is_still_rejected
createDrive({ personal: true }) uses the derived DID browser/lib/src/store.personal-drive.test.ts
Two stores with the same key mint the same subject store.personal-drive.test.ts
Extra drives are listed on the derived personal drive store.personal-drive.test.ts
Extra drive created offline drains on reconnect (genesis must not set a rewind baseline) browser/lib/src/offline-create-drain.test.ts
Lists from a previous random-DID home are unioned onto the derived drive store.personal-drive.test.ts
Agent.personalDriveSubject matches the genesis helper agent.test.ts
Db::setup / ensure_personal_drive use the derived DID and are idempotent lib/src/db.rs::personal_drive_tests
Extra Db::create_drive is listed on the personal drive lib/src/db.rs::personal_drive_tests

Not covered: Flutter create_drive still mints a random DID (the Rust ensure_personal_drive helper exists for setup()). E2E sign-in on a second machine with the old machine offline.

Cloud Vault display metadata: vaultAutoBackup.test.ts verifies name/emoji enrollment and refresh after edits; SaaS enrollment_display_metadata_refreshes_and_survives_legacy_clients verifies persistence and account ownership.

Cloud Server setup

  • data-browser/src/helpers/managed/cloudSync.setup.test.ts: missing placement, source-server replication and refusal, assigned-server connection ordering, and failed connection without local-drive promotion.

  • data-browser/src/helpers/managed/reconcile.test.ts: pending/empty placements do not switch the app away from its source.

  • Paired atomic-saas/portal/e2e/server-setup.spec.ts: setup opens the selected existing drive, never creates a content-free enrollment in the portal.

  • Paired atomic-saas/portal/e2e/server-hosting-live.spec.ts: opt-in real sign-in, grant, signed enrollment, setup UI, source replication and destination HTTP read. Requires two isolated nodes and dev magic links (ATOMIC_HOSTING_LIVE=1). Verified with plain and managed destinations. ATOMIC_HOSTING_MANAGED=1 additionally checks Active usage receipts and the switcher state. Production deployment and Desktop/Tauri replication are not runtime-tested.

  • Hosting consent: cloudSync.setup.test.ts refuses transfer/enrollment without an explicit agreement; paired SaaS HTTP tests enforce and record version 1.

  • driveHostingState.test.ts covers Local/Remote, empty placement, combined Server/Vault, disabled, paused and unknown states. Paired SaaS drive-switcher-hosting.spec.ts checks menu rendering and refresh/error behavior against mocked receipts in the running browser app.

  • Billing return: enrollment.test.ts checks the typed 402 response; paired SaaS server-billing-live.spec.ts follows a free account through mock checkout, back to the selected drive, then explicit consent, replication and an authenticated read from the real managed node. Plan purchase alone creates no enrollment. Real Stripe-hosted test-card checkout remains a deployment check.