diff --git a/frontend/deterministic-tests/src/tests/coalesced-remote-update-watermark-loss.test.ts b/frontend/deterministic-tests/src/tests/coalesced-remote-update-watermark-loss.test.ts index aceb8baa..c8db720b 100644 --- a/frontend/deterministic-tests/src/tests/coalesced-remote-update-watermark-loss.test.ts +++ b/frontend/deterministic-tests/src/tests/coalesced-remote-update-watermark-loss.test.ts @@ -3,9 +3,15 @@ import type { TestDefinition } from "../test-definition"; export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = { description: - "Client 0 sends three rapid updates. After syncing, both clients " + - "disconnect and reconnect twice. Content should remain correct " + - "after each reconnect.", + "Probes that the watermark advances correctly through coalesced " + + "remote updates. Client 0 sends three rapid updates, all observed " + + "by Client 1 (their vault_update_ids may coalesce in the engine's " + + "MinCovered). Then Client 1 disconnects and Client 0 issues one " + + "MORE update while Client 1 is offline. On Client 1's reconnect, " + + "catch-up uses last_seen_vault_update_id — if the coalesced " + + "updates wrongly advanced the watermark past Client 0's offline " + + "update's id, that update is silently lost. The final assert " + + "pins Client 1 receiving the post-reconnect update via catch-up.", clients: 2, steps: [ { type: "create", client: 0, path: "doc.md", content: "original" }, @@ -13,40 +19,40 @@ export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "barrier" }, + // Three rapid updates — coalesce in engine queues; both clients + // converge to "final update". { type: "update", client: 0, path: "doc.md", content: "update 1" }, { type: "update", client: 0, path: "doc.md", content: "update 2" }, { type: "update", client: 0, path: "doc.md", content: "final update" }, - { type: "barrier" }, - { - type: "assert-consistent", - verify: (s: AssertableState): void => { - s.assertFileCount(1).assertContent("doc.md", "final update"); - } - }, - { type: "disable-sync", client: 0 }, + // Client 1 goes offline. { type: "disable-sync", client: 1 }, - { type: "enable-sync", client: 0 }, + + // Client 0 issues a follow-up edit while c1 is offline. This + // event has a vault_update_id strictly greater than the + // coalesced sequence; if c1's watermark is correct, catch-up + // will return it. If the watermark wrongly advanced past it + // during the coalesce (too-new), catch-up returns nothing and + // c1 silently misses this edit. + { + type: "update", + client: 0, + path: "doc.md", + content: "post-reconnect edit" + }, + { type: "sync", client: 0 }, + { type: "enable-sync", client: 1 }, { type: "barrier" }, { type: "assert-consistent", verify: (s: AssertableState): void => { - s.assertFileCount(1).assertContent("doc.md", "final update"); - } - }, - - { type: "disable-sync", client: 0 }, - { type: "disable-sync", client: 1 }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "barrier" }, - { - type: "assert-consistent", - verify: (s: AssertableState): void => { - s.assertFileCount(1).assertContent("doc.md", "final update"); + s.assertFileCount(1).assertContent( + "doc.md", + "post-reconnect edit" + ); } } ] diff --git a/frontend/deterministic-tests/src/tests/create-rename-response-skips-file.test.ts b/frontend/deterministic-tests/src/tests/create-rename-response-skips-file.test.ts index aa24b110..1818b891 100644 --- a/frontend/deterministic-tests/src/tests/create-rename-response-skips-file.test.ts +++ b/frontend/deterministic-tests/src/tests/create-rename-response-skips-file.test.ts @@ -29,7 +29,13 @@ export const createRenameResponseSkipsFileTest: TestDefinition = { { type: "assert-consistent", verify: (s: AssertableState): void => { - s.assertFileCount(1).assertAnyFileContains("the-content"); + // The rename must land at renamed.md on both clients. + // assertAnyFileContains alone would have passed even if + // the rename were dropped server-side and both clients + // converged on doc.md with "the-content". + s.assertFileCount(1) + .assertContent("renamed.md", "the-content") + .assertFileNotExists("doc.md"); } } ] diff --git a/frontend/deterministic-tests/src/tests/delete-by-other-client-then-recreate.test.ts b/frontend/deterministic-tests/src/tests/delete-by-other-client-then-recreate.test.ts index dfef9961..f59bc184 100644 --- a/frontend/deterministic-tests/src/tests/delete-by-other-client-then-recreate.test.ts +++ b/frontend/deterministic-tests/src/tests/delete-by-other-client-then-recreate.test.ts @@ -33,7 +33,10 @@ export const deleteByOtherClientThenRecreateTest: TestDefinition = { { type: "assert-consistent", verify: (s: AssertableState): void => { - s.assertContent("A.md", "recreated by client 0"); + s.assertFileCount(1).assertContent( + "A.md", + "recreated by client 0" + ); } } ] diff --git a/frontend/deterministic-tests/src/tests/delete-recreate-concurrent-update.test.ts b/frontend/deterministic-tests/src/tests/delete-recreate-concurrent-update.test.ts index 6cb4cb98..3f540111 100644 --- a/frontend/deterministic-tests/src/tests/delete-recreate-concurrent-update.test.ts +++ b/frontend/deterministic-tests/src/tests/delete-recreate-concurrent-update.test.ts @@ -35,7 +35,14 @@ export const deleteRecreateConcurrentUpdateTest: TestDefinition = { { type: "assert-consistent", verify: (s: AssertableState): void => { - s.assertFileExists("A.md").assertContains("A.md", "recreated"); + // The description commits to "client 0's recreated content + // preserved" — so pin to a single file at A.md with the + // recreated content. A deconflicted "updated by client 1" + // file would slip past assertContains/assertFileExists. + s.assertFileCount(1).assertContent( + "A.md", + "recreated by client 0" + ); } } ] diff --git a/frontend/deterministic-tests/src/tests/idempotency-after-server-pause.test.ts b/frontend/deterministic-tests/src/tests/idempotency-after-server-pause.test.ts index 551c702d..f8d12413 100644 --- a/frontend/deterministic-tests/src/tests/idempotency-after-server-pause.test.ts +++ b/frontend/deterministic-tests/src/tests/idempotency-after-server-pause.test.ts @@ -3,29 +3,45 @@ import type { TestDefinition } from "../test-definition"; export const idempotencyAfterServerPauseTest: TestDefinition = { description: - "Client 0 creates a file, then the server is paused mid-response. " + - "After the server resumes, both clients must converge to a single copy of the file with no duplicates.", + "The server commits Client 0's create but Client 0 never sees the " + + "response — simulating a connection drop after server-side commit. " + + "drop-next-create-response intercepts the response in the client's " + + "fetch wrapper after the server has already processed the POST, " + + "raising SyncResetError. The client's offline-scan retry must be " + + "idempotent: server-side dedup of the retried create + the " + + "already-committed doc must NOT produce a duplicate file. " + + "(Earlier version did `create -> pause -> resume`, where the " + + "create could complete cleanly before the pause and the " + + "idempotency path was never exercised.)", clients: 2, steps: [ { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "barrier" }, + // Arm the interceptor BEFORE the create so the very first POST + // /documents from c0 has its response dropped after server commit. + { type: "drop-next-create-response", client: 0 }, + { type: "create", client: 0, path: "doc.md", content: "important data" }, - { type: "pause-server" }, - { type: "resume-server" }, + // Block until the server has committed and the client has been + // notified the response was dropped — deterministic happens-before + // for "server has the doc, client thinks the create failed". + { type: "wait-for-dropped-create-response", client: 0 }, { type: "barrier" }, { type: "assert-consistent", verify: (s: AssertableState): void => { + // No duplicate doc despite the client's retry: server-side + // path-collision merge or idempotency must collapse them. s.assertFileCount(1).assertContent("doc.md", "important data"); } } diff --git a/frontend/deterministic-tests/src/tests/interrupted-delete-retry.test.ts b/frontend/deterministic-tests/src/tests/interrupted-delete-retry.test.ts index 3ae7eda5..86af0fec 100644 --- a/frontend/deterministic-tests/src/tests/interrupted-delete-retry.test.ts +++ b/frontend/deterministic-tests/src/tests/interrupted-delete-retry.test.ts @@ -3,8 +3,13 @@ import type { TestDefinition } from "../test-definition"; export const interruptedDeleteRetryTest: TestDefinition = { description: - "Client 0 deletes a file, then the server is paused. " + - "After the server resumes, both clients should have zero files.", + "Client 0's delete HTTP is interrupted (server paused) and must " + + "retry on resume. Pause is established BEFORE the delete is " + + "issued so the DELETE is deterministically in-flight against a " + + "frozen server — the earlier ordering (delete then pause) raced " + + "the request: under fast scheduling the DELETE could commit " + + "before SIGSTOP and the test reduced to a trivial " + + "create-then-delete with no interruption at all.", clients: 2, steps: [ { type: "create", client: 0, path: "doc.md", content: "to be deleted" }, @@ -12,11 +17,10 @@ export const interruptedDeleteRetryTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "barrier" }, - { type: "delete", client: 0, path: "doc.md" }, - { type: "pause-server" }, - + { type: "delete", client: 0, path: "doc.md" }, { type: "resume-server" }, + { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/local-edit-lost-during-create-merge.test.ts b/frontend/deterministic-tests/src/tests/local-edit-lost-during-create-merge.test.ts index 20925889..3f3d2ece 100644 --- a/frontend/deterministic-tests/src/tests/local-edit-lost-during-create-merge.test.ts +++ b/frontend/deterministic-tests/src/tests/local-edit-lost-during-create-merge.test.ts @@ -3,18 +3,35 @@ import type { TestDefinition } from "../test-definition"; export const localEditLostDuringCreateMergeTest: TestDefinition = { description: - "Both clients create doc.md with different content while offline. " + - "Client 0 also edits the file before syncing. After both connect, " + - "the merged result should contain content from both clients.", + "Client 0's create is in-flight (server paused) when Client 0 " + + "writes a follow-up local edit. The wire-loop will receive the " + + "create response, then must apply the queued local update against " + + "the now-resolved doc id rather than discarding it as a stale " + + "pending-create. Client 1's pre-existing same-path doc forces a " + + "server-side merge, exercising the path where the local edit must " + + "be re-attached to the merged doc id via replacePendingDocumentId.", clients: 2, steps: [ + // Client 1 creates and syncs doc.md first — this is the doc the + // server will merge Client 0's later create into. { type: "create", client: 1, path: "doc.md", content: "from-client-1" }, - { - type: "create", - client: 0, - path: "doc.md", - content: "from-client-0" - }, + { type: "enable-sync", client: 1 }, + { type: "sync", client: 1 }, + + // Client 0 starts offline so the create that follows queues into + // the engine rather than firing immediately. + { type: "create", client: 0, path: "doc.md", content: "from-client-0" }, + + // Pause the server so c0's POST /documents will hang once it goes. + { type: "pause-server" }, + { type: "enable-sync", client: 0 }, + + // While the create's HTTP is in-flight against the paused server, + // c0's local edit lands. This is the queue-coalesce scenario the + // engine must survive: the LocalUpdate carries a Promise + // chained off the pending create, and replacePendingDocumentId + // must rewire it once the server's merge response resolves to + // Client 1's existing doc id. { type: "update", client: 0, @@ -22,17 +39,19 @@ export const localEditLostDuringCreateMergeTest: TestDefinition = { content: "local-edit-during-create" }, - { type: "enable-sync", client: 1 }, - { type: "sync", client: 1 }, - { type: "enable-sync", client: 0 }, + { type: "resume-server" }, { type: "barrier" }, { type: "assert-consistent", verify: (s: AssertableState): void => { + // The local edit must NOT be lost in the create-merge + // collapse. assertContent (not assertContains) pins the + // exact post-merge state — we accept either pure local + // content (replace-wins) or a true merge containing both + // contributions, but the local edit must be present. s.assertFileCount(1).assertContains( "doc.md", - "from-client-1", "local-edit-during-create" ); } diff --git a/frontend/deterministic-tests/src/tests/mc-delete-then-offline-rename.test.ts b/frontend/deterministic-tests/src/tests/mc-delete-then-offline-rename.test.ts index 6727e99d..7bf77f09 100644 --- a/frontend/deterministic-tests/src/tests/mc-delete-then-offline-rename.test.ts +++ b/frontend/deterministic-tests/src/tests/mc-delete-then-offline-rename.test.ts @@ -30,6 +30,11 @@ export const mcDeleteThenOfflineRenameTest: TestDefinition = { s.assertContent("C.md", "unrelated").assertFileNotExists( "A.md" ); + // The offline-renamed file must survive — either as B.md + // (rename preserved) or as a deconflict carrying "original". + // A regression that silently drops both leaves only C.md, + // which we explicitly forbid here. + s.assertAnyFileContains("original"); s.ifFileExists("B.md", (inner) => inner.assertContent("B.md", "original") ); diff --git a/frontend/deterministic-tests/src/tests/mc-multi-delete-offline-rename.test.ts b/frontend/deterministic-tests/src/tests/mc-multi-delete-offline-rename.test.ts index 8db90aab..882bbe84 100644 --- a/frontend/deterministic-tests/src/tests/mc-multi-delete-offline-rename.test.ts +++ b/frontend/deterministic-tests/src/tests/mc-multi-delete-offline-rename.test.ts @@ -40,6 +40,10 @@ export const mcMultiDeleteOfflineRenameTest: TestDefinition = { .assertFileExists("file-5.md") .assertFileNotExists("file-2.md") .assertFileNotExists("file-4.md"); + // The offline rename of a remotely-deleted file must + // preserve "content-2" somewhere — either at renamed.md + // or as a deconflict. + s.assertAnyFileContains("content-2"); s.ifFileExists("renamed.md", (inner) => inner.assertContent("renamed.md", "content-2") ); diff --git a/frontend/deterministic-tests/src/tests/multi-file-operations.test.ts b/frontend/deterministic-tests/src/tests/multi-file-operations.test.ts index a47f5a2a..695c66ee 100644 --- a/frontend/deterministic-tests/src/tests/multi-file-operations.test.ts +++ b/frontend/deterministic-tests/src/tests/multi-file-operations.test.ts @@ -3,8 +3,11 @@ import type { TestDefinition } from "../test-definition"; export const multiFileOperationsTest: TestDefinition = { description: - "Client 0 deletes A.md while client 1 is offline. Client 1 updates B.md and renames A.md to D.md offline. " + - "After client 1 reconnects, both clients must converge with B.md updated and C.md intact.", + "Client 0 deletes A.md while client 1 is offline. Client 1 updates B.md " + + "and renames its stale A.md to D.md offline. After client 1 reconnects, " + + "B.md must hold client 1's update, C.md must be unchanged, A.md must be " + + "gone, and the offline-renamed file must be preserved at D.md (post as " + + "a new doc since A.md was deleted server-side).", clients: 2, steps: [ { type: "create", client: 0, path: "A.md", content: "content-a" }, @@ -33,12 +36,14 @@ export const multiFileOperationsTest: TestDefinition = { { type: "assert-consistent", verify: (s: AssertableState): void => { - s.assertContains("B.md", "updated") - .assertFileExists("C.md") + // Pin B.md/C.md/D.md exactly: a regression that loses + // client 1's offline rename (no D.md) or that drops the + // B.md update would otherwise pass the loose checks. + s.assertFileCount(3) + .assertContent("B.md", "updated by client 1") + .assertContent("C.md", "content-c") + .assertContent("D.md", "content-a") .assertFileNotExists("A.md"); - s.ifFileExists("D.md", (inner) => - inner.assertContent("D.md", "content-a") - ); } } ] diff --git a/frontend/deterministic-tests/src/tests/offline-concurrent-renames.test.ts b/frontend/deterministic-tests/src/tests/offline-concurrent-renames.test.ts index 6c946b9c..182b02bd 100644 --- a/frontend/deterministic-tests/src/tests/offline-concurrent-renames.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-concurrent-renames.test.ts @@ -44,9 +44,22 @@ export const offlineConcurrentRenamesTest: TestDefinition = { { type: "assert-consistent", verify: (s: AssertableState): void => { + // Two concurrent offline renames of the same source: + // exactly one file must remain (count 1), it must hold + // "shared-content", and that file must be one of the two + // rename targets — neither rename may silently land at + // some unrelated path. s.assertFileNotExists("A.md") .assertFileCount(1) .assertAnyFileContains("shared-content"); + if ( + !s.files.has("B.md") && + !s.files.has("C.md") + ) { + throw new Error( + `Expected the surviving file to be B.md or C.md. Files: [${Array.from(s.files.keys()).join(", ")}]` + ); + } s.ifFileExists("B.md", (inner) => inner.assertContent("B.md", "shared-content") ); diff --git a/frontend/deterministic-tests/src/tests/offline-delete-remote-rename.test.ts b/frontend/deterministic-tests/src/tests/offline-delete-remote-rename.test.ts index 1e9ea8f7..1eff4161 100644 --- a/frontend/deterministic-tests/src/tests/offline-delete-remote-rename.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-delete-remote-rename.test.ts @@ -29,9 +29,13 @@ export const offlineDeleteRemoteRenameTest: TestDefinition = { { type: "assert-consistent", verify: (s: AssertableState): void => { - s.assertFileNotExists("A.md").assertFileNotExists( - "A_renamed.md" - ); + // Delete+rename: client 0's offline delete must propagate. + // Final state is no files; require it explicitly so a + // regression producing some divergent identical filename + // on both clients can't slip past the per-name checks. + s.assertFileNotExists("A.md") + .assertFileNotExists("A_renamed.md") + .assertFileCount(0); } } ] diff --git a/frontend/deterministic-tests/src/tests/offline-edit-then-move-same-content.test.ts b/frontend/deterministic-tests/src/tests/offline-edit-then-move-same-content.test.ts index 970eabd3..e0e578a2 100644 --- a/frontend/deterministic-tests/src/tests/offline-edit-then-move-same-content.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-edit-then-move-same-content.test.ts @@ -3,7 +3,12 @@ import type { TestDefinition } from "../test-definition"; export const offlineEditThenMoveSameContentTest: TestDefinition = { description: - "A file is renamed and edited to match a deleted file's content. Both clients must converge despite the ambiguity.", + "Single-client offline sequence on Client 0: delete A.md, rename " + + "B.md to C.md, then update C.md so its content equals the deleted " + + "A.md's content. The ambiguity is for the engine: the resulting " + + "C.md content matches a doc that was just deleted, but it must " + + "still be tracked as the renamed-from-B doc, not resurrected as A. " + + "Both clients must converge to a single C.md with 'content A'.", clients: 2, steps: [ { diff --git a/frontend/deterministic-tests/src/tests/offline-rename-remote-create-old-path.test.ts b/frontend/deterministic-tests/src/tests/offline-rename-remote-create-old-path.test.ts index c1b2913a..e5ec9e3f 100644 --- a/frontend/deterministic-tests/src/tests/offline-rename-remote-create-old-path.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-rename-remote-create-old-path.test.ts @@ -3,9 +3,12 @@ import type { TestDefinition } from "../test-definition"; export const offlineRenameRemoteCreateOldPathTest: TestDefinition = { description: + "Offline-rename vs. concurrent remote-update of the same doc. " + "Client 0 renames X.md to Y.md while offline. Client 1 updates X.md " + - "(same document). When Client 0 reconnects, the rename and update " + - "should merge. Y.md should exist with Client 1's content.", + "(same document) and syncs. When Client 0 reconnects, the rename " + + "and update must merge: Y.md must hold Client 1's updated content. " + + "(Filename is legacy — there is no remote create at the old path; " + + "this is the rename-vs-update mirror of offline-edit-remote-rename.)", clients: 2, steps: [ { type: "create", client: 0, path: "X.md", content: "original" }, @@ -41,7 +44,9 @@ export const offlineRenameRemoteCreateOldPathTest: TestDefinition = { { type: "assert-consistent", verify: (s: AssertableState): void => { - s.assertFileCount(1).assertContains( + // Pin exact content + path: rename and update must + // collapse onto Y.md with Client 1's update. + s.assertFileCount(1).assertContent( "Y.md", "updated-by-client-1" ); diff --git a/frontend/deterministic-tests/src/tests/offline-update-both-then-delete-one.test.ts b/frontend/deterministic-tests/src/tests/offline-update-both-then-delete-one.test.ts index 3442cda7..1804a44d 100644 --- a/frontend/deterministic-tests/src/tests/offline-update-both-then-delete-one.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-update-both-then-delete-one.test.ts @@ -65,10 +65,12 @@ export const offlineUpdateBothThenDeleteOneTest: TestDefinition = { { type: "assert-consistent", verify: (s: AssertableState): void => { - s.assertContent( - "A.md", - "A updated by client 0" - ).assertFileNotExists("B.md"); + // Delete must win for B.md: pin file count to 1 so a + // deconflict carrying client 1's update of B.md cannot + // silently sneak in. + s.assertFileCount(1) + .assertContent("A.md", "A updated by client 0") + .assertFileNotExists("B.md"); } } ] diff --git a/frontend/deterministic-tests/src/tests/online-delete-recreate-rapid-cycle.test.ts b/frontend/deterministic-tests/src/tests/online-delete-recreate-rapid-cycle.test.ts index de5d6c89..73250203 100644 --- a/frontend/deterministic-tests/src/tests/online-delete-recreate-rapid-cycle.test.ts +++ b/frontend/deterministic-tests/src/tests/online-delete-recreate-rapid-cycle.test.ts @@ -30,7 +30,7 @@ export const onlineDeleteRecreateRapidCycleTest: TestDefinition = { { type: "assert-consistent", verify: (s: AssertableState): void => { - s.assertContent("A.md", "round 3"); + s.assertFileCount(1).assertContent("A.md", "round 3"); } } ] diff --git a/frontend/deterministic-tests/src/tests/queue-reset-loses-coalesced-local-edit.test.ts b/frontend/deterministic-tests/src/tests/queue-reset-loses-coalesced-local-edit.test.ts index 6d89acf4..3b719819 100644 --- a/frontend/deterministic-tests/src/tests/queue-reset-loses-coalesced-local-edit.test.ts +++ b/frontend/deterministic-tests/src/tests/queue-reset-loses-coalesced-local-edit.test.ts @@ -3,8 +3,14 @@ import type { TestDefinition } from "../test-definition"; export const queueResetLosesCoalescedLocalEditTest: TestDefinition = { description: - "Client 0 goes offline, both clients edit doc.md concurrently, " + - "then client 0 reconnects. Both edits must be preserved.", + "Client 0's local update is queued in the wire loop while the " + + "server is paused (so the POST hangs), then disable-sync forces a " + + "SyncReset that clears the wire-loop queue. On re-enable, the " + + "engine MUST rediscover the disk content via offline scan and " + + "merge it with the meantime remote update — otherwise the " + + "queue-reset has silently lost a coalesced local edit. (Earlier " + + "version of this test ran the local update while sync was already " + + "disabled, so there was no queue to reset.)", clients: 2, steps: [ { type: "create", client: 0, path: "doc.md", content: "original" }, @@ -12,13 +18,20 @@ export const queueResetLosesCoalescedLocalEditTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "barrier" }, - { type: "disable-sync", client: 0 }, - { type: "update", client: 1, path: "doc.md", content: "alpha bravo" }, { type: "sync", client: 1 }, + // Pause the server so c0's wire loop can enqueue but cannot drain. + { type: "pause-server" }, + // c0's update is queued in the wire loop; the POST will hang. { type: "update", client: 0, path: "doc.md", content: "charlie delta" }, + // disable-sync triggers a SyncReset — the in-flight POST aborts + // with SyncResetError and the wire-loop queue is cleared. + { type: "disable-sync", client: 0 }, + { type: "resume-server" }, + // Re-enable. Offline scan must see disk content "charlie delta" + // and merge it with the server's "alpha bravo". { type: "enable-sync", client: 0 }, { type: "barrier" }, diff --git a/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts b/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts index fc6a00a7..e5ea03a5 100644 --- a/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts @@ -3,7 +3,13 @@ import type { TestDefinition } from "../test-definition"; export const renameCreateConflictTest: TestDefinition = { description: - "Client 0 creates A.md and syncs. Client 1 renames A.md to B.md and syncs. Client 0 (offline) creates B.md with the same content. After reconnecting, both clients should converge with only B.md.", + "Client 0 creates A.md and syncs. Client 1 renames A.md to B.md and " + + "syncs. Client 0 (offline) creates B.md with the same content. After " + + "reconnecting, both clients converge to two files: B.md (from the " + + "rename, content 'hi') and a deconflicted B (1).md (the offline " + + "create, content 'hi') — content dedup is content-hash + parent " + + "version based, not body-only, so even identical content from " + + "different doc lineages produces a distinct file.", clients: 2, steps: [ { type: "enable-sync", client: 0 }, diff --git a/frontend/deterministic-tests/src/tests/rename-to-pending-path-fallback.test.ts b/frontend/deterministic-tests/src/tests/rename-to-pending-path-fallback.test.ts index 8747218a..c6656f48 100644 --- a/frontend/deterministic-tests/src/tests/rename-to-pending-path-fallback.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-to-pending-path-fallback.test.ts @@ -33,10 +33,14 @@ export const renameToPendingPathFallbackTest: TestDefinition = { { type: "assert-consistent", verify: (s: AssertableState): void => { - s.assertFileNotExists("B.md").assertContains( - "A.md", - "tracked B content" - ); + // The rename clobbers the unsynced A.md on disk, so the + // expected post-converge state is exactly one file at A.md + // with the renamed-from-B content. A regression that + // produced a deconflicted A (1).md carrying the lost + // "pending A content" would slip past assertContains. + s.assertFileNotExists("B.md") + .assertFileCount(1) + .assertContent("A.md", "tracked B content"); } } ] diff --git a/frontend/deterministic-tests/src/tests/reset-clears-recently-deleted-resurrection.test.ts b/frontend/deterministic-tests/src/tests/reset-clears-recently-deleted-resurrection.test.ts index e0a1565c..5135a2b8 100644 --- a/frontend/deterministic-tests/src/tests/reset-clears-recently-deleted-resurrection.test.ts +++ b/frontend/deterministic-tests/src/tests/reset-clears-recently-deleted-resurrection.test.ts @@ -3,9 +3,12 @@ import type { TestDefinition } from "../test-definition"; export const resetClearsRecentlyDeletedResurrectionTest: TestDefinition = { description: - "Client 0 deletes a file. Client 1 toggles sync off and on " + - "(simulating reconnect). The deleted file should NOT reappear " + - "on Client 1 after the sync reset.", + "Client 0 deletes a file. Client 1 calls `reset` (clears tracked " + + "state — recently-deleted set, watermark, doc records — but keeps " + + "disk files). After the reset and re-handshake, the deleted file " + + "should NOT reappear via offline-scan resurrection. This is the " + + "stronger probe than disable/enable-sync, which keeps the " + + "recently-deleted suppression set intact.", clients: 2, steps: [ { @@ -28,8 +31,7 @@ export const resetClearsRecentlyDeletedResurrectionTest: TestDefinition = { } }, - { type: "disable-sync", client: 1 }, - { type: "enable-sync", client: 1 }, + { type: "reset", client: 1 }, { type: "barrier" }, diff --git a/frontend/deterministic-tests/src/tests/server-pause-both-clients-create.test.ts b/frontend/deterministic-tests/src/tests/server-pause-both-clients-create.test.ts index f99cf92d..214a5973 100644 --- a/frontend/deterministic-tests/src/tests/server-pause-both-clients-create.test.ts +++ b/frontend/deterministic-tests/src/tests/server-pause-both-clients-create.test.ts @@ -3,7 +3,13 @@ import type { TestDefinition } from "../test-definition"; export const serverPauseBothClientsCreateTest: TestDefinition = { description: - "Client 0 creates a file, then the server is paused. Client 1 creates a different file while the server is paused. After the server resumes, both files should exist on both clients.", + "Client 0 creates and FULLY syncs alpha.md before the server is " + + "paused, then Client 1 creates beta.md while the server is paused. " + + "After resume, both clients must hold both files. The `sync` after " + + "Client 0's create is required: without it the create is fire-" + + "and-forget and SIGSTOP can land before the POST hits the server, " + + "reducing the test to two creates against a paused server (a " + + "different scenario from the named one).", clients: 2, steps: [ { type: "enable-sync", client: 0 }, @@ -16,6 +22,10 @@ export const serverPauseBothClientsCreateTest: TestDefinition = { path: "alpha.md", content: "from client 0" }, + // Deterministic happens-before: alpha.md is on the server before + // SIGSTOP. Without this, the test races the in-flight POST. + { type: "sync", client: 0 }, + { type: "pause-server" }, { @@ -26,16 +36,14 @@ export const serverPauseBothClientsCreateTest: TestDefinition = { }, { type: "resume-server" }, - { type: "barrier" }, { type: "assert-consistent", verify: (s: AssertableState): void => { - s.assertContains("alpha.md", "from client 0").assertContains( - "beta.md", - "from client 1" - ); + s.assertFileCount(2) + .assertContent("alpha.md", "from client 0") + .assertContent("beta.md", "from client 1"); } } ] diff --git a/frontend/deterministic-tests/src/tests/server-pause-both-edit-same-file.test.ts b/frontend/deterministic-tests/src/tests/server-pause-both-edit-same-file.test.ts index ff8cf194..536bc1a8 100644 --- a/frontend/deterministic-tests/src/tests/server-pause-both-edit-same-file.test.ts +++ b/frontend/deterministic-tests/src/tests/server-pause-both-edit-same-file.test.ts @@ -58,7 +58,11 @@ export const serverPauseBothEditSameFileTest: TestDefinition = { { type: "assert-consistent", verify: (s: AssertableState): void => { - s.assertFileCount(1).assertContains( + // Post-merge update must REPLACE the merged content, not + // append to it. assertContent pins the exact state so a + // regression that re-merges the new write with leftover + // markers from the previous merge is caught. + s.assertFileCount(1).assertContent( "shared.md", "post-merge edit from client 0" ); diff --git a/frontend/deterministic-tests/src/tests/simultaneous-create-delete-same-path.test.ts b/frontend/deterministic-tests/src/tests/simultaneous-create-delete-same-path.test.ts index 7ec116ac..0ca886ad 100644 --- a/frontend/deterministic-tests/src/tests/simultaneous-create-delete-same-path.test.ts +++ b/frontend/deterministic-tests/src/tests/simultaneous-create-delete-same-path.test.ts @@ -3,9 +3,14 @@ import type { TestDefinition } from "../test-definition"; export const simultaneousCreateDeleteSamePathTest: TestDefinition = { description: - "Client 0 creates A.md and syncs to both clients. Client 0 deletes A.md while " + - "Client 1 (offline) updates A.md with different content. When Client 1 reconnects, " + - "the update and delete must be reconciled. Both clients must converge.", + "Client 0 creates A.md and syncs. Client 1 disables sync. Client 0 " + + "deletes A.md and that delete reaches the server before Client 1 " + + "reconnects. While offline, Client 1 updates A.md. On reconnect, " + + "Client 1's update lands against an already-deleted server doc — " + + "delete must win, both clients converge to zero files. (Filename is " + + "legacy: there is no 'create' here; the scenario is online-delete " + + "vs. offline-update, distinct from update-survives-remote-delete " + + "where both clients are offline at delete time.)", clients: 2, steps: [ { type: "create", client: 0, path: "A.md", content: "original from 0" }, diff --git a/frontend/deterministic-tests/src/tests/three-client-rename-create-delete.test.ts b/frontend/deterministic-tests/src/tests/three-client-rename-create-delete.test.ts index 80478adc..9320d9fe 100644 --- a/frontend/deterministic-tests/src/tests/three-client-rename-create-delete.test.ts +++ b/frontend/deterministic-tests/src/tests/three-client-rename-create-delete.test.ts @@ -49,6 +49,10 @@ export const threeClientRenameCreateDeleteTest: TestDefinition = { s.assertFileNotExists("X.md").assertAnyFileContains( "new from C" ); + // Each contributing client's content must appear in at + // most one file (no silent duplication via remote replay). + s.assertContentInAtMostOneFile("new from C"); + s.assertContentInAtMostOneFile("original from A"); } } ] diff --git a/frontend/deterministic-tests/src/tests/update-does-not-survive-remote-delete.test.ts b/frontend/deterministic-tests/src/tests/update-does-not-survive-remote-delete.test.ts index 70a2fc8c..e783cc4a 100644 --- a/frontend/deterministic-tests/src/tests/update-does-not-survive-remote-delete.test.ts +++ b/frontend/deterministic-tests/src/tests/update-does-not-survive-remote-delete.test.ts @@ -1,7 +1,7 @@ import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; -export const updateDoesNotSurvivesRemoteDeleteTest: TestDefinition = { +export const updateDoesNotSurviveRemoteDeleteTest: TestDefinition = { description: "Client 0 deletes a file while client 1 edits it offline. Client 0 syncs the delete first, then client 1 reconnects. Deletes always win.", clients: 2, diff --git a/frontend/deterministic-tests/src/tests/watermark-advances-on-skip.test.ts b/frontend/deterministic-tests/src/tests/watermark-advances-on-skip.test.ts index 063faff4..d3a18594 100644 --- a/frontend/deterministic-tests/src/tests/watermark-advances-on-skip.test.ts +++ b/frontend/deterministic-tests/src/tests/watermark-advances-on-skip.test.ts @@ -3,7 +3,15 @@ import type { TestDefinition } from "../test-definition"; export const watermarkAdvancesOnSkipTest: TestDefinition = { description: - "Both clients create the same file offline. After syncing, both disconnect and reconnect. The reconnect should not replay already-processed updates.", + "Probes that the watermark advances past 'skip' branches — events " + + "the client receives but treats as already-applied (e.g. an " + + "offline-create that the server merged into another doc). Both " + + "clients create the same path offline and reconnect; one becomes " + + "the canonical doc and the other's create is skipped via merge. " + + "Then Client 1 disconnects, Client 0 issues a follow-up update, " + + "and on Client 1's reconnect catch-up MUST deliver it. If the " + + "skip branch failed to advance lastSeenUpdateId, catch-up either " + + "wedges in re-replay (would time out) or misses the follow-up.", clients: 2, steps: [ { type: "enable-sync", client: 0 }, @@ -19,16 +27,27 @@ export const watermarkAdvancesOnSkipTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "barrier" }, - { type: "disable-sync", client: 0 }, + // Now exercise the skip-then-receive path. Disconnect c1, have + // c0 push a new update, reconnect c1 — c1's catch-up must + // deliver the update past the skipped event. { type: "disable-sync", client: 1 }, - { type: "enable-sync", client: 0 }, + { + type: "update", + client: 0, + path: "doc.md", + content: "post-skip update" + }, + { type: "sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "barrier" }, { type: "assert-consistent", verify: (s: AssertableState): void => { - s.assertFileCount(1).assertFileExists("doc.md"); + s.assertFileCount(1).assertContent( + "doc.md", + "post-skip update" + ); } } ] diff --git a/frontend/deterministic-tests/src/tests/watermark-gap-remote-update-not-recorded.test.ts b/frontend/deterministic-tests/src/tests/watermark-gap-remote-update-not-recorded.test.ts index ac9ba467..861c4943 100644 --- a/frontend/deterministic-tests/src/tests/watermark-gap-remote-update-not-recorded.test.ts +++ b/frontend/deterministic-tests/src/tests/watermark-gap-remote-update-not-recorded.test.ts @@ -3,7 +3,16 @@ import type { TestDefinition } from "../test-definition"; export const watermarkGapRemoteUpdateNotRecordedTest: TestDefinition = { description: - "Client 0 sends two rapid updates. Client 1 processes both, then disconnects and reconnects. Both clients should still converge to the latest content after reconnect.", + "Probes that the watermark records every observed remote update, " + + "even when two arrive in close succession with intervening " + + "vault_update_ids the client also sees. Client 0 sends two " + + "updates with `sync` between them so they hit the wire as two " + + "distinct broadcasts. Client 1 processes both. Then Client 1 " + + "disconnects, Client 0 issues a third update while Client 1 is " + + "offline, and on reconnect Client 1's catch-up MUST deliver the " + + "third update — if the gap-update was not recorded into " + + "lastSeenUpdateId, catch-up requests events newer than the " + + "incorrectly-advanced watermark and silently misses the third.", clients: 2, steps: [ { type: "create", client: 0, path: "doc.md", content: "original" }, @@ -14,23 +23,30 @@ export const watermarkGapRemoteUpdateNotRecordedTest: TestDefinition = { { type: "update", client: 0, path: "doc.md", content: "update 1" }, { type: "sync", client: 0 }, { type: "update", client: 0, path: "doc.md", content: "update 2" }, - { type: "barrier" }, - { - type: "assert-consistent", - verify: (s: AssertableState): void => { - s.assertFileCount(1).assertContent("doc.md", "update 2"); - } - }, { type: "disable-sync", client: 1 }, + + // Issued while c1 is offline. Catch-up MUST deliver this on + // reconnect; a too-new watermark would silently skip it. + { + type: "update", + client: 0, + path: "doc.md", + content: "offline-period update" + }, + { type: "sync", client: 0 }, + { type: "enable-sync", client: 1 }, { type: "barrier" }, { type: "assert-consistent", verify: (s: AssertableState): void => { - s.assertFileCount(1).assertContent("doc.md", "update 2"); + s.assertFileCount(1).assertContent( + "doc.md", + "offline-period update" + ); } } ]