hammer-editor/integrationTests/README.md
Adam Brown 17e56e4863
Fix fresh-sync re-download and project-data conflict; add resync stability matrix (#704)
A never-synced project (null lastSyncedHash) with default project_data
was treated as a local edit and pushed, colliding with the server's
real settings. Baseline a null lastSyncedHash against the default-data
hash so the server copy is adopted instead, matching isProjectDataDirty.

* Heal server scenes with null timestamps instead of re-downloading

A scene uploaded before scenes tracked created/lastEdited arrives from the
server with null timestamps. createScene stamps locally-meaningful values and
the old merge kept them, but those fields are hashed, so the local copy never
matched the server's null-timestamp hash — the scene re-downloaded on every
sync, forever.

On download, backfill the null timestamps with the project's creation time,
then at the download chokepoint detect that the stored copy now hashes
differently from the server's and upload the enriched copy (baselined on the
server's just-recorded hash, so a lone client heals conflict-free). This
generalizes: any future field that round-trips lossily self-heals instead of
looping.

Adds an integration test that watches the wire and asserts a second sync of
unchanged server entities pulls nothing, plus a reusable HttpClient wire tap.

* Add server-originated resync stability matrix

Sweeps every entity type across the field combinations most likely to
round-trip lossily (null/empty optional fields, populated fields, images,
archive state, scene groups, draft-to-scene references) and asserts that a
second sync of unchanged server-originated entities is silent over the wire —
no entity pulled, nothing pushed back. This is the invariant the null-timestamp
re-download bug violated.

Extends the wire tap to also track uploads.

* Add upload-direction resync stability matrix

Mirror of the server-originated matrix for the upload direction: a client
creates entities across each type's hashed fields, syncs them up, and a second
sync must be silent over the wire (nothing re-uploaded, nothing pulled). Closes
the symmetric gap so divergence is caught whether an entity originates on the
client or the server.

* Build out sync test matrix: oracle helpers, two-device harness, mixed fuzzer

Adds the convergence + stability oracle (assertConverged / assertResyncSilent
on top of the wire tap) and organizes the e2e sync tests into three regimes:
first-time, no-change, and mixed.

- HeadlessClient/secondDeviceFor: a second real device can join an existing
  server project with its own local dir, enabling true two-actor tests.
- TwoDeviceSyncTest: a second device adopts a project; independent edits on two
  devices converge.
- MixedSyncFuzzTest: seeded property test driving random create/edit/delete/
  rename across every entity type, asserting convergence + a silent resync.
- README documents the regimes, the model-free oracle, and the coverage map.

* Refresh IdAllocator when a sync completes

findNextId ran only at project-open and sync-start, so a sync that downloaded
entities with higher ids left the allocator's next-id snapshot stale. A create
before the next sync then minted an id that collided with a just-downloaded
entity. Re-derive the allocator in FinalizeSyncOperation after entity transfer.

Adds StaleAllocatorAfterSyncTest (a device creates right after adopting a
project) and drops the now-unnecessary initializeSceneEditor() workaround from
the two-device merge test.
2026-06-30 08:12:31 -07:00

4.1 KiB
Raw Permalink Blame History

Sync integration tests

End-to-end sync tests that run the real client sync engine against a real server. Each test spins up an in-process Jetty server (RoundTripTestBase) and drives one or more fully-wired clients (HeadlessClient) through it. Unlike the server-side :server e2e tests, which drive the HTTP API by hand, these exercise the client's store / hash / conflict logic — the layer where divergence bugs actually live.

The model-free oracle

The hard part of testing sync is knowing the expected end state. We mostly avoid computing it, and instead assert two properties that any correct sync must satisfy — both on RoundTripTestBase:

  • assertConverged(projectName, vararg clients) — every client holds exactly the entity set the server holds, hash for hash. Doesn't care what the entities are, only that the sides agree.
  • assertResyncSilent(client) — an immediate extra sync moves nothing over the wire. Built on tapWire(), an HttpSend interceptor that records real traffic (download_entity 200 vs 304, upload_entity). Any client/server hash divergence surfaces here as a re-download or re-upload.

tapWire() is the workhorse: assert what actually crossed the wire, not what the client claims it did. The null-timestamp re-download bug was a 200 where a 304 belonged.

The three regimes

Sync output is a function of (client baseline, client ops, server ops). New tests should slot into one of these:

Regime Setup Oracle
First-time client empty, server has entities every entity pulled; client converges to server
No-change nothing changed since last sync assertResyncSilent — zero wire transfer
Mixed creates / edits / deletes on one or both sides assertConverged + assertResyncSilent; conflicts only where both sides touched the same entity

Coverage map

First-time

  • ServerDownloadsEntityTest — server-only scene lands on a clean client
  • TwoDeviceSyncTest (a second device downloads…) — a second device adopts an existing project

No-change

  • ResyncStabilityMatrixTest — every entity type × edge-case field values, server-originated, resync silent
  • UploadResyncStabilityMatrixTest — same matrix for client-created entities
  • ResyncDownloadsNothingTest — all types in one project; first sync pulls + heals, second is silent
  • SyncHashStabilityTest, EditResyncNoConflictTest, ResyncBaselineScenariosTest, EntityTypeResyncMatrixTest, SyncedHashBackfillTest — targeted baseline / hash-agreement cases

Mixed

  • MixedSyncFuzzTest — seeded property test: random create/edit/delete/rename across all types, converge + silent
  • TwoDeviceSyncTest (independent edits…) — two devices, disjoint edits, converge
  • IndependentEditsTest, ClientDeletionTest, ClientUploadsEntityTest, ServerOriginatedEntitiesTest — specific transitions
  • ConflictPickClientTest, ConflictPickServerTest — the conflict sub-case (both sides touch one entity)
  • SyncFuzzTest — single-entity edit/rename fuzz (legacy; MixedSyncFuzzTest is the broader net)

Adding tests

  • A new scenario: extend RoundTripTestBase, drive HeadlessClients, and finish with assertConverged / assertResyncSilent rather than hand-rolled state checks.
  • Two devices on one project: secondDeviceFor(primary, localName). The primary must have synced once. A second device that creates entities after adopting should re-open its editor (initializeSceneEditor()) first, mirroring a real session re-deriving its next id.
  • More fuzz coverage: add seeds to MixedSyncFuzzTest.SEEDS (a failure prints the seed + iteration to replay).
  • Scripted "other device" changes: seedServerEntity / mutateServerEntity / seedServerEntityDeletion (+ bump last_id) set server state directly — more faithful than a second real client for server-originated changes, since it sidesteps shared client-side id allocation.