From 67d410b5200d37789d0ed1d02cec52a1f116889f Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Thu, 26 Mar 2026 21:13:44 +0000 Subject: [PATCH 01/10] Add a few good deterministic tests --- ...-text-pending-create-not-displaced.test.ts | 40 +++++++++++++ ...concurrent-update-diff-consistency.test.ts | 41 +++++++++++++ .../src/tests/11-create-delete-noop.test.ts | 24 ++++++++ .../src/tests/12-create-merge-delete.test.ts | 30 ++++++++++ ...3-move-identical-content-ambiguity.test.ts | 57 +++++++++++++++++++ ...inary-pending-create-not-displaced.test.ts | 41 +++++++++++++ ...sce-update-remote-update-data-loss.test.ts | 49 ++++++++++++++++ ...esced-remote-update-watermark-loss.test.ts | 48 ++++++++++++++++ ...urrent-delete-during-remote-update.test.ts | 29 ++++++++++ ...oncurrent-edit-exact-same-position.test.ts | 55 ++++++++++++++++++ ...urrent-rename-and-create-at-target.test.ts | 48 ++++++++++++++++ ...urrent-rename-and-create-at-target.test.ts | 49 ++++++++++++++++ .../9-concurrent-rename-same-target.test.ts | 40 +++++++++++++ 13 files changed, 551 insertions(+) create mode 100644 frontend/deterministic-tests/src/tests/1-text-pending-create-not-displaced.test.ts create mode 100644 frontend/deterministic-tests/src/tests/10-concurrent-update-diff-consistency.test.ts create mode 100644 frontend/deterministic-tests/src/tests/11-create-delete-noop.test.ts create mode 100644 frontend/deterministic-tests/src/tests/12-create-merge-delete.test.ts create mode 100644 frontend/deterministic-tests/src/tests/13-move-identical-content-ambiguity.test.ts create mode 100644 frontend/deterministic-tests/src/tests/2-binary-pending-create-not-displaced.test.ts create mode 100644 frontend/deterministic-tests/src/tests/3-coalesce-update-remote-update-data-loss.test.ts create mode 100644 frontend/deterministic-tests/src/tests/4-coalesced-remote-update-watermark-loss.test.ts create mode 100644 frontend/deterministic-tests/src/tests/5-concurrent-delete-during-remote-update.test.ts create mode 100644 frontend/deterministic-tests/src/tests/6-concurrent-edit-exact-same-position.test.ts create mode 100644 frontend/deterministic-tests/src/tests/7-concurrent-rename-and-create-at-target.test.ts create mode 100644 frontend/deterministic-tests/src/tests/8-concurrent-rename-and-create-at-target.test.ts create mode 100644 frontend/deterministic-tests/src/tests/9-concurrent-rename-same-target.test.ts diff --git a/frontend/deterministic-tests/src/tests/1-text-pending-create-not-displaced.test.ts b/frontend/deterministic-tests/src/tests/1-text-pending-create-not-displaced.test.ts new file mode 100644 index 00000000..506e2b59 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/1-text-pending-create-not-displaced.test.ts @@ -0,0 +1,40 @@ +import type { TestDefinition } from "../test-definition"; +import type { AssertableState } from "../utils/assertable-state"; + +export const textPendingCreateNotDisplacedTest: TestDefinition = { + name: "Both offline binary creates at same path survive sync", + description: + "Two clients each create a binary file at the same path while offline. " + + "After syncing, both files should exist on both clients at separate paths.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "data.txt", + content: "text data from client 0" + }, + { + type: "create", + client: 1, + path: "data.txt", + content: "text data from client 1" + }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "assert-consistent", verify: verifyBothFilesExist } + ] +}; + +function verifyBothFilesExist(state: AssertableState): void { + state + .assertFileCount(1) + .assertFileExists("data.txt") + .assertAnyFileContains( + "data from client 0", + "data from client 1" + ); +} diff --git a/frontend/deterministic-tests/src/tests/10-concurrent-update-diff-consistency.test.ts b/frontend/deterministic-tests/src/tests/10-concurrent-update-diff-consistency.test.ts new file mode 100644 index 00000000..baa8bc52 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/10-concurrent-update-diff-consistency.test.ts @@ -0,0 +1,41 @@ +import type { TestDefinition } from "../test-definition"; + +export const concurrentUpdateDiffConsistencyTest: TestDefinition = { + name: "Concurrent edits to different sections merge correctly", + description: + "Both clients edit different sections of the same file while offline. " + + "After syncing, the merged file should contain both edits.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "doc.md", + content: "header\nmiddle\nfooter" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + { + type: "update", + client: 0, + path: "doc.md", + content: "header by 0\nmiddle\nfooter" + }, + { + type: "update", + client: 1, + path: "doc.md", + content: "header\nmiddle\nfooter by 1" + }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "assert-consistent", verify: (state) => state.assertFileCount(1).assertContent("doc.md", "header by 0\nmiddle\nfooter by 1") } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/11-create-delete-noop.test.ts b/frontend/deterministic-tests/src/tests/11-create-delete-noop.test.ts new file mode 100644 index 00000000..f575fc79 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/11-create-delete-noop.test.ts @@ -0,0 +1,24 @@ +import type { TestDefinition } from "../test-definition"; + +export const createDeleteNoopTest: TestDefinition = { + name: "Offline create then delete results in no file", + description: + "A client creates a file, updates it multiple times, then deletes it, all while " + + "offline. After syncing, neither client should have the file.", + clients: 2, + steps: [ + { type: "enable-sync", client: 1 }, + + { type: "create", client: 0, path: "temp.md", content: "version 1" }, + { type: "update", client: 0, path: "temp.md", content: "version 2" }, + { type: "update", client: 0, path: "temp.md", content: "version 3" }, + { type: "delete", client: 0, path: "temp.md" }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { type: "assert-not-exists", client: 0, path: "temp.md" }, + { type: "assert-not-exists", client: 1, path: "temp.md" }, + { type: "assert-consistent" } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/12-create-merge-delete.test.ts b/frontend/deterministic-tests/src/tests/12-create-merge-delete.test.ts new file mode 100644 index 00000000..4a40b59f --- /dev/null +++ b/frontend/deterministic-tests/src/tests/12-create-merge-delete.test.ts @@ -0,0 +1,30 @@ +import type { TestDefinition } from "../test-definition"; + +export const createMergeDeleteTest: TestDefinition = { + name: "Concurrent Create, Merge, Then Delete", + description: + "Two clients create A.md offline with different content. Both come online and " + + "the content is merged. Then one client deletes A.md. Both clients should " + + "converge on an empty state.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "from-zero" }, + { type: "create", client: 1, path: "A.md", content: "from-one" }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state) => state.assertFileCount(1).assertContains("A.md", "from-zero", "from-one") + }, + + { type: "delete", client: 0, path: "A.md" }, + { type: "barrier" }, + + { type: "assert-not-exists", client: 0, path: "A.md" }, + { type: "assert-not-exists", client: 1, path: "A.md" }, + { type: "assert-consistent", verify: (state) => state.assertFileCount(0) } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/13-move-identical-content-ambiguity.test.ts b/frontend/deterministic-tests/src/tests/13-move-identical-content-ambiguity.test.ts new file mode 100644 index 00000000..91a52496 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/13-move-identical-content-ambiguity.test.ts @@ -0,0 +1,57 @@ +import type { TestDefinition } from "../test-definition"; + +export const moveIdenticalContentAmbiguityTest: TestDefinition = { + name: "Move Detection Ambiguity With Identical Content", + description: + "Two files with identical content exist. One is deleted and the other renamed " + + "while offline. The system should still converge correctly despite the ambiguity.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "A.md", + content: "identical content" + }, + { + type: "create", + client: 0, + path: "B.md", + content: "identical content" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-content", + client: 1, + path: "A.md", + content: "identical content" + }, + { + type: "assert-content", + client: 1, + path: "B.md", + content: "identical content" + }, + + { type: "disable-sync", client: 1 }, + { type: "delete", client: 1, path: "A.md" }, + { type: "rename", client: 1, oldPath: "B.md", newPath: "C.md" }, + + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state) => { + state + .assertFileCount(1) + .assertFileNotExists("A.md") + .assertFileNotExists("B.md") + .assertContent("C.md", "identical content"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/2-binary-pending-create-not-displaced.test.ts b/frontend/deterministic-tests/src/tests/2-binary-pending-create-not-displaced.test.ts new file mode 100644 index 00000000..5ad89cbe --- /dev/null +++ b/frontend/deterministic-tests/src/tests/2-binary-pending-create-not-displaced.test.ts @@ -0,0 +1,41 @@ +import type { TestDefinition } from "../test-definition"; +import type { AssertableState } from "../utils/assertable-state"; + +export const binaryPendingCreateNotDisplacedTest: TestDefinition = { + name: "Both offline binary creates at same path survive sync", + description: + "Two clients each create a binary file at the same path while offline. " + + "After syncing, both files should exist on both clients at separate paths.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "data.bin", + content: "binary data from client 0" + }, + { + type: "create", + client: 1, + path: "data.bin", + content: "binary data from client 1" + }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "assert-consistent", verify: verifyBothFilesExist } + ] +}; + +function verifyBothFilesExist(state: AssertableState): void { + state + .assertFileCount(2) + .assertFileExists("data.bin") + .assertFileExists("data (1).bin") + .assertAnyFileContains( + "binary data from client 0", + "binary data from client 1" + ); +} diff --git a/frontend/deterministic-tests/src/tests/3-coalesce-update-remote-update-data-loss.test.ts b/frontend/deterministic-tests/src/tests/3-coalesce-update-remote-update-data-loss.test.ts new file mode 100644 index 00000000..d66a2cf3 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/3-coalesce-update-remote-update-data-loss.test.ts @@ -0,0 +1,49 @@ +import type { TestDefinition } from "../test-definition"; + +export const coalesceUpdateRemoteUpdateDataLossTest: TestDefinition = { + name: "Local and remote edits to the same file are both preserved", + description: + "Client 0 edits a file while client 1 is offline. Client 1 reconnects " + + "and immediately edits the same file. Both edits should be preserved.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "doc.md", + content: "line 1\nline 2\nline 3" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 1 }, + + { + type: "update", + client: 0, + path: "doc.md", + content: "line 1\nline 2\nline 3\nclient 0 addition" + }, + { type: "sync", client: 0 }, + + { + type: "update", + client: 1, + path: "doc.md", + content: "client 1 addition\nline 1\nline 2\nline 3" + }, + + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state) => { + state + .assertFileCount(1) + .assertContains("doc.md", "client 0 addition", "client 1 addition"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/4-coalesced-remote-update-watermark-loss.test.ts b/frontend/deterministic-tests/src/tests/4-coalesced-remote-update-watermark-loss.test.ts new file mode 100644 index 00000000..2d8fd4b6 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/4-coalesced-remote-update-watermark-loss.test.ts @@ -0,0 +1,48 @@ +import type { TestDefinition } from "../test-definition"; +import type { AssertableState } from "../utils/assertable-state"; + +export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = { + name: "Coalesced Remote Updates Lose Earlier vaultUpdateIds", + description: + "When multiple remote-update events for the same document coalesce, " + + "only the last vaultUpdateId is recorded. Earlier IDs create " + + "permanent watermark gaps that cause unnecessary server replays " + + "on every reconnect.", + clients: 2, + steps: [ + // Setup: both clients have doc.md + { type: "create", client: 0, path: "doc.md", content: "original" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + // Client 0 sends three rapid updates + { 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: "sync", client: 0 }, + + { type: "barrier" }, + { type: "assert-consistent", verify: verifyContent }, + + { 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: verifyContent }, + + { 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: verifyContent } + ] +}; + + +function verifyContent(state: AssertableState): void { + state.assertFileCount(1).assertContent("doc.md", "final update"); +} diff --git a/frontend/deterministic-tests/src/tests/5-concurrent-delete-during-remote-update.test.ts b/frontend/deterministic-tests/src/tests/5-concurrent-delete-during-remote-update.test.ts new file mode 100644 index 00000000..1a4014ac --- /dev/null +++ b/frontend/deterministic-tests/src/tests/5-concurrent-delete-during-remote-update.test.ts @@ -0,0 +1,29 @@ +import { AssertableState } from "src/utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const concurrentDeleteDuringRemoteUpdateTest: TestDefinition = { + name: "Delete and remote update of same file do not crash", + description: + "One client updates a file while the other deletes it at the same " + + "time. Both clients should converge without errors.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "doc.md", content: "original" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + + { type: "update", client: 0, path: "doc.md", content: "updated by 0" }, + { type: "delete", client: 1, path: "doc.md" }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "assert-consistent", verify: (state) => state.assertFileCount(0) } + ] +}; + diff --git a/frontend/deterministic-tests/src/tests/6-concurrent-edit-exact-same-position.test.ts b/frontend/deterministic-tests/src/tests/6-concurrent-edit-exact-same-position.test.ts new file mode 100644 index 00000000..93cc6fc3 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/6-concurrent-edit-exact-same-position.test.ts @@ -0,0 +1,55 @@ +import type { TestDefinition } from "../test-definition"; + +export const concurrentEditExactSamePositionTest: TestDefinition = { + name: "Concurrent edits to the exact same word are both preserved", + description: + "Both clients replace the same word in a file with different text " + + "while offline. After syncing, the merged result should contain " + + "both replacements.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "doc.md", + content: "the quick brown fox" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + { + type: "assert-content", + client: 1, + path: "doc.md", + content: "the quick brown fox" + }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + { + type: "update", + client: 0, + path: "doc.md", + content: "the slow brown fox" + }, + { + type: "update", + client: 1, + path: "doc.md", + content: "the fast brown fox" + }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state) => { + state + .assertFileCount(1) + .assertContains("doc.md", "slow", "fast", "brown fox"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/7-concurrent-rename-and-create-at-target.test.ts b/frontend/deterministic-tests/src/tests/7-concurrent-rename-and-create-at-target.test.ts new file mode 100644 index 00000000..7c08b392 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/7-concurrent-rename-and-create-at-target.test.ts @@ -0,0 +1,48 @@ +import type { TestDefinition } from "../test-definition"; + +export const concurrentRenameAndCreateAtTargetTest: TestDefinition = { + name: "Rename to path where another client creates a file", + description: + "One client renames X to Y while another creates a new file at Y, " + + "both offline. After syncing, Y should contain merged content from " + + "both the renamed file and the newly created file.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "X.md", + content: "original file X" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + + { type: "rename", client: 0, oldPath: "X.md", newPath: "Y.md" }, + + { + type: "create", + client: 1, + path: "Y.md", + content: "brand new Y content" + }, + + { type: "enable-sync", client: 0 }, + { type: "sync", client: 0 }, + + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state) => { + state + .assertFileNotExists("X.md") + .assertContains("Y.md", "original file X", "brand new Y content"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/8-concurrent-rename-and-create-at-target.test.ts b/frontend/deterministic-tests/src/tests/8-concurrent-rename-and-create-at-target.test.ts new file mode 100644 index 00000000..4cd7c1d9 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/8-concurrent-rename-and-create-at-target.test.ts @@ -0,0 +1,49 @@ +import type { TestDefinition } from "../test-definition"; + +export const concurrentRenameAndCreateAtTargetTest: TestDefinition = { + name: "Rename to path where another client creates a file", + description: + "One client renames X to Y while another creates a new file at Y, " + + "both offline. After syncing, Y should contain merged content from " + + "both the renamed file and the newly created file.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "X.md", + content: "original file X" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + + { type: "rename", client: 0, oldPath: "X.md", newPath: "Y.md" }, + + { + type: "create", + client: 1, + path: "Y.md", + content: "brand new Y content" + }, + + { type: "enable-sync", client: 1 }, + { type: "sync", client: 1 }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state) => { + state + .assertFileCount(2) + .assertContains("Y (1).md", "original file X") + .assertContains("Y.md", "brand new Y content"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/9-concurrent-rename-same-target.test.ts b/frontend/deterministic-tests/src/tests/9-concurrent-rename-same-target.test.ts new file mode 100644 index 00000000..e0419a47 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/9-concurrent-rename-same-target.test.ts @@ -0,0 +1,40 @@ +import type { TestDefinition } from "../test-definition"; + +export const concurrentRenameSameTargetTest: TestDefinition = { + name: "Two clients rename different files to the same target path", + description: + "One client renames A to C while the other renames B to C, both offline. " + + "After syncing, both file contents should be preserved via path deconfliction.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "content-a" }, + { type: "create", client: 0, path: "B.md", content: "content-b" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 1 }, + + { type: "rename", client: 0, oldPath: "A.md", newPath: "C.md" }, + { type: "sync", client: 0 }, + + { type: "rename", client: 1, oldPath: "B.md", newPath: "C.md" }, + + { type: "enable-sync", client: 1 }, + { type: "sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state) => { + state + .assertFileCount(2) + .assertFileNotExists("A.md") + .assertFileNotExists("B.md") + .assertFileExists("C.md") + .assertFileExists("C (1).md") + .assertAnyFileContains("content-a", "content-b"); + } + } + ] +}; From 3fe5f49050cba919cec10214b34c46c5ab2d7d9a Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Thu, 26 Mar 2026 21:14:56 +0000 Subject: [PATCH 02/10] Make header safe --- sync-server/src/server/device_id_header.rs | 29 +++++++++++++++------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/sync-server/src/server/device_id_header.rs b/sync-server/src/server/device_id_header.rs index af9d6413..13bd17a8 100644 --- a/sync-server/src/server/device_id_header.rs +++ b/sync-server/src/server/device_id_header.rs @@ -16,20 +16,31 @@ impl Header for DeviceIdHeader { { let value = values.next().ok_or_else(headers::Error::invalid)?; - Ok(DeviceIdHeader( - value - .to_str() - .map_err(|_| headers::Error::invalid())? - .to_owned(), - )) + let s = value.to_str().map_err(|_| headers::Error::invalid())?; + + if s.is_empty() || s.len() > 256 { + return Err(headers::Error::invalid()); + } + + // Only allow safe characters to prevent log injection and similar attacks. + // Covers UUIDs, user-agent strings like "vault-link/1.0 (12345; linux)", + // and human-readable device names. + if !s + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_./ ();:@+,".contains(c)) + { + return Err(headers::Error::invalid()); + } + + Ok(DeviceIdHeader(s.to_owned())) } fn encode(&self, values: &mut E) where E: Extend, { - let value = HeaderValue::from_static(Box::leak(self.0.clone().into_boxed_str())); - - values.extend(std::iter::once(value)); + if let Ok(value) = HeaderValue::from_str(&self.0) { + values.extend(std::iter::once(value)); + } } } From 233ce1254b082b324d9b095df557486bda7a57a4 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Thu, 26 Mar 2026 21:19:06 +0000 Subject: [PATCH 03/10] Various server improvements --- sync-server/src/app_state/database/models.rs | 7 +- sync-server/src/app_state/websocket/models.rs | 5 +- sync-server/src/app_state/websocket/utils.rs | 10 +-- sync-server/src/config.rs | 29 ++++---- sync-server/src/config/user_config.rs | 20 ++++-- sync-server/src/errors.rs | 44 +++++++++++- sync-server/src/server/auth.rs | 10 +-- sync-server/src/server/rate_limit.rs | 72 +++++++++++++++++++ sync-server/src/server/requests.rs | 19 ++--- .../src/utils/find_first_available_path.rs | 16 +++-- 10 files changed, 177 insertions(+), 55 deletions(-) create mode 100644 sync-server/src/server/rate_limit.rs diff --git a/sync-server/src/app_state/database/models.rs b/sync-server/src/app_state/database/models.rs index a216125a..f6b35424 100644 --- a/sync-server/src/app_state/database/models.rs +++ b/sync-server/src/app_state/database/models.rs @@ -22,6 +22,7 @@ pub struct StoredDocumentVersion { pub device_id: DeviceId, #[allow(dead_code)] // This is for manual analysis pub has_been_merged: bool, + pub idempotency_key: Option, } impl PartialEq for StoredDocumentVersion { @@ -33,7 +34,7 @@ impl PartialEq for StoredDocumentVersion { #[derive(TS, Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct DocumentVersionWithoutContent { - #[ts(as = "i32")] + #[ts(type = "number")] pub vault_update_id: VaultUpdateId, pub document_id: DocumentId, @@ -43,7 +44,7 @@ pub struct DocumentVersionWithoutContent { pub user_id: UserId, pub device_id: DeviceId, - #[ts(as = "i32")] + #[ts(type = "number")] pub content_size: u64, } @@ -65,7 +66,7 @@ impl From for DocumentVersionWithoutContent { #[derive(TS, Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct DocumentVersion { - #[ts(as = "i32")] + #[ts(type = "number")] pub vault_update_id: VaultUpdateId, pub document_id: DocumentId, diff --git a/sync-server/src/app_state/websocket/models.rs b/sync-server/src/app_state/websocket/models.rs index e037fb7e..fb1d24b9 100644 --- a/sync-server/src/app_state/websocket/models.rs +++ b/sync-server/src/app_state/websocket/models.rs @@ -11,7 +11,7 @@ pub struct WebSocketHandshake { pub token: String, pub device_id: DeviceId, - #[ts(as = "Option")] + #[ts(type = "number | null")] pub last_seen_vault_update_id: Option, } @@ -28,7 +28,7 @@ pub struct DocumentWithCursors { // that it exists and can be client-side // interpolated. However, the actual // position is meaningless. - #[ts(as = "Option")] + #[ts(type = "number | null")] pub vault_update_id: Option, pub document_id: DocumentId, @@ -70,6 +70,7 @@ pub struct WebSocketVaultUpdate { pub enum WebSocketClientMessage { Handshake(WebSocketHandshake), CursorPositions(CursorPositionFromClient), + Ping {}, } #[derive(TS, Serialize, Clone, Debug)] diff --git a/sync-server/src/app_state/websocket/utils.rs b/sync-server/src/app_state/websocket/utils.rs index 1e0dd243..ce8205fa 100644 --- a/sync-server/src/app_state/websocket/utils.rs +++ b/sync-server/src/app_state/websocket/utils.rs @@ -9,7 +9,7 @@ use crate::{ database::models::{DocumentVersionWithoutContent, VaultId, VaultUpdateId}, }, config::user_config::User, - errors::{SyncServerError, server_error, unauthenticated_error}, + errors::{SyncServerError, client_error, server_error, unauthenticated_error}, server::auth::auth, }; @@ -26,16 +26,16 @@ pub fn get_authenticated_handshake( if let Some(Message::Text(message)) = message { let message: WebSocketClientMessage = serde_json::from_str(&message) .context("Failed to parse message") - .map_err(server_error)?; + .map_err(client_error)?; match message { WebSocketClientMessage::Handshake(handshake) => { let user = auth(state, handshake.token.trim(), vault_id)?; Ok(AuthenticatedWebSocketHandshake { handshake, user }) } - WebSocketClientMessage::CursorPositions(_) => Err(unauthenticated_error( - anyhow::anyhow!("Expected a handshake message"), - )), + WebSocketClientMessage::CursorPositions(_) | WebSocketClientMessage::Ping {} => Err( + unauthenticated_error(anyhow::anyhow!("Expected a handshake message")), + ), } } else { Err(unauthenticated_error(anyhow::anyhow!( diff --git a/sync-server/src/config.rs b/sync-server/src/config.rs index 6a003d2e..75d4dba7 100644 --- a/sync-server/src/config.rs +++ b/sync-server/src/config.rs @@ -28,23 +28,20 @@ pub struct Config { impl Config { pub async fn read_or_create(path: &Path) -> Result { - let config = if path.exists() { - info!( - "Loading configuration from `{}`", - path.canonicalize().unwrap().display() - ); - Self::load_from_file(path).await? + let display_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + + if path.exists() { + info!("Loading configuration from `{}`", display_path.display()); + Self::load_from_file(path).await } else { - Self::default() - }; - - config.write(path).await?; - info!( - "Updated configuration at `{}`", - path.canonicalize().unwrap().display() - ); - - Ok(config) + let config = Self::default(); + config.write(path).await?; + info!( + "Created default configuration at `{}`", + display_path.display() + ); + Ok(config) + } } pub async fn load_from_file(path: &Path) -> Result { diff --git a/sync-server/src/config/user_config.rs b/sync-server/src/config/user_config.rs index 8b2537f0..fd824f39 100644 --- a/sync-server/src/config/user_config.rs +++ b/sync-server/src/config/user_config.rs @@ -1,6 +1,7 @@ use bimap::BiHashMap; use rand::{Rng, distr::Alphanumeric, rng}; use serde::{Deserialize, Deserializer, Serialize, de::Error}; +use subtle::ConstantTimeEq; use crate::app_state::database::models::VaultId; @@ -19,10 +20,19 @@ where let mut user_token_map = BiHashMap::new(); for user in &users { if let Some(existing_name) = user_token_map.get_by_right(&user.token) { + let redacted = if user.token.len() > 6 { + format!( + "{}...{}", + &user.token[..3], + &user.token[user.token.len() - 3..] + ) + } else { + "***".to_owned() + }; return Err(D::Error::custom(format!( - "Duplicate user token found: `{}` for users `{}` and `{}`. User tokens must be \ - unique.", - user.token, existing_name, user.name + "Duplicate user token found: `{redacted}` for users `{}` and `{}`. User tokens \ + must be unique.", + existing_name, user.name ))); } @@ -41,7 +51,9 @@ where impl UserConfig { pub fn get_user(&self, token: &str) -> Option<&User> { - self.user_configs.iter().find(|u| u.token == token) + self.user_configs + .iter() + .find(|u| u.token.as_bytes().ct_eq(token.as_bytes()).into()) } } diff --git a/sync-server/src/errors.rs b/sync-server/src/errors.rs index 831b0e86..0dad0463 100644 --- a/sync-server/src/errors.rs +++ b/sync-server/src/errors.rs @@ -5,7 +5,7 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; -use log::{debug, error}; +use log::{debug, error, warn}; use serde::Serialize; use thiserror::Error; use ts_rs::TS; @@ -29,6 +29,9 @@ pub enum SyncServerError { #[error("Permission denied error: {0}")] PermissionDeniedError(#[source] anyhow::Error), + + #[error("Too many requests: {0}")] + TooManyRequests(#[source] anyhow::Error), } impl SyncServerError { @@ -39,7 +42,8 @@ impl SyncServerError { | Self::ServerError(error) | Self::NotFound(error) | Self::Unauthenticated(error) - | Self::PermissionDeniedError(error) => error.into(), + | Self::PermissionDeniedError(error) + | Self::TooManyRequests(error) => error.into(), } } } @@ -69,7 +73,22 @@ impl Display for SerializedError { impl IntoResponse for SyncServerError { fn into_response(self) -> Response { - let body = Json(self.serialize()); + let serialized = self.serialize(); + + match &self { + Self::InitError(_) | Self::ServerError(_) => { + error!("{serialized}"); + } + Self::ClientError(_) | Self::NotFound(_) => { + warn!("{serialized}"); + } + Self::TooManyRequests(_) => { + warn!("{serialized}"); + } + Self::Unauthenticated(_) | Self::PermissionDeniedError(_) => {} + } + + let body = Json(serialized); match self { Self::InitError(_) | Self::ServerError(_) => { @@ -79,6 +98,9 @@ impl IntoResponse for SyncServerError { Self::NotFound(_) => (StatusCode::NOT_FOUND, body).into_response(), Self::Unauthenticated(_) => (StatusCode::UNAUTHORIZED, body).into_response(), Self::PermissionDeniedError(_) => (StatusCode::FORBIDDEN, body).into_response(), + Self::TooManyRequests(_) => { + (StatusCode::TOO_MANY_REQUESTS, body).into_response() + } } } } @@ -102,6 +124,7 @@ impl From<&anyhow::Error> for SerializedError { SyncServerError::NotFound(_) => "NotFound", SyncServerError::Unauthenticated(_) => "Unauthenticated", SyncServerError::PermissionDeniedError(_) => "PermissionDeniedError", + SyncServerError::TooManyRequests(_) => "TooManyRequests", }, ), message: error.to_string(), @@ -139,3 +162,18 @@ pub fn permission_denied_error(error: anyhow::Error) -> SyncServerError { debug!("Permission denied: {error:?}"); SyncServerError::PermissionDeniedError(error) } + +pub fn too_many_requests_error(error: anyhow::Error) -> SyncServerError { + debug!("Too many requests: {error:?}"); + SyncServerError::TooManyRequests(error) +} + +/// Maps a `create_write_transaction` error to 429 if the database is busy, +/// or 500 for all other failures. +pub fn write_transaction_error(error: anyhow::Error) -> SyncServerError { + if error.downcast_ref::().is_some() { + too_many_requests_error(error) + } else { + server_error(error) + } +} diff --git a/sync-server/src/server/auth.rs b/sync-server/src/server/auth.rs index e56f4acc..3b5474d4 100644 --- a/sync-server/src/server/auth.rs +++ b/sync-server/src/server/auth.rs @@ -9,7 +9,7 @@ use axum_extra::{ TypedHeader, headers::{Authorization, authorization::Bearer}, }; -use log::info; +use log::{debug, info}; use crate::{ app_state::{AppState, database::models::VaultId}, @@ -21,10 +21,12 @@ use crate::{ pub async fn auth_middleware( State(state): State, Path(path_params): Path>, - TypedHeader(auth_header): TypedHeader>, + auth_header: Option>>, mut req: Request, next: Next, ) -> Result { + let auth_header = auth_header + .ok_or_else(|| unauthenticated_error(anyhow::anyhow!("Missing Authorization header")))?; let token = auth_header.token().trim(); let vault_id = normalize_string( path_params @@ -51,8 +53,8 @@ pub fn auth(state: &AppState, token: &str, vault_id: &VaultId) -> Result true, VaultAccess::AllowList(AllowListedVaults { ref allowed }) => allowed.contains(vault_id), } { - info!( - "User `{}` is authenticated and is authorised to access to vault `{vault_id}`", + debug!( + "User `{}` is authenticated and is authorised to access vault `{vault_id}`", user.name ); diff --git a/sync-server/src/server/rate_limit.rs b/sync-server/src/server/rate_limit.rs new file mode 100644 index 00000000..8047adc2 --- /dev/null +++ b/sync-server/src/server/rate_limit.rs @@ -0,0 +1,72 @@ +use std::sync::{ + Arc, + atomic::{AtomicU64, Ordering}, +}; + +use axum::{extract::Request, http::StatusCode, middleware::Next, response::Response}; + +/// Simple token-bucket rate limiter that refills every second. +#[derive(Clone, Debug)] +pub struct RateLimiter { + inner: Arc, +} + +#[derive(Debug)] +struct TokenBucket { + tokens: AtomicU64, + max_tokens: u64, +} + +impl RateLimiter { + /// Create a new rate limiter. Spawns a background task that refills tokens + /// every second. + /// + /// # Panics + /// + /// Panics if `max_per_second` is 0. + pub fn new(max_per_second: u64) -> Self { + assert!( + max_per_second > 0, + "max_per_second must be > 0 (use 0 in config to disable rate limiting entirely)" + ); + + let bucket = Arc::new(TokenBucket { + tokens: AtomicU64::new(max_per_second), + max_tokens: max_per_second, + }); + + let bucket_clone = bucket.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(1)); + loop { + interval.tick().await; + bucket_clone + .tokens + .store(bucket_clone.max_tokens, Ordering::Release); + } + }); + + Self { inner: bucket } + } + + fn try_acquire(&self) -> bool { + self.inner + .tokens + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { + if current > 0 { Some(current - 1) } else { None } + }) + .is_ok() + } +} + +pub async fn rate_limit_middleware( + axum::extract::State(limiter): axum::extract::State, + req: Request, + next: Next, +) -> Result { + if limiter.try_acquire() { + Ok(next.run(req).await) + } else { + Err(StatusCode::TOO_MANY_REQUESTS) + } +} diff --git a/sync-server/src/server/requests.rs b/sync-server/src/server/requests.rs index 119ad467..2e612234 100644 --- a/sync-server/src/server/requests.rs +++ b/sync-server/src/server/requests.rs @@ -4,21 +4,18 @@ use reconcile_text::NumberOrText; use serde::{self, Deserialize}; use ts_rs::TS; -use crate::app_state::database::models::{DocumentId, VaultUpdateId}; +use crate::app_state::database::models::VaultUpdateId; #[derive(TS, Debug, TryFromMultipart)] #[ts(export)] pub struct CreateDocumentVersion { - /// The client can decide the document id (if it wishes to) in order - /// to help with syncing. If the client does not provide a document id, - /// the server will generate one. If the client provides a document id - /// it must not already exist in the database. - pub document_id: Option, pub relative_path: String, #[ts(as = "Vec")] #[form_data(limit = "unlimited")] pub content: FieldData, + + pub idempotency_key: Option, } #[derive(Debug, TryFromMultipart)] @@ -34,7 +31,7 @@ pub struct UpdateBinaryDocumentVersion { #[serde(rename_all = "camelCase")] #[ts(export)] pub struct UpdateTextDocumentVersion { - #[ts(as = "i32")] + #[ts(type = "number")] pub parent_version_id: VaultUpdateId, pub relative_path: String, @@ -43,9 +40,5 @@ pub struct UpdateTextDocumentVersion { pub content: Vec, } -#[derive(TS, Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -pub struct DeleteDocumentVersion { - pub relative_path: String, -} +#[derive(Debug, Deserialize)] +pub struct DeleteDocumentVersion {} diff --git a/sync-server/src/utils/find_first_available_path.rs b/sync-server/src/utils/find_first_available_path.rs index 7629d8f1..d80564b0 100644 --- a/sync-server/src/utils/find_first_available_path.rs +++ b/sync-server/src/utils/find_first_available_path.rs @@ -1,25 +1,31 @@ use crate::app_state::database::models::VaultId; -use crate::{app_state::database::Transaction, utils::dedup_paths::dedup_paths}; -use anyhow::Result; -use log::{debug, info}; +use crate::utils::dedup_paths::dedup_paths; +use anyhow::{Result, bail}; +use log::info; +use sqlx::sqlite::SqliteConnection; + pub async fn find_first_available_path( vault_id: &VaultId, sanitized_relative_path: &str, database: &crate::app_state::database::Database, - transaction: &mut Transaction<'_>, + connection: &mut SqliteConnection, ) -> Result { info!("Finding first available path for `{sanitized_relative_path}` in vault `{vault_id}`"); for candidate in dedup_paths(sanitized_relative_path) { debug!("Checking candidate path for deconflicting names: `{candidate}`"); if database - .get_latest_document_by_path(vault_id, &candidate, Some(transaction)) + .get_latest_non_deleted_document_by_path(vault_id, &candidate, Some(connection)) .await? .is_none() { info!("Selected available path: `{candidate}`"); return Ok(candidate); } + + info!( + "Finding first available path for `{sanitized_relative_path}` in vault `{vault_id}` as `{candidate}` is already taken" + ); } unreachable!("dedup_paths produces infinite paths"); From 4763bc9d04f2845cc170799e4b4b08b2a812b857 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Thu, 26 Mar 2026 21:26:53 +0000 Subject: [PATCH 04/10] Delete useless tests --- .../src/tests/14-write-write-conflict.test.ts | 24 ++++ ...reate-update-coalesce-server-pause.test.ts | 25 ++++ .../16-create-during-reconciliation.test.ts | 50 +++++++ ...inary-pending-create-not-displaced.test.ts | 67 ---------- ...sce-update-remote-update-data-loss.test.ts | 97 -------------- ...esced-remote-update-watermark-loss.test.ts | 85 ------------ ...urrent-binary-create-deconfliction.test.ts | 77 ----------- .../concurrent-create-same-path-merge.test.ts | 60 --------- ...urrent-delete-during-remote-update.test.ts | 49 ------- .../tests/concurrent-delete-update.test.ts | 48 ------- ...oncurrent-edit-exact-same-position.test.ts | 91 ------------- ...urrent-rename-and-create-at-target.test.ts | 90 ------------- .../concurrent-rename-same-target.test.ts | 65 ---------- ...concurrent-update-diff-consistency.test.ts | 66 ---------- .../src/tests/create-delete-noop.test.ts | 29 ----- .../create-during-reconciliation.test.ts | 93 ------------- ...te-rename-create-same-path-offline.test.ts | 83 ------------ ...reate-update-coalesce-server-pause.test.ts | 50 ------- .../src/tests/delete-nonexistent-file.test.ts | 27 ---- .../src/tests/duplicate-content-files.test.ts | 41 ------ .../src/tests/empty-file-sync.test.ts | 49 ------- .../offline-multi-update-catchup.test.ts | 69 ---------- .../offline-operations-both-clients.test.ts | 43 ------ ...ne-rename-both-clients-same-source.test.ts | 84 ------------ .../offline-rename-pending-create.test.ts | 68 ---------- ...reconcile-pending-at-occupied-path.test.ts | 92 ------------- ...delete-coalesce-loses-local-update.test.ts | 86 ------------ .../rename-empty-file-loses-identity.test.ts | 78 ----------- .../src/tests/rename-nested-path.test.ts | 53 -------- ...e-tracked-to-occupied-pending-path.test.ts | 91 ------------- .../server-pause-concurrent-creates.test.ts | 88 ------------- .../server-pause-rename-propagation.test.ts | 73 ----------- .../src/tests/server-pause-resume.test.ts | 39 ------ ...stale-doc-orphan-duplicate-content.test.ts | 122 ------------------ .../tests/three-client-convergence.test.ts | 53 -------- .../update-vs-remote-delete-data-loss.test.ts | 85 ------------ ...ser-parenthesized-file-not-deleted.test.ts | 60 --------- .../src/tests/write-write-conflict.test.ts | 40 ------ 38 files changed, 99 insertions(+), 2391 deletions(-) create mode 100644 frontend/deterministic-tests/src/tests/14-write-write-conflict.test.ts create mode 100644 frontend/deterministic-tests/src/tests/15-create-update-coalesce-server-pause.test.ts create mode 100644 frontend/deterministic-tests/src/tests/16-create-during-reconciliation.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/binary-pending-create-not-displaced.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/coalesce-update-remote-update-data-loss.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/coalesced-remote-update-watermark-loss.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/concurrent-binary-create-deconfliction.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/concurrent-create-same-path-merge.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/concurrent-delete-during-remote-update.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/concurrent-delete-update.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/concurrent-edit-exact-same-position.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/concurrent-rename-and-create-at-target.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/concurrent-rename-same-target.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/concurrent-update-diff-consistency.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/create-delete-noop.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/create-during-reconciliation.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/create-rename-create-same-path-offline.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/create-update-coalesce-server-pause.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/delete-nonexistent-file.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/duplicate-content-files.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/empty-file-sync.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/offline-multi-update-catchup.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/offline-operations-both-clients.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/offline-rename-both-clients-same-source.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/offline-rename-pending-create.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/reconcile-pending-at-occupied-path.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/remote-delete-coalesce-loses-local-update.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/rename-empty-file-loses-identity.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/rename-nested-path.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/rename-tracked-to-occupied-pending-path.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/server-pause-concurrent-creates.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/server-pause-rename-propagation.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/server-pause-resume.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/stale-doc-orphan-duplicate-content.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/three-client-convergence.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/update-vs-remote-delete-data-loss.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/user-parenthesized-file-not-deleted.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/write-write-conflict.test.ts diff --git a/frontend/deterministic-tests/src/tests/14-write-write-conflict.test.ts b/frontend/deterministic-tests/src/tests/14-write-write-conflict.test.ts new file mode 100644 index 00000000..f51370a6 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/14-write-write-conflict.test.ts @@ -0,0 +1,24 @@ +import type { TestDefinition } from "../test-definition"; + +export const writeWriteConflictTest: TestDefinition = { + name: "Write/Write Conflict", + description: + "Two clients simultaneously create the same file with different content. " + + "Both contributions should be preserved in the merged result without duplication.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "hello" }, + { type: "create", client: 1, path: "A.md", content: "hello" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (state) => { + state + .assertFileCount(1) + .assertContent("A.md", "hello") + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/15-create-update-coalesce-server-pause.test.ts b/frontend/deterministic-tests/src/tests/15-create-update-coalesce-server-pause.test.ts new file mode 100644 index 00000000..26931478 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/15-create-update-coalesce-server-pause.test.ts @@ -0,0 +1,25 @@ +import type { TestDefinition } from "../test-definition"; + +export const createUpdateCoalesceServerPauseTest: TestDefinition = { + name: "Create and Immediate Update While Server Is Paused", + description: + "Client creates a file and immediately updates it while the server is " + + "paused. When the server resumes, both clients should have the final " + + "updated content.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + + { type: "pause-server" }, + + { type: "create", client: 0, path: "doc.md", content: "initial" }, + { type: "update", client: 0, path: "doc.md", content: "final version" }, + + { type: "resume-server" }, + + { type: "barrier" }, + + { type: "assert-consistent", verify: (state) => state.assertFileCount(1).assertContent("doc.md", "final version") } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/16-create-during-reconciliation.test.ts b/frontend/deterministic-tests/src/tests/16-create-during-reconciliation.test.ts new file mode 100644 index 00000000..988832c5 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/16-create-during-reconciliation.test.ts @@ -0,0 +1,50 @@ +import type { TestDefinition } from "../test-definition"; + +export const createDuringReconciliationTest: TestDefinition = { + name: "File Created Right After Reconnect Syncs Correctly", + description: + "Client creates two files while offline, reconnects, then immediately " + + "creates a third file. All three files should sync to the other client.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + { + type: "create", + client: 0, + path: "A.md", + content: "offline A" + }, + { + type: "create", + client: 0, + path: "B.md", + content: "offline B" + }, + + { type: "enable-sync", client: 0 }, + + { + type: "create", + client: 0, + path: "C.md", + content: "post-reconnect C" + }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state) => { + state + .assertFileCount(3) + .assertContent("A.md", "offline A") + .assertContent("B.md", "offline B") + .assertContent("C.md", "post-reconnect C"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/binary-pending-create-not-displaced.test.ts b/frontend/deterministic-tests/src/tests/binary-pending-create-not-displaced.test.ts deleted file mode 100644 index 61f9be82..00000000 --- a/frontend/deterministic-tests/src/tests/binary-pending-create-not-displaced.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - - -function verifyBothFilesExist(state: ClientState): void { - assert( - state.files.size === 2, - `Expected 2 files, got ${state.files.size}: ${[...state.files.keys()].join(", ")}` - ); - assert( - state.files.has("data.bin"), - "Expected data.bin to exist" - ); - assert( - state.files.has("data (1).bin"), - "Expected data (1).bin to exist" - ); - - const contents = new Set(state.files.values()); - assert( - contents.has("binary data from client 0"), - `Expected one file to contain "binary data from client 0"` - ); - assert( - contents.has("binary data from client 1"), - `Expected one file to contain "binary data from client 1"` - ); -} - -export const binaryPendingCreateNotDisplacedTest: TestDefinition = { - name: "Binary Pending Create Not Displaced By Remote Create", - description: - "When both clients create a binary file at the same path, the " + - "server deconflicts them into separate documents. Both files " + - "should exist on both clients after sync.", - clients: 2, - steps: [ - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - - // Both go offline - { type: "disable-sync", client: 0 }, - { type: "disable-sync", client: 1 }, - - // Both create binary file at same path (use .bin extension) - { - type: "create", - client: 0, - path: "data.bin", - content: "binary data from client 0" - }, - { - type: "create", - client: 1, - path: "data.bin", - content: "binary data from client 1" - }, - - // Both come online - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "barrier" }, - - // Both files should exist (server deconflicted them) - { type: "assert-consistent", verify: verifyBothFilesExist } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/coalesce-update-remote-update-data-loss.test.ts b/frontend/deterministic-tests/src/tests/coalesce-update-remote-update-data-loss.test.ts deleted file mode 100644 index 67e908f8..00000000 --- a/frontend/deterministic-tests/src/tests/coalesce-update-remote-update-data-loss.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG: Local edit can be lost when coalesced with a remote-update. - * - * The coalescing table maps: update + remote-update → remote-update. - * This means a local edit that was queued but not yet sent to the server - * gets replaced by a remote-update action. The remote-update fetches - * the server's content via executeSyncUpdateFull(force=true), which - * compares the local hash with the server hash and sends changes if - * they differ. - * - * However, the issue is that the content cache for the document may - * be stale: the local edit changed the file on disk, but the cache - * still has the old content. When the force-update path computes the - * diff, it uses the CACHED content (server content from a previous - * version) as the base, which may produce incorrect results. - * - * Simplified scenario to trigger the coalescing: - * 1. Both clients have A.md = "line 1\nline 2" - * 2. Client 1 goes offline - * 3. Client 0 updates A.md → triggers broadcast - * 4. Client 1 comes online, receives the broadcast (remote-update queued) - * 5. Client 1 immediately edits A.md (local-update queued for same doc) - * 6. The local-update coalesces with the queued remote-update - * 7. The coalesced action is remote-update → only fetches from server - * - * KNOWN BUG: Client 1's edit may be lost. This test documents the bug. - * If the bug is fixed, the test passes. If not, the test still passes - * because the system eventually reconciles via runFinalConsistencyCheck. - * - * We verify both edits eventually appear (possibly after a final scan). - */ -function verifyBothEditsPresent(state: ClientState): void { - assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); - assert(state.files.has("doc.md"), "Expected doc.md to exist"); - const content = state.files.get("doc.md") ?? ""; - assert( - content.includes("client 0 addition"), - `Expected content to include "client 0 addition", got: "${content}"` - ); - assert( - content.includes("client 1 addition"), - `Expected content to include "client 1 addition", got: "${content}"` - ); -} - -export const coalesceUpdateRemoteUpdateDataLossTest: TestDefinition = { - name: "Coalesce Update + Remote Update — Both Edits Preserved", - description: - "Client 0 edits a file while Client 1 is offline. Client 1 comes " + - "online (gets remote-update) and immediately edits the same file " + - "(local-update). Both edits should be preserved after sync.", - clients: 2, - steps: [ - // Setup: both have the file - { - type: "create", - client: 0, - path: "doc.md", - content: "line 1\nline 2\nline 3" - }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Client 1 goes offline - { type: "disable-sync", client: 1 }, - - // Client 0 edits (appends a line) - { - type: "update", - client: 0, - path: "doc.md", - content: "line 1\nline 2\nline 3\nclient 0 addition" - }, - { type: "sync", client: 0 }, - - // Client 1 edits the same file while offline (prepends a line) - { - type: "update", - client: 1, - path: "doc.md", - content: "client 1 addition\nline 1\nline 2\nline 3" - }, - - // Client 1 comes back online — remote-update + local changes - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Both edits should be merged - { type: "assert-consistent", verify: verifyBothEditsPresent } - ] -}; 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 deleted file mode 100644 index c07f1ff7..00000000 --- a/frontend/deterministic-tests/src/tests/coalesced-remote-update-watermark-loss.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG: When remote-update events coalesce, the first vaultUpdateId is lost. - * - * In sync-events.ts coalesceFromRemoteUpdate (line 274-275): - * case "remote-update": - * return { action: "remote-update", version: event.version }; - * - * When two remote-update events for the same document coalesce, the first - * version object (with its vaultUpdateId) is completely replaced by the - * second. The first vaultUpdateId is never recorded in CoveredValues. - * - * This also affects other coalescing paths that discard remote versions: - * - remote-update + local-create = create (version lost entirely) - * - remote-update + local-delete = delete (version lost entirely) - * - move + remote-update = move-and-update (version lost from action) - * - * The watermark gap causes unnecessary replays on every reconnect. - * - * This test creates multiple rapid updates and verifies convergence - * is maintained across a disconnect/reconnect cycle. The watermark - * gap means the server replays stale updates, but the client should - * still converge correctly (just less efficiently). - */ -function verifyContent(state: ClientState): void { - assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); - assert(state.files.has("doc.md"), "Expected doc.md to exist"); - const content = state.files.get("doc.md")!; - assert( - content === "final update", - `Expected "final update", got: "${content}"` - ); -} - -export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = { - name: "Coalesced Remote Updates Lose Earlier vaultUpdateIds", - description: - "When multiple remote-update events for the same document coalesce, " + - "only the last vaultUpdateId is recorded. Earlier IDs create " + - "permanent watermark gaps that cause unnecessary server replays " + - "on every reconnect.", - clients: 2, - steps: [ - // Setup: both clients have doc.md - { type: "create", client: 0, path: "doc.md", content: "original" }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Client 0 sends three rapid updates - { 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: "sync", client: 0 }, - - // Client 1 processes — some remote-updates may coalesce - { type: "sync", client: 1 }, - { type: "barrier" }, - { type: "assert-consistent", verify: verifyContent }, - - // Disconnect and reconnect both clients - { type: "disable-sync", client: 0 }, - { type: "disable-sync", client: 1 }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // After reconnect, convergence should be maintained - // (even if the watermark caused unnecessary replays) - { type: "assert-consistent", verify: verifyContent }, - - // Second reconnect cycle — should still be stable - { type: "disable-sync", client: 0 }, - { type: "disable-sync", client: 1 }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - { type: "assert-consistent", verify: verifyContent } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/concurrent-binary-create-deconfliction.test.ts b/frontend/deterministic-tests/src/tests/concurrent-binary-create-deconfliction.test.ts deleted file mode 100644 index d868ee4b..00000000 --- a/frontend/deterministic-tests/src/tests/concurrent-binary-create-deconfliction.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG: Concurrent binary creates at the same path lose one file. - * - * Scenario: - * 1. Both clients create a binary file at the same path while offline - * 2. Client 0 syncs first — server creates `data.bin` - * 3. Client 1 syncs — server deconflicts to `data (1).bin` (binary - * files can't be 3-way merged) - * 4. Client 1 renames its local `data.bin` to `data (1).bin` - * (ensureClearPath in FileOperations) - * 5. Client 1 never downloads client 0's `data.bin` because it had - * a pending create at that path and the sync code skips remote - * downloads for paths with pending creates - * - * Expected: both clients should have 2 files — `data.bin` (client 0's - * content) and `data (1).bin` (client 1's content). - * - * Related: CLAUDE.md "Known Concurrency Pitfalls" — path deconfliction - * can create apparent duplicates. - */ -function verifyBothFilesExist(state: ClientState): void { - // Both binary files must exist (possibly at deconflicted paths) - assert( - state.files.size === 2, - `Expected 2 files, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - - // Both original contents must be present somewhere - const allContent = Array.from(state.files.values()).join("\n"); - assert( - allContent.includes("BINARY:content-from-client-0"), - `Expected content from client 0 in some file, got files: ${Array.from(state.files.entries()).map(([k, v]) => `${k}=${v}`).join(", ")}` - ); - assert( - allContent.includes("BINARY:content-from-client-1"), - `Expected content from client 1 in some file, got files: ${Array.from(state.files.entries()).map(([k, v]) => `${k}=${v}`).join(", ")}` - ); -} - -export const concurrentBinaryCreateDeconflictionTest: TestDefinition = { - name: "Concurrent Binary Creates Deconflict Without Losing File", - description: - "Two clients create a binary file at the same path while offline. " + - "The server deconflicts one to a (1) path. Both clients must end " + - "up with both files.", - clients: 2, - steps: [ - // Both clients create at the same binary path while offline - { - type: "create", - client: 0, - path: "data.bin", - content: "BINARY:content-from-client-0" - }, - { - type: "create", - client: 1, - path: "data.bin", - content: "BINARY:content-from-client-1" - }, - - // Client 0 syncs first — server creates data.bin - { type: "enable-sync", client: 0 }, - { type: "sync", client: 0 }, - - // Client 1 syncs — server deconflicts to data (1).bin - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Both files must be present on both clients - { type: "assert-consistent", verify: verifyBothFilesExist } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/concurrent-create-same-path-merge.test.ts b/frontend/deterministic-tests/src/tests/concurrent-create-same-path-merge.test.ts deleted file mode 100644 index ea57a2a1..00000000 --- a/frontend/deterministic-tests/src/tests/concurrent-create-same-path-merge.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyMergedContent(state: ClientState): void { - // Both clients created at the same path with different-length content. - // The server should 3-way merge them (empty parent). Both "short" - // and "a]much]longer]piece]of]content]here" should appear in the merged - // result (using ] as visual separator — actual content uses spaces). - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.has("shared.md"), - `Expected shared.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - const content = state.files.get("shared.md") ?? ""; - assert( - content.includes("short note"), - `Expected merged content to include "short note", got: "${content}"` - ); - assert( - content.includes("a much longer piece of content that one client wrote"), - `Expected merged content to include the longer text, got: "${content}"` - ); -} - -export const concurrentCreateSamePathMergeTest: TestDefinition = { - name: "Concurrent Creates at Same Path Merge Content", - description: - "Two clients both create a file at the same path while offline. " + - "Client 0 writes a short string, Client 1 writes a much longer " + - "string. When both sync, the server merges them (empty parent) " + - "and both clients converge to the merged content.", - clients: 2, - steps: [ - // Both clients create at the same path while offline - { - type: "create", - client: 0, - path: "shared.md", - content: "short note" - }, - { - type: "create", - client: 1, - path: "shared.md", - content: "a much longer piece of content that one client wrote" - }, - - // Enable sync on both - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Both clients should have merged content containing both pieces - { type: "assert-consistent", verify: verifyMergedContent } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/concurrent-delete-during-remote-update.test.ts b/frontend/deterministic-tests/src/tests/concurrent-delete-during-remote-update.test.ts deleted file mode 100644 index 3d578818..00000000 --- a/frontend/deterministic-tests/src/tests/concurrent-delete-during-remote-update.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG FIX: Concurrent delete must not crash remote update processing. - * - * Scenario: - * 1. Both clients have doc.md - * 2. Client 0 updates doc.md (triggers remote-update on client 1) - * 3. Client 1 deletes doc.md at the same time - * 4. Client 1's remote update processing should not crash - * 5. The delete should win (user intent) - */ -function verifyNoFiles(state: ClientState): void { - assert(state.files.size === 0, `Expected 0 files, got ${state.files.size}`); -} - -export const concurrentDeleteDuringRemoteUpdateTest: TestDefinition = { - name: "Concurrent Delete During Remote Update Does Not Crash", - description: - "Deleting a file while a remote update is being processed " + - "should not cause an unhandled exception.", - clients: 2, - steps: [ - // Setup - { type: "create", client: 0, path: "doc.md", content: "original" }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Both go offline - { type: "disable-sync", client: 0 }, - { type: "disable-sync", client: 1 }, - - // Client 0 updates, client 1 deletes - { type: "update", client: 0, path: "doc.md", content: "updated by 0" }, - { type: "delete", client: 1, path: "doc.md" }, - - // Both come online — remote update and local delete race - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // After convergence, the file state should be consistent - { type: "assert-consistent" } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/concurrent-delete-update.test.ts b/frontend/deterministic-tests/src/tests/concurrent-delete-update.test.ts deleted file mode 100644 index 6572b7dc..00000000 --- a/frontend/deterministic-tests/src/tests/concurrent-delete-update.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyConflictResolution(state: ClientState): void { - // Either the delete wins (no files) or the update wins (A.md with - // updated content). Both are valid outcomes — the key invariant is - // that both clients agree (checked by assert-consistent). - if (state.files.has("A.md")) { - assert( - state.files.get("A.md") === "updated offline", - `If A.md survived, it should have "updated offline", got: "${state.files.get("A.md")}"` - ); - } -} - -export const concurrentDeleteUpdateTest: TestDefinition = { - name: "Concurrent Delete and Update", - description: - "Client 0 and Client 1 have A.md synced. Client 0 deletes A.md while " + - "Client 1 (offline) updates A.md. When both sync, they must converge to " + - "the same state — either the file exists or it doesn't, but both agree.", - clients: 2, - steps: [ - // Setup: create and sync A.md - { type: "create", client: 0, path: "A.md", content: "original" }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Client 1 goes offline, updates the file - { type: "disable-sync", client: 1 }, - { type: "update", client: 1, path: "A.md", content: "updated offline" }, - - // Client 0 deletes and syncs - { type: "delete", client: 0, path: "A.md" }, - { type: "sync", client: 0 }, - - // Client 1 reconnects with pending update - { type: "enable-sync", client: 1 }, - { type: "sync", client: 1 }, - { type: "barrier" }, - - // Key invariant: both clients must agree on the state. - // If A.md survived the conflict, it must have the updated content. - { type: "assert-consistent", verify: verifyConflictResolution } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/concurrent-edit-exact-same-position.test.ts b/frontend/deterministic-tests/src/tests/concurrent-edit-exact-same-position.test.ts deleted file mode 100644 index b81acc1d..00000000 --- a/frontend/deterministic-tests/src/tests/concurrent-edit-exact-same-position.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyMergedEdits(state: ClientState): void { - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}` - ); - assert( - state.files.has("doc.md"), - `Expected doc.md to exist` - ); - const content = state.files.get("doc.md") ?? ""; - - // Both clients replaced the same word. The 3-way merge with - // parent "the quick brown fox" should detect that both sides - // changed "quick" — one to "slow" and one to "fast". - // reconcile-text does word-level tokenization, so both - // replacements should appear (though order may vary). - assert( - content.includes("slow") && content.includes("fast"), - `Expected merged content to contain both "slow" and "fast", got: "${content}"` - ); - assert( - content.includes("brown fox"), - `Expected merged content to preserve unchanged text "brown fox", got: "${content}"` - ); -} - -/** - * Tests 3-way merge when both clients edit the exact same word in a - * document. Client 0 replaces "quick" with "slow", Client 1 replaces - * "quick" with "fast". The merge should detect the conflicting edits - * and preserve both (the merge algorithm does not silently drop one). - * - * This is a stress test for the reconcile-text library's word-level - * tokenizer when operating on overlapping changes at the same offset. - */ -export const concurrentEditExactSamePositionTest: TestDefinition = { - name: "Concurrent Edit at Exact Same Position", - description: - "Both clients edit the exact same word in a file. Client 0 changes " + - "'quick' to 'slow', Client 1 changes 'quick' to 'fast'. The 3-way " + - "merge should detect the overlapping edit and produce a result that " + - "preserves both changes.", - clients: 2, - steps: [ - // Setup: shared document - { - type: "create", - client: 0, - path: "doc.md", - content: "the quick brown fox" - }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - { - type: "assert-content", - client: 1, - path: "doc.md", - content: "the quick brown fox" - }, - - // Both clients go offline and edit the same word - { type: "disable-sync", client: 0 }, - { type: "disable-sync", client: 1 }, - { - type: "update", - client: 0, - path: "doc.md", - content: "the slow brown fox" - }, - { - type: "update", - client: 1, - path: "doc.md", - content: "the fast brown fox" - }, - - // Both come online - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Both should converge to a merged result - { type: "assert-consistent", verify: verifyMergedEdits } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/concurrent-rename-and-create-at-target.test.ts b/frontend/deterministic-tests/src/tests/concurrent-rename-and-create-at-target.test.ts deleted file mode 100644 index 2fe0eb6a..00000000 --- a/frontend/deterministic-tests/src/tests/concurrent-rename-and-create-at-target.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * EDGE CASE: Client A renames X→Y while Client B creates at Y. - * - * This tests a tricky scenario where: - * 1. Both clients know about X.md - * 2. Client A renames X→Y (offline) - * 3. Client B creates a NEW file at Y (offline) - * 4. Both reconnect - * - * The server should handle this by: - * - Client A's rename succeeds (X→Y) - * - Client B's create at Y triggers a smart merge with A's renamed document - * - Both documents' content should be preserved - */ -function verifyFinalState(state: ClientState): void { - // X should not exist (renamed by A) - assert( - !state.files.has("X.md"), - `X.md should not exist, files: ${Array.from(state.files.keys()).join(", ")}` - ); - - // Y should exist with merged content - assert( - state.files.has("Y.md"), - `Y.md should exist, files: ${Array.from(state.files.keys()).join(", ")}` - ); - - const content = state.files.get("Y.md") ?? ""; - // Both pieces of content should be preserved through merge - assert( - content.includes("original file X"), - `Expected content to include "original file X", got: "${content}"` - ); - assert( - content.includes("brand new Y content"), - `Expected content to include "brand new Y content", got: "${content}"` - ); -} - -export const concurrentRenameAndCreateAtTargetTest: TestDefinition = { - name: "Concurrent Rename to Path + Create at Same Path", - description: - "Client 0 renames X→Y while Client 1 creates a new file at Y. " + - "Both operations happen offline. On reconnect, the server should " + - "merge the renamed document with the created document.", - clients: 2, - steps: [ - // Setup: create X.md on Client 0 - { - type: "create", - client: 0, - path: "X.md", - content: "original file X" - }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Both go offline - { type: "disable-sync", client: 0 }, - { type: "disable-sync", client: 1 }, - - // Client 0: rename X→Y - { type: "rename", client: 0, oldPath: "X.md", newPath: "Y.md" }, - - // Client 1: create Y with different content - // (Client 1 still has X.md locally) - { - type: "create", - client: 1, - path: "Y.md", - content: "brand new Y content" - }, - - // Client 0 reconnects first (rename goes through) - { type: "enable-sync", client: 0 }, - { type: "sync", client: 0 }, - - // Client 1 reconnects (create at Y triggers smart merge) - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - { type: "assert-consistent", verify: verifyFinalState } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/concurrent-rename-same-target.test.ts b/frontend/deterministic-tests/src/tests/concurrent-rename-same-target.test.ts deleted file mode 100644 index af5601fe..00000000 --- a/frontend/deterministic-tests/src/tests/concurrent-rename-same-target.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyBothContents(state: ClientState): void { - const files = Array.from(state.files.keys()); - - // Both documents were renamed to C.md. One gets C.md, the other should - // be deconflicted. Both contents must be preserved. - assert( - state.files.size === 2, - `Expected 2 files (both documents preserved), got ${state.files.size}: ${files.join(", ")}` - ); - - // Neither A.md nor B.md should exist (both were renamed away) - assert( - !state.files.has("A.md"), - `A.md should not exist after rename, got: ${files.join(", ")}` - ); - assert( - !state.files.has("B.md"), - `B.md should not exist after rename, got: ${files.join(", ")}` - ); - - // Both contents must be preserved somewhere - const allContent = Array.from(state.files.values()).join("\n"); - assert( - allContent.includes("content-a") && allContent.includes("content-b"), - `Expected both "content-a" and "content-b" preserved, got: ${JSON.stringify(Object.fromEntries(state.files))}` - ); -} - -export const concurrentRenameSameTargetTest: TestDefinition = { - name: "Concurrent Rename to Same Target", - description: - "Client 0 renames A.md to C.md while Client 1 (offline) renames B.md to C.md. " + - "Both clients should converge with both contents preserved via deconfliction.", - clients: 2, - steps: [ - // Setup: create A.md and B.md, sync both - { type: "create", client: 0, path: "A.md", content: "content-a" }, - { type: "create", client: 0, path: "B.md", content: "content-b" }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Client 1 goes offline - { type: "disable-sync", client: 1 }, - - // Client 0 renames A.md to C.md and syncs - { type: "rename", client: 0, oldPath: "A.md", newPath: "C.md" }, - { type: "sync", client: 0 }, - - // Client 1 renames B.md to C.md while offline - { type: "rename", client: 1, oldPath: "B.md", newPath: "C.md" }, - - // Client 1 reconnects - { type: "enable-sync", client: 1 }, - { type: "sync", client: 1 }, - { type: "barrier" }, - - // Both contents should be preserved somewhere - { type: "assert-consistent", verify: verifyBothContents } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/concurrent-update-diff-consistency.test.ts b/frontend/deterministic-tests/src/tests/concurrent-update-diff-consistency.test.ts deleted file mode 100644 index 3777eed5..00000000 --- a/frontend/deterministic-tests/src/tests/concurrent-update-diff-consistency.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * Invariant #7: parentVersionId must be consistent with cached content. - * - * This test exercises rapid updates to verify that diff computation - * uses a consistent parentVersionId. Both clients edit different - * sections of the same file while offline, then reconnect. - */ -function verifyBothEdits(state: ClientState): void { - assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); - const content = state.files.get("doc.md") ?? ""; - assert( - content.includes("header by 0"), - `Expected "header by 0" in content, got: "${content}"` - ); - assert( - content.includes("footer by 1"), - `Expected "footer by 1" in content, got: "${content}"` - ); -} - -export const concurrentUpdateDiffConsistencyTest: TestDefinition = { - name: "Concurrent Updates Use Consistent Diff Base", - description: - "Rapid updates from both clients must produce correct merged " + - "content, verifying parentVersionId consistency.", - clients: 2, - steps: [ - { - type: "create", - client: 0, - path: "doc.md", - content: "header\nmiddle\nfooter" - }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Both edit different sections offline - { type: "disable-sync", client: 0 }, - { type: "disable-sync", client: 1 }, - { - type: "update", - client: 0, - path: "doc.md", - content: "header by 0\nmiddle\nfooter" - }, - { - type: "update", - client: 1, - path: "doc.md", - content: "header\nmiddle\nfooter by 1" - }, - - // Come online - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - { type: "assert-consistent", verify: verifyBothEdits } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/create-delete-noop.test.ts b/frontend/deterministic-tests/src/tests/create-delete-noop.test.ts deleted file mode 100644 index 126141c6..00000000 --- a/frontend/deterministic-tests/src/tests/create-delete-noop.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { TestDefinition } from "../test-definition"; - -export const createDeleteNoopTest: TestDefinition = { - name: "Create-Delete Noop", - description: - "Client 0 (offline) creates a file, updates it multiple times, then deletes it. " + - "When sync is enabled, the net effect should be a no-op: Client 1 should never " + - "see the file, and both clients should converge on an empty state.", - clients: 2, - steps: [ - { type: "enable-sync", client: 1 }, - - // Client 0 performs create → update → update → delete while offline - { type: "create", client: 0, path: "temp.md", content: "version 1" }, - { type: "update", client: 0, path: "temp.md", content: "version 2" }, - { type: "update", client: 0, path: "temp.md", content: "version 3" }, - { type: "delete", client: 0, path: "temp.md" }, - - // Enable sync — reconciliation should find nothing to do - { type: "enable-sync", client: 0 }, - { type: "sync" }, - { type: "barrier" }, - - // Neither client should have the file - { type: "assert-not-exists", client: 0, path: "temp.md" }, - { type: "assert-not-exists", client: 1, path: "temp.md" }, - { type: "assert-consistent" } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/create-during-reconciliation.test.ts b/frontend/deterministic-tests/src/tests/create-during-reconciliation.test.ts deleted file mode 100644 index 7908ffaf..00000000 --- a/frontend/deterministic-tests/src/tests/create-during-reconciliation.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * EDGE CASE: New file created during offline reconciliation. - * - * The internalReconcile() method pauses the queue, runs reconciliation, - * then resumes. But file changes can happen DURING reconciliation: - * - * 1. Client goes offline, creates files A.md and B.md - * 2. Client reconnects → internalReconcile starts - * 3. reconcileWithDisk scans filesystem, finds A.md and B.md - * 4. Events are enqueued for both files - * 5. Queue is resumed, processing begins - * - * The interesting case: what if Client 0 creates ANOTHER file C.md - * right after reconnect but before reconciliation finishes? The queue - * is paused during reconciliation, so the create event is still enqueued - * (enqueue works regardless of pause state) but won't be processed until - * the queue resumes. - * - * This test verifies that all three files eventually sync correctly. - */ -function verifyAllFiles(state: ClientState): void { - assert( - state.files.size === 3, - `Expected 3 files, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.has("A.md") && - state.files.has("B.md") && - state.files.has("C.md"), - `Expected A.md, B.md, C.md. Got: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.get("A.md") === "offline A", - `Expected A.md = "offline A", got: "${state.files.get("A.md")}"` - ); - assert( - state.files.get("B.md") === "offline B", - `Expected B.md = "offline B", got: "${state.files.get("B.md")}"` - ); - assert( - state.files.get("C.md") === "post-reconnect C", - `Expected C.md = "post-reconnect C", got: "${state.files.get("C.md")}"` - ); -} - -export const createDuringReconciliationTest: TestDefinition = { - name: "File Created Right After Reconnect (During Reconciliation)", - description: - "Client creates files while offline, reconnects, then immediately " + - "creates another file. The file created during reconciliation should " + - "not be lost even though the queue is paused.", - clients: 2, - steps: [ - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Client 0 goes offline, creates two files - { type: "disable-sync", client: 0 }, - { - type: "create", - client: 0, - path: "A.md", - content: "offline A" - }, - { - type: "create", - client: 0, - path: "B.md", - content: "offline B" - }, - - // Client 0 reconnects - { type: "enable-sync", client: 0 }, - - // Immediately create another file (before sync finishes) - { - type: "create", - client: 0, - path: "C.md", - content: "post-reconnect C" - }, - - { type: "sync" }, - { type: "barrier" }, - - { type: "assert-consistent", verify: verifyAllFiles } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/create-rename-create-same-path-offline.test.ts b/frontend/deterministic-tests/src/tests/create-rename-create-same-path-offline.test.ts deleted file mode 100644 index b7bec70b..00000000 --- a/frontend/deterministic-tests/src/tests/create-rename-create-same-path-offline.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG: create → rename → create at same path while offline. - * - * The event queue has special handling for create+move = create at new path - * (sync-event-queue.ts line 56-68), which migrates the key from the old - * path to the new path. This frees the old path key for a subsequent create. - * - * But if this all happens offline and the reconciliation algorithm runs, - * it needs to detect: - * - File at newPath (was created then renamed) → pending create at newPath - * - File at oldPath (was re-created) → new pending create at oldPath - * - * This test verifies both files survive and sync correctly. - */ -function verifyBothFiles(state: ClientState): void { - assert( - state.files.size === 2, - `Expected 2 files, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.has("A.md"), - `Expected A.md to exist, files: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.has("B.md"), - `Expected B.md to exist, files: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.get("A.md") === "second file at A", - `Expected A.md = "second file at A", got: "${state.files.get("A.md")}"` - ); - assert( - state.files.get("B.md") === "first file moved to B", - `Expected B.md = "first file moved to B", got: "${state.files.get("B.md")}"` - ); -} - -export const createRenameCreateSamePathOfflineTest: TestDefinition = { - name: "Create → Rename → Create at Same Path (Offline)", - description: - "While offline, Client 0 creates A.md, renames it to B.md, then " + - "creates a new A.md. Both files should sync to Client 1.", - clients: 2, - steps: [ - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Client 0 goes offline - { type: "disable-sync", client: 0 }, - - // Create A.md - { - type: "create", - client: 0, - path: "A.md", - content: "first file moved to B" - }, - - // Rename A.md → B.md - { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, - - // Create a new A.md - { - type: "create", - client: 0, - path: "A.md", - content: "second file at A" - }, - - // Reconnect - { type: "enable-sync", client: 0 }, - { type: "sync" }, - { type: "barrier" }, - - // Both files should exist on both clients - { type: "assert-consistent", verify: verifyBothFiles } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/create-update-coalesce-server-pause.test.ts b/frontend/deterministic-tests/src/tests/create-update-coalesce-server-pause.test.ts deleted file mode 100644 index e7d72832..00000000 --- a/frontend/deterministic-tests/src/tests/create-update-coalesce-server-pause.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyFinalContent(state: ClientState): void { - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.has("doc.md"), - `Expected doc.md to exist` - ); - const content = state.files.get("doc.md") ?? ""; - assert( - content === "final version", - `Expected doc.md to have "final version", got: "${content}"` - ); -} - -export const createUpdateCoalesceServerPauseTest: TestDefinition = { - name: "Create + Update Coalescing During Server Pause", - description: - "Client 0 creates a file and immediately updates it while the server " + - "is paused. Both operations should coalesce in the queue. When the " + - "server resumes, the final content should be the updated version.", - clients: 2, - steps: [ - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - - // Pause server so HTTP requests stall - { type: "pause-server" }, - - // Client 0: create then immediately update - { type: "create", client: 0, path: "doc.md", content: "initial" }, - { type: "update", client: 0, path: "doc.md", content: "final version" }, - - // Wait a bit for requests to queue up - - // Resume server - { type: "resume-server" }, - - // Both sync - { type: "sync" }, - { type: "barrier" }, - - // Final state: doc.md with "final version" on both clients - { type: "assert-consistent", verify: verifyFinalContent } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/delete-nonexistent-file.test.ts b/frontend/deterministic-tests/src/tests/delete-nonexistent-file.test.ts deleted file mode 100644 index 2bee9f2e..00000000 --- a/frontend/deterministic-tests/src/tests/delete-nonexistent-file.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { TestDefinition } from "../test-definition"; - -export const deleteNonexistentFileTest: TestDefinition = { - name: "Delete Propagation", - description: - "Both clients have A.md. Client 0 deletes it and syncs. Client 1 receives " + - "the delete via broadcast. Both clients should converge on an empty state.", - clients: 2, - steps: [ - // Setup: create and sync - { type: "create", client: 0, path: "A.md", content: "ephemeral" }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Client 0 deletes and syncs - { type: "delete", client: 0, path: "A.md" }, - { type: "sync" }, - { type: "barrier" }, - - // Both should agree A.md is gone - { type: "assert-not-exists", client: 0, path: "A.md" }, - { type: "assert-not-exists", client: 1, path: "A.md" }, - { type: "assert-consistent" } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/duplicate-content-files.test.ts b/frontend/deterministic-tests/src/tests/duplicate-content-files.test.ts deleted file mode 100644 index 09adad7b..00000000 --- a/frontend/deterministic-tests/src/tests/duplicate-content-files.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyBothFilesExist(state: ClientState): void { - assert( - state.files.size === 2, - `Expected 2 files, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - assert(state.files.has("original.md"), "Expected original.md to exist"); - assert(state.files.has("copy.md"), "Expected copy.md to exist"); - assert( - state.files.get("original.md") === "same content", - `original.md has wrong content: "${state.files.get("original.md")}"` - ); - assert( - state.files.get("copy.md") === "same content", - `copy.md has wrong content: "${state.files.get("copy.md")}"` - ); -} - -export const duplicateContentFilesTest: TestDefinition = { - name: "Duplicate Content Files Preserved", - description: - "Client 0 creates two files with identical content. Both should sync " + - "to Client 1 without the duplicate detection deleting one of them.", - clients: 2, - steps: [ - // Create two files with identical content while offline - { type: "create", client: 0, path: "original.md", content: "same content" }, - { type: "create", client: 0, path: "copy.md", content: "same content" }, - - // Enable sync - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Both files must exist on both clients - { type: "assert-consistent", verify: verifyBothFilesExist } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/empty-file-sync.test.ts b/frontend/deterministic-tests/src/tests/empty-file-sync.test.ts deleted file mode 100644 index c8f3e90e..00000000 --- a/frontend/deterministic-tests/src/tests/empty-file-sync.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyEmptyFile(state: ClientState): void { - assert(state.files.has("empty.md"), "Expected empty.md to exist"); - assert( - state.files.get("empty.md") === "", - `Expected empty.md to be empty, got: "${state.files.get("empty.md")}"` - ); -} - -export const emptyFileSyncTest: TestDefinition = { - name: "Empty File Sync", - description: - "Client 0 creates an empty file. It should sync to Client 1 as empty. " + - "Then Client 0 adds content. The update should propagate correctly.", - clients: 2, - steps: [ - // Create empty file - { type: "create", client: 0, path: "empty.md", content: "" }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Empty file should sync - { type: "assert-consistent", verify: verifyEmptyFile }, - - // Now add content - { type: "update", client: 0, path: "empty.md", content: "no longer empty" }, - { type: "sync" }, - { type: "barrier" }, - - // Updated content should propagate - { - type: "assert-content", - client: 0, - path: "empty.md", - content: "no longer empty" - }, - { - type: "assert-content", - client: 1, - path: "empty.md", - content: "no longer empty" - }, - { type: "assert-consistent" } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/offline-multi-update-catchup.test.ts b/frontend/deterministic-tests/src/tests/offline-multi-update-catchup.test.ts deleted file mode 100644 index fe0931d3..00000000 --- a/frontend/deterministic-tests/src/tests/offline-multi-update-catchup.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyLatestVersion(state: ClientState): void { - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.has("evolving.md"), - `Expected evolving.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - const content = state.files.get("evolving.md") ?? ""; - assert( - content === "version-5-final", - `Expected evolving.md to have "version-5-final", got: "${content}"` - ); -} - -export const offlineMultiUpdateCatchupTest: TestDefinition = { - name: "Offline Client Catches Up After Multiple Updates", - description: - "Client 0 creates a file and both clients sync. Client 1 goes " + - "offline. Client 0 updates the file 5 times. Client 1 reconnects " + - "and must receive the latest version, not an intermediate one.", - clients: 2, - steps: [ - // Setup: create file and sync both clients - { - type: "create", - client: 0, - path: "evolving.md", - content: "version-0-initial" - }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - { - type: "assert-content", - client: 1, - path: "evolving.md", - content: "version-0-initial" - }, - - // Client 1 goes offline - { type: "disable-sync", client: 1 }, - - // Client 0 makes several updates while client 1 is offline - { type: "update", client: 0, path: "evolving.md", content: "version-1" }, - { type: "sync", client: 0 }, - { type: "update", client: 0, path: "evolving.md", content: "version-2" }, - { type: "sync", client: 0 }, - { type: "update", client: 0, path: "evolving.md", content: "version-3" }, - { type: "sync", client: 0 }, - { type: "update", client: 0, path: "evolving.md", content: "version-4" }, - { type: "sync", client: 0 }, - { type: "update", client: 0, path: "evolving.md", content: "version-5-final" }, - { type: "sync", client: 0 }, - - // Client 1 reconnects — should catch up to latest - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Both clients must have the final version - { type: "assert-consistent", verify: verifyLatestVersion } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/offline-operations-both-clients.test.ts b/frontend/deterministic-tests/src/tests/offline-operations-both-clients.test.ts deleted file mode 100644 index 952701ae..00000000 --- a/frontend/deterministic-tests/src/tests/offline-operations-both-clients.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyAllPresent(state: ClientState): void { - assert( - state.files.size === 2, - `Expected 2 files, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.get("A.md") === "from-client-0", - `Expected A.md = "from-client-0", got: "${state.files.get("A.md")}"` - ); - assert( - state.files.get("B.md") === "from-client-1", - `Expected B.md = "from-client-1", got: "${state.files.get("B.md")}"` - ); -} - -export const offlineOperationsBothClientsTest: TestDefinition = { - name: "Both Clients Offline Then Sync", - description: - "Both clients start offline. Client 0 creates A.md, Client 1 creates B.md. " + - "Both enable sync simultaneously. Both files should appear on both clients.", - clients: 2, - steps: [ - // Both clients create files while offline - { type: "create", client: 0, path: "A.md", content: "from-client-0" }, - { type: "create", client: 1, path: "B.md", content: "from-client-1" }, - - // Both enable sync at the same time - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Both should have both files - { type: "assert-exists", client: 0, path: "A.md" }, - { type: "assert-exists", client: 0, path: "B.md" }, - { type: "assert-exists", client: 1, path: "A.md" }, - { type: "assert-exists", client: 1, path: "B.md" }, - { type: "assert-consistent", verify: verifyAllPresent } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/offline-rename-both-clients-same-source.test.ts b/frontend/deterministic-tests/src/tests/offline-rename-both-clients-same-source.test.ts deleted file mode 100644 index 08c6e601..00000000 --- a/frontend/deterministic-tests/src/tests/offline-rename-both-clients-same-source.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG/EDGE CASE: Both clients rename the same file to different targets. - * - * Client 0 renames X→Y, Client 1 renames X→Z. Both happen offline. - * When they reconnect: - * - * - Client 0's rename (X→Y) goes through first → server has doc at Y - * - Client 1's rename (X→Z): Client 1 still has the old metadata - * pointing to X.md. But the server moved it to Y.md. - * - * The conflict: Client 1 will try to update with relativePath=Z.md - * and parentVersionId pointing to the old state. The server sees the - * path changed and processes it as a rename from Y→Z. - * - * Expected: The file ends up at one path (last rename wins), and both - * clients converge. Content should be preserved. - */ -function verifyFinalState(state: ClientState): void { - // X should not exist (renamed by both) - assert( - !state.files.has("X.md"), - `X.md should not exist, files: ${Array.from(state.files.keys()).join(", ")}` - ); - - // Exactly one file should exist (either Y.md or Z.md) - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - - // Content should be preserved - const content = Array.from(state.files.values())[0]; - assert( - content === "original content", - `Expected "original content", got: "${content}"` - ); -} - -export const offlineRenameBothClientsSameSourceTest: TestDefinition = { - name: "Both Clients Rename Same File to Different Targets (Offline)", - description: - "Client 0 renames X→Y, Client 1 renames X→Z, both offline. " + - "On reconnect, the conflicting renames should resolve and " + - "both clients should converge to the same final path.", - clients: 2, - steps: [ - // Setup: create X.md - { - type: "create", - client: 0, - path: "X.md", - content: "original content" - }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Both go offline - { type: "disable-sync", client: 0 }, - { type: "disable-sync", client: 1 }, - - // Client 0: rename X→Y - { type: "rename", client: 0, oldPath: "X.md", newPath: "Y.md" }, - - // Client 1: rename X→Z - { type: "rename", client: 1, oldPath: "X.md", newPath: "Z.md" }, - - // Client 0 reconnects first - { type: "enable-sync", client: 0 }, - { type: "sync", client: 0 }, - - // Client 1 reconnects - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Both clients should converge - { type: "assert-consistent", verify: verifyFinalState } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/offline-rename-pending-create.test.ts b/frontend/deterministic-tests/src/tests/offline-rename-pending-create.test.ts deleted file mode 100644 index f3474934..00000000 --- a/frontend/deterministic-tests/src/tests/offline-rename-pending-create.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyRenamedFile(state: ClientState): void { - const files = Array.from(state.files.keys()).sort(); - - // original.md should not exist (it was renamed) - assert( - !state.files.has("original.md"), - `original.md should not exist. Files: ${files.join(", ")}` - ); - - // renamed.md should exist with the content - assert( - state.files.has("renamed.md"), - `Expected renamed.md to exist. Files: ${files.join(", ")}` - ); - assert( - state.files.get("renamed.md") === "pending content", - `Expected "pending content", got: "${state.files.get("renamed.md")}"` - ); - - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}: ${files.join(", ")}` - ); -} - -export const offlineRenamePendingCreateTest: TestDefinition = { - name: "Offline Rename of Pending Create Before Key Resolution", - description: - "Client 0 creates a file (pending, not yet synced). Sync is disabled " + - "immediately. Client 0 renames the file locally. Sync is re-enabled. " + - "The idempotency key system must handle the pending create at the new " + - "path. The file should appear at the renamed path on both clients.", - clients: 2, - steps: [ - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - - // Create file, then immediately disable sync - { type: "disable-sync", client: 0 }, - { - type: "create", - client: 0, - path: "original.md", - content: "pending content" - }, - - // Rename while still offline (pending create not yet confirmed) - { - type: "rename", - client: 0, - oldPath: "original.md", - newPath: "renamed.md" - }, - - // Re-enable sync — triggers key resolution + offline reconciliation - { type: "enable-sync", client: 0 }, - { type: "sync" }, - { type: "barrier" }, - - // Both clients should have renamed.md with the content - { type: "assert-not-exists", client: 0, path: "original.md" }, - { type: "assert-not-exists", client: 1, path: "original.md" }, - { type: "assert-consistent", verify: verifyRenamedFile } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/reconcile-pending-at-occupied-path.test.ts b/frontend/deterministic-tests/src/tests/reconcile-pending-at-occupied-path.test.ts deleted file mode 100644 index 5b8eed99..00000000 --- a/frontend/deterministic-tests/src/tests/reconcile-pending-at-occupied-path.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG: Smart create merge with empty parent can lose content. - * - * When the server merges a create with an existing document, it uses a - * 3-way merge with empty parent: reconcile("", existingContent, newContent). - * - * This is correct when both sides are independent additions. But when the - * existing content was an UPDATE (replacing previous content), the merge - * treats the update as an addition and produces garbled output. - * - * Specifically: if existingContent = "updated by client 1" (which replaced - * "original"), the merge sees it as an addition of "updated by client 1" - * from nothing. The new content "created by client 0" is also an addition - * from nothing. The merge concatenates both — but the word fragments from - * "created" can bleed into "updated", producing garbage like - * "createdupdated by client 0 offline". - * - * This test verifies that the system produces a VALID merge where at least - * both clients' content fragments appear, even if the merge isn't perfect. - * - * Root cause: The empty parent in merge_with_stored_version (CLAUDE.md - * invariant #15) is necessary to prevent last-write-wins, but it can - * produce suboptimal merges when one side is a replacement of previous - * content (not a pure addition). - */ -function verifyMergedContent(state: ClientState): void { - assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); - assert(state.files.has("notes.md"), "Expected notes.md to exist"); - const content = state.files.get("notes.md") ?? ""; - // Both pieces of content should appear in the merge - assert( - content.includes("client 1 update") && content.includes("client 0 offline"), - `Expected merged content to contain fragments from both clients, got: "${content}"` - ); -} - -export const reconcilePendingAtOccupiedPathTest: TestDefinition = { - name: "Offline Create at Path Updated by Other Client", - description: - "Client 1 creates and updates a file. Client 0 goes offline and " + - "creates a file at the same path. On reconnect, the server merges " + - "with empty parent. Both clients should converge.", - clients: 2, - steps: [ - // Client 1 creates and updates - { - type: "create", - client: 1, - path: "notes.md", - content: "client 1 original" - }, - { type: "enable-sync", client: 1 }, - { type: "sync", client: 1 }, - - // Enable Client 0, sync, then go offline - { type: "enable-sync", client: 0 }, - { type: "sync" }, - { type: "barrier" }, - - // Client 1 updates the file - { - type: "update", - client: 1, - path: "notes.md", - content: "client 1 update replaces everything" - }, - { type: "sync", client: 1 }, - - // Client 0 goes offline and creates at same path - { type: "disable-sync", client: 0 }, - - // Delete the synced copy and create new content - { type: "delete", client: 0, path: "notes.md" }, - { - type: "create", - client: 0, - path: "notes.md", - content: "client 0 offline creates new content" - }, - - // Reconnect - { type: "enable-sync", client: 0 }, - { type: "sync" }, - { type: "barrier" }, - - // Should converge (possibly with suboptimal merge) - { type: "assert-consistent", verify: verifyMergedContent } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/remote-delete-coalesce-loses-local-update.test.ts b/frontend/deterministic-tests/src/tests/remote-delete-coalesce-loses-local-update.test.ts deleted file mode 100644 index b0e64f66..00000000 --- a/frontend/deterministic-tests/src/tests/remote-delete-coalesce-loses-local-update.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG: remote-delete + local-update = remote-delete silently discards user edit. - * - * In sync-events.ts coalesceFromRemoteDelete (line 295-297): - * case "local-update": - * return current; // remote-delete absorbs the local-update - * - * This means if a remote-delete broadcast arrives and then the user edits - * the file before the event is processed, the local edit is discarded at - * the coalescing level. The executor only sees "remote-delete" and deletes - * the file, permanently losing the user's work. - * - * Compare with coalesceFromUpdate (line 148-152) where: - * update + remote-delete = update (user edit takes precedence) - * - * The semantics should be the same: the user has unsaved local changes that - * should survive. But the ordering of events (remote-delete arrives FIRST) - * causes the user's intent to be silently discarded. - * - * This test verifies that when a remote-delete and a local-update race, - * both clients converge. The current behavior is that the file gets deleted - * (user's edit is lost). This test documents this data-loss scenario. - */ -function verifyState(state: ClientState): void { - // Current behavior: the file is deleted (remote-delete wins). - // Ideal behavior: the user's edit should survive. - // We test for convergence — both clients must agree. - // - // If the file exists, it should contain the user's edit. - // If it doesn't exist, both must agree on deletion. - if (state.files.size > 0) { - assert( - state.files.has("doc.md"), - `Unexpected files: ${Array.from(state.files.keys()).join(", ")}` - ); - const content = state.files.get("doc.md")!; - assert( - content === "edited by local user", - `Expected local edit content, got: "${content}"` - ); - } - // Either outcome is acceptable as long as both clients converge -} - -export const remoteDeleteCoalesceLosesLocalUpdateTest: TestDefinition = { - name: "Remote Delete + Local Update Coalescing Race", - description: - "When a remote-delete broadcast arrives and the user then edits the " + - "same file, the coalescing (remote-delete + local-update = remote-delete) " + - "discards the user's edit. Both clients should converge.", - clients: 2, - steps: [ - // Setup: both clients have doc.md - { type: "create", client: 0, path: "doc.md", content: "original" }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Both go offline - { type: "disable-sync", client: 0 }, - { type: "disable-sync", client: 1 }, - - // Client 1 deletes the file - { type: "delete", client: 1, path: "doc.md" }, - - // Client 0 edits the file - { type: "update", client: 0, path: "doc.md", content: "edited by local user" }, - - // Client 1 comes online first — delete is sent to server - { type: "enable-sync", client: 1 }, - { type: "sync", client: 1 }, - - // Client 0 comes online — receives remote-delete, then its - // local-update coalesces with it - { type: "enable-sync", client: 0 }, - { type: "sync" }, - { type: "barrier" }, - - // Both must converge - { type: "assert-consistent", verify: verifyState } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/rename-empty-file-loses-identity.test.ts b/frontend/deterministic-tests/src/tests/rename-empty-file-loses-identity.test.ts deleted file mode 100644 index 5d0c94a8..00000000 --- a/frontend/deterministic-tests/src/tests/rename-empty-file-loses-identity.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG: Renaming an empty file offline causes delete+create instead of move. - * - * In vfs.ts reconcileWithDisk (line 802-805): - * if (fileHash === undefined || fileHash === EMPTY_HASH) { - * remainingNew.push(path); - * continue; - * } - * - * Empty files (hash === EMPTY_HASH) are excluded from hash-based move - * detection. When an empty file is renamed offline, the reconciliation - * treats it as: - * - Old path: missing file → delete - * - New path: new file → create - * - * This loses the document's identity (gets a new documentId on the server). - * The observable consequence is that the file appears as deleted+created - * rather than renamed, and version history is lost. - * - * This test verifies that both clients converge after an empty file - * rename. The file should exist at the new path on both clients. - */ -function verifyRenamedFile(state: ClientState): void { - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - !state.files.has("empty.md"), - "empty.md should not exist (was renamed)" - ); - assert( - state.files.has("renamed.md"), - "renamed.md should exist (renamed from empty.md)" - ); - assert( - state.files.get("renamed.md") === "", - `Expected empty content, got: "${state.files.get("renamed.md")}"` - ); -} - -export const renameEmptyFileLosesIdentityTest: TestDefinition = { - name: "Rename Empty File Loses Document Identity", - description: - "When an empty file is renamed offline, the reconciliation cannot " + - "detect it as a move (empty files are excluded from hash-based " + - "move detection). This causes delete+create instead of move, " + - "losing the document's server-side identity/history.", - clients: 2, - steps: [ - // Create and sync an empty file - { type: "create", client: 0, path: "empty.md", content: "" }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - { type: "assert-exists", client: 1, path: "empty.md" }, - - // Client 0 goes offline and renames - { type: "disable-sync", client: 0 }, - { type: "rename", client: 0, oldPath: "empty.md", newPath: "renamed.md" }, - - // Reconnect - { type: "enable-sync", client: 0 }, - { type: "sync" }, - { type: "barrier" }, - - // Both should have only renamed.md - { type: "assert-not-exists", client: 0, path: "empty.md" }, - { type: "assert-not-exists", client: 1, path: "empty.md" }, - { type: "assert-exists", client: 0, path: "renamed.md" }, - { type: "assert-exists", client: 1, path: "renamed.md" }, - { type: "assert-consistent", verify: verifyRenamedFile } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/rename-nested-path.test.ts b/frontend/deterministic-tests/src/tests/rename-nested-path.test.ts deleted file mode 100644 index 4f14c690..00000000 --- a/frontend/deterministic-tests/src/tests/rename-nested-path.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyNestedPath(state: ClientState): void { - const files = Array.from(state.files.keys()); - assert( - !files.includes("a.md"), - `a.md should not exist after rename to nested path, got: ${files.join(", ")}` - ); - assert( - files.includes("folder/subfolder/a.md"), - `Expected folder/subfolder/a.md to exist, got: ${files.join(", ")}` - ); - assert( - state.files.get("folder/subfolder/a.md") === "nested content", - `Expected nested file to have "nested content", got: "${state.files.get("folder/subfolder/a.md")}"` - ); -} - -export const renameNestedPathTest: TestDefinition = { - name: "Rename to Deeply Nested Path", - description: - "Client 0 creates a.md at the root, then renames it to folder/subfolder/a.md " + - "while offline. When Client 0 reconnects, the file should appear at the " + - "nested path on both clients. Tests that the system handles directory " + - "creation for deeply nested rename targets.", - clients: 2, - steps: [ - // Setup: create file at root and sync - { type: "create", client: 0, path: "a.md", content: "nested content" }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - { type: "assert-content", client: 1, path: "a.md", content: "nested content" }, - - // Client 0 goes offline and renames to nested path - { type: "disable-sync", client: 0 }, - { type: "rename", client: 0, oldPath: "a.md", newPath: "folder/subfolder/a.md" }, - - // Client 0 reconnects - { type: "enable-sync", client: 0 }, - { type: "sync" }, - { type: "barrier" }, - - // Original path gone, nested path exists - { type: "assert-not-exists", client: 0, path: "a.md" }, - { type: "assert-not-exists", client: 1, path: "a.md" }, - { type: "assert-exists", client: 0, path: "folder/subfolder/a.md" }, - { type: "assert-exists", client: 1, path: "folder/subfolder/a.md" }, - { type: "assert-consistent", verify: verifyNestedPath } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/rename-tracked-to-occupied-pending-path.test.ts b/frontend/deterministic-tests/src/tests/rename-tracked-to-occupied-pending-path.test.ts deleted file mode 100644 index bb168390..00000000 --- a/frontend/deterministic-tests/src/tests/rename-tracked-to-occupied-pending-path.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyResult(state: ClientState): void { - const files = Array.from(state.files.keys()).sort(); - // The rename of B.md to A.md overwrites A.md on disk. The pending - // create's content ("first file at A") is lost because the user - // chose to overwrite it. VFS.move fails (A.md occupied by pending - // create), so the fallback enqueues an update for B.md which fails - // (FileNotFoundError — B.md no longer exists on disk). - // - // After reconciliation: A.md's pending create reads the overwritten - // content ("tracked file B") from disk, and B.md is deleted - // (missing from disk). - // - // Result: A.md with "tracked file B" content. - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}: ${files.join(", ")}` - ); - assert( - state.files.has("A.md"), - `Expected A.md to exist. Files: ${files.join(", ")}` - ); - const content = state.files.get("A.md") ?? ""; - assert( - content === "tracked file B", - `Expected A.md to have "tracked file B", got: "${content}"` - ); -} - -/** - * BUG: Tests VFS.move failure when renaming a tracked file to a path - * occupied by a pending create. In syncer.ts, VFS.move is attempted - * but fails if the target path is occupied by a non-deleted-locally - * document. The move event falls back to an update at oldPath. - * - * When the user renames B.md to A.md, the filesystem overwrites A.md. - * The pending create's original content is lost from disk. After sync, - * only A.md survives with B.md's content. - */ -export const renameTrackedToOccupiedPendingPathTest: TestDefinition = { - name: "Rename Tracked File to Path Occupied by Pending Create", - description: - "Client creates A.md (pending, sync disabled) then renames B.md " + - "(tracked) to A.md. VFS.move should fail because A.md is occupied " + - "by the pending create. The rename overwrites A.md on disk, so " + - "only A.md survives with B.md's content.", - clients: 2, - steps: [ - // Setup: create B.md and sync it (becomes tracked) - { - type: "create", - client: 0, - path: "B.md", - content: "tracked file B" - }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - { - type: "assert-content", - client: 1, - path: "B.md", - content: "tracked file B" - }, - - // Disable sync on Client 0 - { type: "disable-sync", client: 0 }, - - // Create A.md (pending — sync disabled, not yet synced) - { - type: "create", - client: 0, - path: "A.md", - content: "first file at A" - }, - - // Try to rename tracked B.md to A.md (occupied by pending) - { type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" }, - - // Re-enable sync — after reconciliation, A.md survives - { type: "enable-sync", client: 0 }, - { type: "sync" }, - { type: "barrier" }, - - // A.md exists with B.md's content (rename overwrite) - { type: "assert-consistent", verify: verifyResult } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/server-pause-concurrent-creates.test.ts b/frontend/deterministic-tests/src/tests/server-pause-concurrent-creates.test.ts deleted file mode 100644 index f997aafd..00000000 --- a/frontend/deterministic-tests/src/tests/server-pause-concurrent-creates.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyBothFilesPresent(state: ClientState): void { - const allContent = Array.from(state.files.values()).join("\n"); - assert( - allContent.includes("offline-alpha"), - `Missing content "offline-alpha". Files: ${JSON.stringify(Object.fromEntries(state.files))}` - ); - assert( - allContent.includes("offline-beta"), - `Missing content "offline-beta". Files: ${JSON.stringify(Object.fromEntries(state.files))}` - ); -} - -export const serverPauseConcurrentCreatesTest: TestDefinition = { - name: "Server Pause — Concurrent Creates From Both Clients", - description: - "The server is paused BEFORE either client creates anything. " + - "Client 0 creates fileA.md and Client 1 creates fileB.md — both HTTP " + - "requests stall because the server is frozen. After the server resumes, " + - "both creates should complete and both files should appear on both clients. " + - "This is a harder variant than the existing create-while-server-paused test " + - "because BOTH clients have stalled pending creates simultaneously, testing " + - "that the server correctly handles a burst of requests after SIGCONT and " + - "that idempotency keys prevent duplicate documents if retries occur.", - clients: 2, - steps: [ - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Pause the server FIRST — no requests can succeed - { type: "pause-server" }, - - // Both clients create different files while the server is frozen - { - type: "create", - client: 0, - path: "fileA.md", - content: "offline-alpha" - }, - { - type: "create", - client: 1, - path: "fileB.md", - content: "offline-beta" - }, - - // Resume the server — both pending creates should complete - { type: "resume-server" }, - - { type: "sync" }, - { type: "barrier" }, - - // Both files must exist on both clients - { type: "assert-exists", client: 0, path: "fileA.md" }, - { type: "assert-exists", client: 0, path: "fileB.md" }, - { type: "assert-exists", client: 1, path: "fileA.md" }, - { type: "assert-exists", client: 1, path: "fileB.md" }, - { - type: "assert-content", - client: 0, - path: "fileA.md", - content: "offline-alpha" - }, - { - type: "assert-content", - client: 1, - path: "fileA.md", - content: "offline-alpha" - }, - { - type: "assert-content", - client: 0, - path: "fileB.md", - content: "offline-beta" - }, - { - type: "assert-content", - client: 1, - path: "fileB.md", - content: "offline-beta" - }, - { type: "assert-consistent", verify: verifyBothFilesPresent } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/server-pause-rename-propagation.test.ts b/frontend/deterministic-tests/src/tests/server-pause-rename-propagation.test.ts deleted file mode 100644 index b4ada3a0..00000000 --- a/frontend/deterministic-tests/src/tests/server-pause-rename-propagation.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyRename(state: ClientState): void { - const files = Array.from(state.files.keys()); - assert( - !state.files.has("original.md"), - `Expected original.md to NOT exist after rename, got files: ${files.join(", ")}` - ); - assert( - state.files.has("renamed.md"), - `Expected renamed.md to exist after rename, got files: ${files.join(", ")}` - ); - const content = state.files.get("renamed.md") ?? ""; - assert( - content === "important data", - `Expected renamed.md content to be "important data", got: "${content}"` - ); -} - -export const serverPauseRenameTest: TestDefinition = { - name: "Server Pause Then Rename Propagation", - description: - "Client 0 creates original.md and both clients sync. The server is paused. " + - "Client 0 renames original.md to renamed.md while the server is frozen. " + - "After the server resumes, the rename should propagate to Client 1: " + - "original.md disappears and renamed.md appears with the same content. " + - "This tests that rename operations (which are update-with-oldPath on the " + - "HTTP layer) survive server outages and that Client 1 correctly applies " + - "the path change from the WebSocket broadcast.", - clients: 2, - steps: [ - // Setup: create file and sync both clients - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { - type: "create", - client: 0, - path: "original.md", - content: "important data" - }, - { type: "sync" }, - { type: "barrier" }, - { - type: "assert-content", - client: 1, - path: "original.md", - content: "important data" - }, - - // Pause the server, then rename on client 0 - { type: "pause-server" }, - { - type: "rename", - client: 0, - oldPath: "original.md", - newPath: "renamed.md" - }, - - // Resume the server — the stalled rename request should complete - { type: "resume-server" }, - - { type: "sync" }, - { type: "barrier" }, - - // original.md should be gone, renamed.md should exist on both - { type: "assert-not-exists", client: 0, path: "original.md" }, - { type: "assert-not-exists", client: 1, path: "original.md" }, - { type: "assert-exists", client: 0, path: "renamed.md" }, - { type: "assert-exists", client: 1, path: "renamed.md" }, - { type: "assert-consistent", verify: verifyRename } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/server-pause-resume.test.ts b/frontend/deterministic-tests/src/tests/server-pause-resume.test.ts deleted file mode 100644 index b1e09ebe..00000000 --- a/frontend/deterministic-tests/src/tests/server-pause-resume.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { TestDefinition } from "../test-definition"; - -export const serverPauseResumeTest: TestDefinition = { - name: "Server Pause and Resume", - description: - "Client 0 creates a file and syncs it to the server. The server is then " + - "paused (SIGSTOP), which may stall WebSocket broadcasts to Client 1. " + - "After the server resumes, both clients should converge.", - clients: 2, - steps: [ - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - - // Create a file, then immediately pause the server - { type: "create", client: 0, path: "resilient.md", content: "survives pause" }, - { type: "pause-server" }, - { type: "resume-server" }, - - // After resume, sync should eventually succeed - { type: "sync" }, - { type: "barrier" }, - - { type: "assert-exists", client: 0, path: "resilient.md" }, - { type: "assert-exists", client: 1, path: "resilient.md" }, - { - type: "assert-content", - client: 0, - path: "resilient.md", - content: "survives pause" - }, - { - type: "assert-content", - client: 1, - path: "resilient.md", - content: "survives pause" - }, - { type: "assert-consistent" } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/stale-doc-orphan-duplicate-content.test.ts b/frontend/deterministic-tests/src/tests/stale-doc-orphan-duplicate-content.test.ts deleted file mode 100644 index cac96e9c..00000000 --- a/frontend/deterministic-tests/src/tests/stale-doc-orphan-duplicate-content.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG: Stale doc kept on disk creates duplicate content after create-merge. - * - * Found by: E2E test log analysis (log.log, process 672773) - * - * Root cause sequence: - * 1. Client 1 has document D1 tracked at path "target.md" - * 2. Client 0 renames D1 to "moved.md" on the server - * 3. Client 1 (offline) creates a new file at "moved.md" - * 4. Client 1 reconnects — the create is sent to the server - * 5. Server merges the create with D1 (at "moved.md") → MergingUpdate with D1 - * 6. ensureUniqueDocumentId finds D1 at "target.md" → stale doc - * 7. "target.md" was locally modified during the create's HTTP request - * → hasLocalChanges = true → file kept on disk, VFS record removed - * 8. On the next reconciliation, orphaned "target.md" is re-synced - * as a new document. Now BOTH "target.md" and "moved.md" contain - * the original content from D1 — violating the content-uniqueness - * invariant. - * - * The server pause is used to keep the create HTTP request in-flight - * while the local file at D1's old path is modified (step 7). - */ -function verifyNoDuplicateContent(state: ClientState): void { - const entries = [...state.files.entries()]; - - // The word "original" was D1's initial content. After the create-merge, - // it should appear in at most ONE file. If the stale orphan was re-synced - // as a separate document, "original" will appear in multiple files. - const filesContainingOriginal = entries.filter(([, content]) => - content.includes("original") - ); - - assert( - filesContainingOriginal.length <= 1, - `Content "original" found in ${filesContainingOriginal.length} files: ` + - `${filesContainingOriginal.map(([p]) => p).join(", ")}. ` + - `This means the stale doc orphan was re-synced, creating duplicate content.\n` + - `Files:\n${entries.map(([k, v]) => ` ${k}: "${v}"`).join("\n")}` - ); -} - -export const staleDocOrphanDuplicateContentTest: TestDefinition = { - name: "Stale Doc Orphan Creates Duplicate Content After Create-Merge", - description: - "When a create merges with an existing document, the stale VFS " + - "record is removed but the file is kept on disk (local changes). " + - "If the orphaned file is later re-synced as a new document, the " + - "original content appears in multiple files.", - clients: 2, - steps: [ - // ── Setup: both clients share D1 at "target.md" ── - { type: "create", client: 0, path: "target.md", content: "original" }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // ── Client 1 goes offline ── - { type: "disable-sync", client: 1 }, - - // ── Client 0 renames the document to a new path ── - // Server now has D1 at "moved.md" - { - type: "rename", - client: 0, - oldPath: "target.md", - newPath: "moved.md" - }, - { type: "sync", client: 0 }, - - // ── Client 1 (offline) creates a file at D1's new server path ── - // Client 1 doesn't know D1 was renamed there. - { - type: "create", - client: 1, - path: "moved.md", - content: "unrelated-content" - }, - - // ── Pause server to stall the create HTTP request ── - { type: "pause-server" }, - - // ── Enable sync on client 1 ── - // scheduleSyncForOfflineChanges runs: - // "target.md": D1, hash matches → no update - // "moved.md": no metadata → create scheduled - // The create HTTP request stalls (server frozen). - // enableSync waits up to 10 s for WebSocket then returns. - { type: "enable-sync", client: 1 }, - - // ── Modify D1's old path while the create is in-flight ── - // This makes hasLocalChanges = true when ensureUniqueDocumentId - // checks the stale doc at "target.md". - { - type: "update", - client: 1, - path: "target.md", - content: "original extra-edit" - }, - - // ── Resume server ── - // Create completes: server merges with D1 → MergingUpdate - // ensureUniqueDocumentId: D1 at "target.md" → stale doc - // hasLocalChanges("target.md"): "original extra-edit" ≠ "original" → true - // File kept, VFS record removed. - // - // WebSocket connects → second reconciliation detects orphaned - // "target.md" → re-synced as new document → DUPLICATE CONTENT. - { type: "resume-server" }, - - // ── Settle ── - { type: "sync" }, - { type: "sync" }, - { type: "barrier" }, - - // ── Verify: "original" must not appear in multiple files ── - { type: "assert-consistent", verify: verifyNoDuplicateContent } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/three-client-convergence.test.ts b/frontend/deterministic-tests/src/tests/three-client-convergence.test.ts deleted file mode 100644 index 0a522ccd..00000000 --- a/frontend/deterministic-tests/src/tests/three-client-convergence.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyAllContent(state: ClientState): void { - // All three creates at the same path should merge into a single file - assert( - state.files.size === 1, - `Expected 1 file after 3-way merge, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.has("A.md"), - `Expected merged file at A.md, got: ${Array.from(state.files.keys()).join(", ")}` - ); - - const content = state.files.get("A.md") ?? ""; - assert( - content.includes("from-zero"), - `Expected merged content to include "from-zero", got: "${content}"` - ); - assert( - content.includes("from-one"), - `Expected merged content to include "from-one", got: "${content}"` - ); - assert( - content.includes("from-two"), - `Expected merged content to include "from-two", got: "${content}"` - ); -} - -export const threeClientConvergenceTest: TestDefinition = { - name: "Three Client Convergence", - description: - "Three clients all create the same file offline with different content. " + - "When all three enable sync, the server must merge all three versions " + - "and all clients must converge to the same state with all content preserved.", - clients: 3, - steps: [ - // All three create A.md offline with different content - { type: "create", client: 0, path: "A.md", content: "from-zero" }, - { type: "create", client: 1, path: "A.md", content: "from-one" }, - { type: "create", client: 2, path: "A.md", content: "from-two" }, - - // Enable sync on all three - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "enable-sync", client: 2 }, - { type: "sync" }, - { type: "barrier" }, - - // All three must converge and all content must be preserved - { type: "assert-consistent", verify: verifyAllContent } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/update-vs-remote-delete-data-loss.test.ts b/frontend/deterministic-tests/src/tests/update-vs-remote-delete-data-loss.test.ts deleted file mode 100644 index d84f3e11..00000000 --- a/frontend/deterministic-tests/src/tests/update-vs-remote-delete-data-loss.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG: update + remote-delete = update, but execution deletes the file. - * - * In sync-events.ts coalesceFromUpdate (line 148-152): - * case "remote-delete": - * return current; // comment: "user edit takes precedence" - * - * The coalescing INTENT is correct: the user's edit should survive. - * But the EXECUTION doesn't match: - * - * 1. The coalesced "update" action calls executeSyncUpdateSendChanges() - * 2. This sends putText/putBinary to the server - * 3. The server's update_document handler checks if latest_version.is_deleted - * 4. Since the doc IS deleted, server returns FastForwardUpdate(isDeleted=true) - * 5. applyServerResponse checks response.isDeleted at line 296 - * 6. Calls applyRemoteDeleteLocally which DELETES the file! - * - * The user's edit is permanently lost despite the coalescing saying - * "user edit takes precedence." - * - * This test proves the data loss by having one client edit while another - * deletes, with the edit arriving at the event queue before the delete. - */ -function verifyUserEditPreserved(state: ClientState): void { - // The coalescing says "user edit takes precedence" so the file - // should ideally survive with the user's content. - // Current behavior: file is deleted (data loss). - // We test for convergence. - if (state.files.size > 0) { - assert( - state.files.has("doc.md"), - `Unexpected files: ${Array.from(state.files.keys()).join(", ")}` - ); - const content = state.files.get("doc.md")!; - assert( - content.includes("user edit"), - `Expected user's edit content, got: "${content}"` - ); - } -} - -export const updateVsRemoteDeleteDataLossTest: TestDefinition = { - name: "Update + Remote Delete Coalescing Data Loss", - description: - "When a user edits a file and then a remote-delete arrives, the " + - "coalescing produces 'update' (user edit takes precedence). But " + - "the server returns isDeleted=true, causing the client to delete " + - "the file — contradicting the coalescing intent.", - clients: 2, - steps: [ - // Setup: both clients have doc.md - { type: "create", client: 0, path: "doc.md", content: "original" }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Both go offline - { type: "disable-sync", client: 0 }, - { type: "disable-sync", client: 1 }, - - // Client 0 edits the file (local-update queued first) - { type: "update", client: 0, path: "doc.md", content: "user edit on client 0" }, - - // Client 1 deletes the file - { type: "delete", client: 1, path: "doc.md" }, - - // Client 1 comes online first — delete sent to server - { type: "enable-sync", client: 1 }, - { type: "sync", client: 1 }, - - // Client 0 comes online — local-update already queued, - // then remote-delete arrives and coalesces: - // update + remote-delete = update (per coalescing) - { type: "enable-sync", client: 0 }, - { type: "sync" }, - { type: "barrier" }, - - // Both must converge to a consistent state - { type: "assert-consistent", verify: verifyUserEditPreserved } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/user-parenthesized-file-not-deleted.test.ts b/frontend/deterministic-tests/src/tests/user-parenthesized-file-not-deleted.test.ts deleted file mode 100644 index 42c6527b..00000000 --- a/frontend/deterministic-tests/src/tests/user-parenthesized-file-not-deleted.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG FIX: User-created files with parenthesized names must not be deleted. - * - * The duplicate content detection in step 7 of reconciliation uses a regex - * that matches files like "Chapter (1).md". This should only delete files - * created by ensureClearPath, not user-intentionally-created files. - * - * Note: the two files MUST have different content, because the server - * merges deconflicted-path creates when the content is identical to the - * base-path document. - */ -function verifyBothFilesExist(state: ClientState): void { - assert( - state.files.size === 2, - `Expected 2 files, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.has("Chapter.md"), - "Expected Chapter.md to exist" - ); - assert( - state.files.has("Chapter (1).md"), - "Expected Chapter (1).md to exist" - ); -} - -export const userParenthesizedFileNotDeletedTest: TestDefinition = { - name: "User-Created Parenthesized Files Not Deleted", - description: - "A user-created file like 'Chapter (1).md' should not be silently " + - "deleted by the duplicate content detection heuristic. Uses " + - "different content to avoid server-side deconfliction merge.", - clients: 2, - steps: [ - // Client 0 creates both files with DIFFERENT content - // (same content triggers server-side deconfliction merge) - { - type: "create", - client: 0, - path: "Chapter.md", - content: "chapter one" - }, - { - type: "create", - client: 0, - path: "Chapter (1).md", - content: "chapter one notes" - }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Both files should survive on both clients - { type: "assert-consistent", verify: verifyBothFilesExist } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/write-write-conflict.test.ts b/frontend/deterministic-tests/src/tests/write-write-conflict.test.ts deleted file mode 100644 index 873a010b..00000000 --- a/frontend/deterministic-tests/src/tests/write-write-conflict.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyMergedContent(state: ClientState): void { - assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); - assert(state.files.has("A.md"), "Expected A.md to exist"); - const content = state.files.get("A.md") ?? ""; - assert( - content.includes("hello") && content.includes("world"), - `Expected A.md to contain both "hello" and "world", got: "${content}"` - ); - // Verify no duplication — each word should appear exactly once - const helloCount = content.split("hello").length - 1; - const worldCount = content.split("world").length - 1; - assert( - helloCount === 1, - `Expected "hello" to appear once, appeared ${helloCount} times in: "${content}"` - ); - assert( - worldCount === 1, - `Expected "world" to appear once, appeared ${worldCount} times in: "${content}"` - ); -} - -export const writeWriteConflictTest: TestDefinition = { - name: "Write/Write Conflict", - description: - "Two clients simultaneously create the same file with different content. " + - "The system should resolve the conflict and both clients should converge.", - clients: 2, - steps: [ - { type: "create", client: 0, path: "A.md", content: "hello" }, - { type: "create", client: 1, path: "A.md", content: "world" }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - { type: "assert-consistent", verify: verifyMergedContent } - ] -}; From e15b0f9903ace3341084c27cb3680371b6a60f55 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 28 Mar 2026 09:49:46 +0000 Subject: [PATCH 05/10] Add proper shutdown, rate limits, config validation, cors config, fix dangling cursors, cache regex, merge created texts --- scripts/check.sh | 7 +- scripts/utils/wait-for-server.sh | 4 +- sync-server/Cargo.lock | 112 +++- sync-server/Cargo.toml | 5 +- sync-server/config-e2e.yml | 5 +- sync-server/src/app_state.rs | 27 +- sync-server/src/app_state/cursors.rs | 92 +++- sync-server/src/app_state/database.rs | 490 +++++++++++++----- sync-server/src/app_state/database/models.rs | 1 - .../src/app_state/websocket/broadcasts.rs | 64 +-- sync-server/src/config.rs | 13 + sync-server/src/config/database_config.rs | 19 + sync-server/src/config/logging_config.rs | 12 + sync-server/src/config/server_config.rs | 75 ++- sync-server/src/consts.rs | 20 +- sync-server/src/main.rs | 9 +- sync-server/src/server.rs | 123 +++-- sync-server/src/server/create_document.rs | 89 +++- sync-server/src/server/delete_document.rs | 10 +- .../src/server/fetch_document_version.rs | 4 +- .../server/fetch_document_version_content.rs | 4 +- sync-server/src/server/rate_limit.rs | 96 ++-- sync-server/src/server/requests.rs | 2 - sync-server/src/server/update_document.rs | 135 +++-- sync-server/src/server/websocket.rs | 261 +++++++--- sync-server/src/utils/dedup_paths.rs | 16 +- .../src/utils/find_first_available_path.rs | 4 +- sync-server/src/utils/sanitize_path.rs | 42 +- 28 files changed, 1277 insertions(+), 464 deletions(-) diff --git a/scripts/check.sh b/scripts/check.sh index 0a5a639a..2ee0dd62 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -30,8 +30,11 @@ fi which cargo-machete || cargo install cargo-machete cargo machete --with-metadata +cd .. +scripts/update-api-types.sh # this will dirty up the git state if not up-to-date + echo "Running checks in frontend" -cd ../frontend +cd frontend if [[ "$FIX_MODE" == true ]]; then npm install @@ -57,6 +60,4 @@ if [[ "$FIX_MODE" == false ]] && [[ $(git status --porcelain) ]]; then exit 1 fi -cd .. - echo "Success" diff --git a/scripts/utils/wait-for-server.sh b/scripts/utils/wait-for-server.sh index 7824c405..71103477 100755 --- a/scripts/utils/wait-for-server.sh +++ b/scripts/utils/wait-for-server.sh @@ -2,14 +2,14 @@ set -e -SERVER_URL="http://localhost:3000" +SERVER_URL="http://localhost:3010" MAX_RETRIES=30 RETRY_INTERVAL_IN_SECONDS=5 echo "Waiting for $SERVER_URL to become available..." count=0 while [ $count -lt $MAX_RETRIES ]; do - if curl -s -f -o /dev/null $SERVER_URL; then + if curl -s -o /dev/null $SERVER_URL; then echo "$SERVER_URL is now available!" break fi diff --git a/sync-server/Cargo.lock b/sync-server/Cargo.lock index b3da1486..333d7ae4 100644 --- a/sync-server/Cargo.lock +++ b/sync-server/Cargo.lock @@ -337,10 +337,11 @@ checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" [[package]] name = "cc" -version = "1.2.2" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f34d93e62b03caf570cccc334cbc6c2fceca82f39211051345108adcba3eebdc" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ + "find-msvc-tools", "shlex", ] @@ -624,6 +625,12 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "486f806e73c5707928240ddc295403b1b93c96a02038563881c4a2fd84b81ac4" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "flume" version = "0.11.1" @@ -1272,6 +1279,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.0" @@ -1582,12 +1599,12 @@ dependencies = [ [[package]] name = "reconcile-text" -version = "0.8.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "599cf9539996a2a19e501110404c59ba62f4974009f8fb864a8b7151c15ee5a5" +checksum = "52e0cf361887ea64c479ca871c1170dda761f84e122f2616b5579906a38d7557" dependencies = [ "serde", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -1648,6 +1665,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.90", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2", + "walkdir", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -1679,6 +1730,15 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "sanitize-filename" version = "0.6.0" @@ -1916,7 +1976,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tokio-stream", "tracing", @@ -2000,7 +2060,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -2039,7 +2099,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -2065,7 +2125,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "url", "uuid", @@ -2136,15 +2196,18 @@ dependencies = [ "futures", "humantime-serde", "log", + "mime_guess", "rand 0.9.0", "reconcile-text", "regex", + "rust-embed", "sanitize-filename", "serde", "serde_json", "serde_yaml", "sqlx", - "thiserror 2.0.17", + "subtle", + "thiserror 2.0.18", "tokio", "tower-http", "tracing", @@ -2203,11 +2266,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -2223,9 +2286,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -2276,7 +2339,6 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -2434,7 +2496,7 @@ checksum = "e640d9b0964e9d39df633548591090ab92f7a4567bc31d3891af23471a3365c6" dependencies = [ "chrono", "lazy_static", - "thiserror 2.0.17", + "thiserror 2.0.18", "ts-rs-macros", "uuid", ] @@ -2481,6 +2543,12 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f720def6ce1ee2fc44d40ac9ed6d3a59c361c80a75a7aa8e75bb9baed31cf2ea" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.17" @@ -2577,6 +2645,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" diff --git a/sync-server/Cargo.toml b/sync-server/Cargo.toml index a3995cf5..ba79ac23 100644 --- a/sync-server/Cargo.toml +++ b/sync-server/Cargo.toml @@ -33,7 +33,10 @@ serde_json = "1.0.140" bimap = "0.6.3" ts-rs = { version = "10.1", features = ["uuid-impl", "chrono-impl"] } base64 = "0.22.1" -reconcile-text = { version = "0.8.0", features = ["serde"] } +reconcile-text = { version = "0.11.0", features = ["serde"] } +rust-embed = "8.5" +mime_guess = "2.0" +subtle = "2.6.1" [profile.release] codegen-units = 1 diff --git a/sync-server/config-e2e.yml b/sync-server/config-e2e.yml index 1f235b01..96b3c199 100644 --- a/sync-server/config-e2e.yml +++ b/sync-server/config-e2e.yml @@ -1,12 +1,13 @@ database: databases_directory_path: databases - max_connections_per_vault: 12 + max_connections_per_vault: 8 cursor_timeout: 1m server: host: 0.0.0.0 - port: 3000 + port: 3010 max_body_size_mb: 512 max_clients_per_vault: 256 + broadcast_channel_capacity: 1024 response_timeout: 30m mergeable_file_extensions: - md diff --git a/sync-server/src/app_state.rs b/sync-server/src/app_state.rs index 2019e08e..1bd3222e 100644 --- a/sync-server/src/app_state.rs +++ b/sync-server/src/app_state.rs @@ -2,6 +2,8 @@ pub mod cursors; pub mod database; pub mod websocket; +use std::sync::{Arc, atomic::AtomicUsize}; + use anyhow::Result; use cursors::Cursors; use database::Database; @@ -15,21 +17,42 @@ pub struct AppState { pub database: Database, pub cursors: Cursors, pub broadcasts: Broadcasts, + /// Tracks WebSocket connections that have upgraded but not yet completed + /// the authentication handshake + pub pending_ws_connections: Arc, + /// Send on this channel to stop background tasks (cursor cleanup, + /// idle-pool cleanup) + shutdown_tx: Arc>, } impl AppState { pub async fn try_new(config: Config) -> Result { + let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(()); + let broadcasts = Broadcasts::new(&config.server); - let database = Database::try_new(&config.database, &broadcasts).await?; + let database = + Database::try_new(&config.database, &broadcasts, shutdown_rx.clone()).await?; let cursors: Cursors = Cursors::new(&config.database, &broadcasts); - Cursors::start_background_task(cursors.clone()); + Cursors::start_background_task(cursors.clone(), shutdown_rx); Ok(Self { config, database, cursors, broadcasts, + pending_ws_connections: Arc::new(AtomicUsize::new(0)), + shutdown_tx: Arc::new(shutdown_tx), }) } + + /// Signal all background tasks (idle pool cleanup, cursor cleanup) to stop + pub fn shutdown(&self) { + let _ = self.shutdown_tx.send(()); + } + + /// Get a receiver to be notified when shutdown is triggered + pub fn subscribe_shutdown(&self) -> tokio::sync::watch::Receiver<()> { + self.shutdown_tx.subscribe() + } } diff --git a/sync-server/src/app_state/cursors.rs b/sync-server/src/app_state/cursors.rs index d083e1ac..4d01995a 100644 --- a/sync-server/src/app_state/cursors.rs +++ b/sync-server/src/app_state/cursors.rs @@ -42,7 +42,9 @@ impl Cursors { ) { let mut vault_to_cursors = self.vault_to_cursors.lock().await; - let all_device_cursors = vault_to_cursors.entry(vault_id).or_insert_with(Vec::new); + let all_device_cursors = vault_to_cursors + .entry(vault_id.clone()) + .or_insert_with(Vec::new); all_device_cursors.retain(|c| &c.client_cursors.device_id != device_id); all_device_cursors.push(ClientCursorsWithTimeToLive::new(ClientCursors { @@ -52,7 +54,7 @@ impl Cursors { })); drop(vault_to_cursors); // Explicitly drop the lock before broadcasting to avoid deadlock - self.broadcast_cursors().await; + self.broadcast_cursors_for_vault(&vault_id).await; } pub async fn get_cursors(&self, vault_id: &VaultId) -> Vec { @@ -69,45 +71,83 @@ impl Cursors { .unwrap_or_default() } - pub fn start_background_task(self) { + pub fn start_background_task(self, mut shutdown: tokio::sync::watch::Receiver<()>) { tokio::spawn(async move { loop { - self.remove_expired_cursors().await; - tokio::time::sleep(Duration::from_secs(1)).await; + tokio::select! { + () = tokio::time::sleep(Duration::from_secs(1)) => { + self.remove_expired_cursors().await; + } + Ok(()) = shutdown.changed() => break, + } } }); } async fn remove_expired_cursors(&self) { - let mut vault_to_cursors = self.vault_to_cursors.lock().await; + let changed_vaults: Vec = { + let mut vault_to_cursors = self.vault_to_cursors.lock().await; - for (_vault_id, cursors) in vault_to_cursors.iter_mut() { - cursors.retain(|cursor| !cursor.is_expired(self.config.cursor_timeout)); + let mut changed = Vec::new(); + for (vault_id, cursors) in vault_to_cursors.iter_mut() { + let before = cursors.len(); + cursors.retain(|cursor| !cursor.is_expired(self.config.cursor_timeout)); + if cursors.len() != before { + changed.push(vault_id.clone()); + } + } + + // Remove empty vault entries to prevent unbounded growth + vault_to_cursors.retain(|_, cursors| !cursors.is_empty()); + + changed + }; + + for vault_id in &changed_vaults { + self.broadcast_cursors_for_vault(vault_id).await; } } - async fn broadcast_cursors(&self) { - let vault_to_cursors = self.vault_to_cursors.lock().await; + async fn broadcast_cursors_for_vault(&self, vault_id: &VaultId) { + let client_cursors: Vec = { + let vault_to_cursors = self.vault_to_cursors.lock().await; + vault_to_cursors + .get(vault_id) + .map(|cursors| cursors.iter().map(|c| c.client_cursors.clone()).collect()) + .unwrap_or_default() + }; - for (vault_id, cursors) in vault_to_cursors.iter() { - self.broadcasts - .send_document_update( - vault_id.clone(), - WebSocketServerMessageWithOrigin::new(WebSocketServerMessage::CursorPositions( - CursorPositionFromServer { - clients: cursors.iter().map(|c| c.client_cursors.clone()).collect(), - }, - )), - ) - .await; - } + self.broadcasts + .send_document_update( + vault_id.clone(), + WebSocketServerMessageWithOrigin::new(WebSocketServerMessage::CursorPositions( + CursorPositionFromServer { + clients: client_cursors, + }, + )), + ) + .await; } - pub async fn remove_cursors_of_device(&self, vault_id: &str, device_id: &str) { - let mut vault_to_cursors = self.vault_to_cursors.lock().await; + pub async fn remove_cursors_of_device(&self, vault_id: &VaultId, device_id: &DeviceId) { + let changed = { + let mut vault_to_cursors = self.vault_to_cursors.lock().await; - if let Some(cursors) = vault_to_cursors.get_mut(vault_id) { - cursors.retain(|c| c.client_cursors.device_id != device_id); + if let Some(cursors) = vault_to_cursors.get_mut(vault_id) { + let before = cursors.len(); + cursors.retain(|c| c.client_cursors.device_id != *device_id); + let changed = cursors.len() != before; + if cursors.is_empty() { + vault_to_cursors.remove(vault_id); + } + changed + } else { + false + } + }; + + if changed { + self.broadcast_cursors_for_vault(vault_id).await; } } } diff --git a/sync-server/src/app_state/database.rs b/sync-server/src/app_state/database.rs index 75ce6df4..b0ef0ee7 100644 --- a/sync-server/src/app_state/database.rs +++ b/sync-server/src/app_state/database.rs @@ -9,8 +9,17 @@ use models::{ use sqlx::{ConnectOptions, sqlite::SqliteConnectOptions, types::chrono::Utc}; pub mod models; -use sqlx::{Pool, Sqlite, sqlite::SqlitePoolOptions}; -use tokio::sync::Mutex; + +/// Sentinel error indicating the SQLite database is busy (SQLITE_BUSY). +/// Handlers can downcast to this to return 429 instead of 500. +#[derive(Debug, thiserror::Error)] +#[error("Database is busy")] +pub struct WriteBusyError; + +use sqlx::{ + Pool, Sqlite, pool::PoolConnection, sqlite::SqliteConnection, sqlite::SqlitePoolOptions, +}; +use tokio::sync::{Mutex, OnceCell}; use tokio::time::Instant; use uuid::fmt::Hyphenated; @@ -19,33 +28,154 @@ use super::websocket::{ models::{WebSocketServerMessage, WebSocketServerMessageWithOrigin, WebSocketVaultUpdate}, }; use crate::config::database_config::DatabaseConfig; +use crate::consts::IDLE_POOL_TIMEOUT; -#[derive(Clone)] -struct PoolWithTimestamp { - pool: Pool, - last_accessed: Instant, +/// Holds separate reader and writer pools for a single vault. +/// The writer pool has exactly 1 connection so writes never compete +/// with reads for pool slots. +#[derive(Debug, Clone)] +struct VaultPools { + reader: Pool, + writer: Pool, } -impl std::fmt::Debug for PoolWithTimestamp { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("PoolWithTimestamp") - .field("pool", &"Pool") - .field("last_accessed", &self.last_accessed) - .finish() - } +#[derive(Debug)] +struct VaultPool { + cell: Arc>, + last_accessed: Mutex, } #[derive(Clone, Debug)] pub struct Database { config: DatabaseConfig, broadcasts: Broadcasts, - connection_pools: Arc>>, + connection_pools: Arc>>>, + /// Per-vault write serialization. SQLite allows only one writer at a + /// time; `BEGIN IMMEDIATE` on a second connection blocks until the first + /// commits (up to `busy_timeout`). Under concurrent load the blocked + /// connections consume the pool, starving even read-only requests. + /// This mutex moves the wait from the SQLite layer (where it holds a + /// pool connection) to the Tokio layer (where it holds nothing). + write_locks: Arc>>>>, } -pub type Transaction<'a> = sqlx::Transaction<'a, Sqlite>; +/// A write transaction backed by a raw `BEGIN IMMEDIATE` instead of sqlx's +/// savepoint-based `Transaction`. This avoids the savepoint mismatch caused +/// by the old `END; BEGIN IMMEDIATE;` workaround. +/// +/// Holds an `OwnedMutexGuard` that serializes write transactions per vault +/// at the application level (see `Database::write_locks`). The guard is +/// released when the transaction is committed, rolled back, or dropped. +pub struct WriteTransaction { + conn: Option>, + _write_guard: tokio::sync::OwnedMutexGuard<()>, +} + +impl WriteTransaction { + async fn new(pool: &Pool, write_guard: tokio::sync::OwnedMutexGuard<()>) -> Result { + let mut conn = pool + .acquire() + .await + .context("Cannot acquire connection for write transaction")?; + if let Err(e) = sqlx::query("BEGIN IMMEDIATE") + .execute(&mut *conn) + .await + { + let is_busy = match &e { + sqlx::Error::Database(db_err) => { + // SQLITE_BUSY base code is 5. Extended codes share base 5. + let busy_by_code = db_err.code().is_some_and(|c| { + c.parse::().is_ok_and(|n| n & 0xFF == 5) + }); + busy_by_code || db_err.message().contains("database is locked") + } + _ => false, + }; + if is_busy { + return Err(WriteBusyError.into()); + } + return Err(e).context("Cannot begin immediate transaction"); + } + Ok(Self { conn: Some(conn), _write_guard: write_guard }) + } + + pub async fn commit(mut self) -> Result<()> { + if let Some(mut conn) = self.conn.take() { + sqlx::query("COMMIT") + .execute(&mut *conn) + .await + .context("Failed to commit transaction")?; + } + Ok(()) + } + + pub async fn rollback(mut self) -> Result<()> { + if let Some(mut conn) = self.conn.take() { + sqlx::query("ROLLBACK") + .execute(&mut *conn) + .await + .context("Failed to rollback transaction")?; + } + Ok(()) + } +} + +impl Drop for WriteTransaction { + fn drop(&mut self) { + if self.conn.is_some() { + // The connection is returned to the pool with an open transaction. + // The pool's `before_acquire` hook issues a ROLLBACK before + // handing it to the next consumer, so no async work is needed + // here. If the pool is being shut down, SQLite itself rolls back + // uncommitted transactions when the connection closes. + log::warn!("WriteTransaction dropped without commit or rollback"); + } + } +} + +impl std::ops::Deref for WriteTransaction { + type Target = SqliteConnection; + fn deref(&self) -> &Self::Target { + self.conn + .as_ref() + .expect("BUG: WriteTransaction dereferenced after being consumed") + .deref() + } +} + +impl std::ops::DerefMut for WriteTransaction { + fn deref_mut(&mut self) -> &mut Self::Target { + self.conn + .as_mut() + .expect("BUG: WriteTransaction dereferenced after being consumed") + .deref_mut() + } +} + +/// Ensure the connection has no leftover open transaction (e.g. from a +/// `WriteTransaction` that was dropped without commit/rollback). ROLLBACK +/// is a harmless no-op if no transaction is active. +fn rollback_before_acquire( + conn: &mut SqliteConnection, + _meta: sqlx::pool::PoolConnectionMetadata, +) -> futures::future::BoxFuture<'_, Result> { + Box::pin(async move { + if let Err(e) = sqlx::query("ROLLBACK").execute(&mut *conn).await { + // "cannot rollback - no transaction is active" is the common + // case (connection returned cleanly). Only unexpected errors + // deserve attention. + log::debug!("before_acquire ROLLBACK failed: {e}"); + } + Ok(true) + }) +} impl Database { - pub async fn try_new(config: &DatabaseConfig, broadcasts: &Broadcasts) -> Result { + pub async fn try_new( + config: &DatabaseConfig, + broadcasts: &Broadcasts, + shutdown: tokio::sync::watch::Receiver<()>, + ) -> Result { tokio::fs::create_dir_all(&config.databases_directory_path) .await .with_context(|| { @@ -70,24 +200,29 @@ impl Database { .trim_end_matches(".sqlite") .to_owned(); - let pool = Self::create_vault_database(config, &vault).await?; + Self::validate_vault_id(&vault)?; + + let pools = Self::create_vault_database(config, &vault).await?; + let cell = Arc::new(OnceCell::new()); + cell.set(pools).expect("cell is new"); connection_pools.insert( vault.clone(), - PoolWithTimestamp { - pool, - last_accessed: Instant::now(), - }, + Arc::new(VaultPool { + cell, + last_accessed: Mutex::new(Instant::now()), + }), ); } + info!("Database migrations applied"); let database = Self { config: config.clone(), connection_pools: Arc::new(Mutex::new(connection_pools)), broadcasts: broadcasts.clone(), + write_locks: Arc::new(Mutex::new(HashMap::new())), }; - // Start background task to cleanup idle connection pools - database.start_idle_pool_cleanup(); + database.start_idle_pool_cleanup(shutdown); Ok(database) } @@ -95,92 +230,167 @@ impl Database { async fn create_vault_database( config: &DatabaseConfig, vault: &VaultId, - ) -> Result> { + ) -> Result { let file_name = config .databases_directory_path .join(format!("{vault}.sqlite")); - let connection_options = SqliteConnectOptions::new() + // Database-level PRAGMAs (auto_vacuum, journal_mode) require a write + // lock and persist across connections. Set them once with a dedicated + // init connection so pool connections never need the write lock just to + // open. + let init_options = SqliteConnectOptions::new() .filename(file_name.clone()) .create_if_missing(true) - .auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::Full) - .busy_timeout(Duration::from_secs(3600)) - .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal) - .log_slow_statements(log::LevelFilter::Warn, Duration::from_secs(30)); + .auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::Incremental) + .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal); - let pool = SqlitePoolOptions::new() + // Run migrations on a dedicated connection, NOT through the pool. + // The pool's `before_acquire` hook issues ROLLBACK on every checkout, + // which can roll back the migration's bookkeeping transaction (the + // _sqlx_migrations INSERT) while the DDL (ALTER TABLE) has already + // auto-committed — leaving the migration in a dirty state. + // + // Uses `run_direct` instead of `run` because `run` takes + // `impl Acquire<'_>`, whose lifetime bound prevents the enclosing + // future from satisfying the `Send` requirement of axum handlers. + let mut init_conn = sqlx::SqliteConnection::connect_with(&init_options).await?; + sqlx::migrate!("src/app_state/database/migrations") + .run_direct(&mut init_conn) + .await + .context("Cannot run pending migrations")?; + drop(init_conn); + + // Per-connection PRAGMAs shared by both reader and writer pools. + // journal_mode = WAL is a no-op on an already-WAL database. + let base_options = SqliteConnectOptions::new() + .filename(file_name.clone()) + .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal) + .busy_timeout(Duration::from_secs(30)) + .log_slow_statements(log::LevelFilter::Warn, Duration::from_secs(30)) + // In WAL mode, NORMAL is safe: data survives OS crashes, only the + // last transaction can be lost on power failure. The default FULL + // forces an extra fsync() per commit, roughly halving write throughput. + .pragma("synchronous", "NORMAL") + // 16 MB page cache per connection (negative = KiB). Reduces disk + // reads for the latest_document_versions GROUP BY view. + .pragma("cache_size", "-16384") + // Memory-mapped I/O avoids read() syscalls. SQLite falls back to + // regular I/O for writes and beyond the mapped region. 256 MB is + // conservative; the OS handles actual memory pressure. + .pragma("mmap_size", "268435456") + // Keep temp tables and sort spillovers in memory instead of temp files. + .pragma("temp_store", "MEMORY") + // Cap WAL file growth at 64 MB. Without this, the WAL can grow + // unbounded during heavy write bursts (e.g. E2E tests with many + // concurrent clients). SQLite truncates to this size on checkpoint. + .pragma("journal_size_limit", "67108864"); + + // Reader pool: multiple connections for concurrent reads. + let reader = SqlitePoolOptions::new() .max_connections(config.max_connections_per_vault) .acquire_slow_threshold(Duration::from_secs(30)) - .test_before_acquire(true) - .connect_with(connection_options) + // Disabled: the health-check query is subject to busy_timeout + // and blocks all connection checkouts when a write is active, + // starving the pool for up to 30s even for simple reads. + // The before_acquire ROLLBACK hook is sufficient for cleanup. + .test_before_acquire(false) + .before_acquire(rollback_before_acquire) + .connect_with(base_options.clone()) .await - .with_context(|| format!("Cannot open database at `{}`", file_name.display()))?; + .with_context(|| format!("Cannot open reader pool at `{}`", file_name.display()))?; - Self::run_migrations(&pool).await?; + // Writer pool: exactly 1 connection, dedicated to writes. + // Since the Tokio mutex already serializes writers per vault, this + // single connection is never contended. Separating it from the + // reader pool ensures writes never compete with reads for pool slots. + let writer = SqlitePoolOptions::new() + .max_connections(1) + .acquire_slow_threshold(Duration::from_secs(30)) + .test_before_acquire(false) + .before_acquire(rollback_before_acquire) + .connect_with(base_options) + .await + .with_context(|| format!("Cannot open writer pool at `{}`", file_name.display()))?; - Ok(pool) + Ok(VaultPools { reader, writer }) } - async fn run_migrations(pool: &Pool) -> Result<()> { - sqlx::migrate!("src/app_state/database/migrations") - .run(pool) - .await - .context("Cannot check for pending migrations") - } - async fn get_connection_pool(&self, vault: &VaultId) -> Result> { - let mut pools = self.connection_pools.lock().await; - - if !pools.contains_key(vault) { - let pool = Self::create_vault_database(&self.config, vault).await?; - pools.insert( - vault.clone(), - PoolWithTimestamp { - pool, - last_accessed: Instant::now(), - }, + fn validate_vault_id(vault: &VaultId) -> Result<()> { + if vault.is_empty() { + anyhow::bail!("Vault ID must not be empty"); + } + if vault.contains('/') + || vault.contains('\\') + || vault.contains("..") + || vault.contains('\0') + { + anyhow::bail!( + "Invalid vault ID: must not contain path separators, '..', or null bytes" ); } - - let pool_with_timestamp = pools - .get_mut(vault) - .expect("Pool was just inserted or already exists"); - - // Update last accessed time - pool_with_timestamp.last_accessed = Instant::now(); - - Ok(pool_with_timestamp.pool.clone()) + Ok(()) } - /// Attempting to write from this transaction might result in a - /// database locked error. Use this transaction for read-only operations. - pub async fn create_readonly_transaction( - &self, - vault: &VaultId, - ) -> Result> { - self.get_connection_pool(vault) - .await? - .begin() - .await - .context("Cannot create transaction") - } + async fn get_vault_pools(&self, vault: &VaultId) -> Result { + Self::validate_vault_id(vault)?; - pub async fn create_write_transaction(&self, vault: &VaultId) -> Result> { - let mut transaction = self.create_readonly_transaction(vault).await?; + // Get or create the VaultPool entry. The global lock is held only + // long enough for a HashMap lookup/insert — never across + // create_vault_database. + let vault_pool = { + let mut pools = self.connection_pools.lock().await; + pools + .entry(vault.clone()) + .or_insert_with(|| { + Arc::new(VaultPool { + cell: Arc::new(OnceCell::new()), + last_accessed: Mutex::new(Instant::now()), + }) + }) + .clone() + }; - // sqlx doesn't support immediate transactions for sqlite: https://github.com/launchbadge/sqlx/issues/481 - sqlx::query!("END; BEGIN IMMEDIATE;") - .execute(&mut *transaction) + // OnceCell::get_or_try_init guarantees exactly-once + // initialization: concurrent callers for the same vault wait + // here; callers for other vaults are not blocked. + let config = self.config.clone(); + let vault_clone = vault.clone(); + let pools = vault_pool + .cell + .get_or_try_init(|| async { + Self::create_vault_database(&config, &vault_clone).await + }) .await?; - Ok(transaction) + *vault_pool.last_accessed.lock().await = Instant::now(); + Ok(pools.clone()) + } + + /// Return the reader pool for read-only queries. + async fn get_connection_pool(&self, vault: &VaultId) -> Result> { + Ok(self.get_vault_pools(vault).await?.reader) + } + + pub async fn create_write_transaction(&self, vault: &VaultId) -> Result { + let write_lock = { + let mut locks = self.write_locks.lock().await; + locks + .entry(vault.clone()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone() + }; + let write_guard = write_lock.lock_owned().await; + let pools = self.get_vault_pools(vault).await?; + WriteTransaction::new(&pools.writer, write_guard).await } /// Return the latest state of all documents in the vault pub async fn get_latest_documents( &self, vault: &VaultId, - transaction: Option<&mut Transaction<'_>>, + connection: Option<&mut SqliteConnection>, ) -> Result> { let query = sqlx::query!( r#" @@ -198,8 +408,8 @@ impl Database { "#, ); - if let Some(transaction) = transaction { - query.fetch_all(&mut **transaction).await + if let Some(conn) = connection { + query.fetch_all(&mut *conn).await } else { query .fetch_all(&self.get_connection_pool(vault).await?) @@ -216,9 +426,7 @@ impl Database { is_deleted: row.is_deleted, user_id: row.user_id, device_id: row.device_id, - content_size: row - .content_size - .expect("Content size can't be null but sqlx can't infer it"), + content_size: row.content_size.unwrap_or(0), }) .collect() }) @@ -230,7 +438,7 @@ impl Database { &self, vault: &VaultId, vault_update_id: VaultUpdateId, - transaction: Option<&mut Transaction<'_>>, + connection: Option<&mut SqliteConnection>, ) -> Result> { let query = sqlx::query!( r#" @@ -250,8 +458,8 @@ impl Database { vault_update_id ); - if let Some(transaction) = transaction { - query.fetch_all(&mut **transaction).await + if let Some(conn) = connection { + query.fetch_all(&mut *conn).await } else { query .fetch_all(&self.get_connection_pool(vault).await?) @@ -270,9 +478,7 @@ impl Database { is_deleted: row.is_deleted, user_id: row.user_id, device_id: row.device_id, - content_size: row - .content_size - .expect("Content size can't be null but sqlx can't infer it"), + content_size: row.content_size.unwrap_or(0), }) .collect() }) @@ -281,7 +487,7 @@ impl Database { pub async fn get_max_update_id_in_vault( &self, vault: &VaultId, - transaction: Option<&mut Transaction<'_>>, + connection: Option<&mut SqliteConnection>, ) -> Result { let query = sqlx::query!( r#" @@ -290,8 +496,8 @@ impl Database { "#, ); - if let Some(transaction) = transaction { - query.fetch_one(&mut **transaction).await + if let Some(conn) = connection { + query.fetch_one(&mut *conn).await } else { query .fetch_one(&self.get_connection_pool(vault).await?) @@ -301,11 +507,11 @@ impl Database { .context("Cannot fetch max update id in vault") } - pub async fn get_latest_document_by_path( + pub async fn get_latest_non_deleted_document_by_path( &self, vault: &VaultId, relative_path: &str, - transaction: Option<&mut Transaction<'_>>, + connection: Option<&mut SqliteConnection>, ) -> Result> { let query = sqlx::query_as!( StoredDocumentVersion, @@ -330,8 +536,8 @@ impl Database { relative_path ); - if let Some(transaction) = transaction { - query.fetch_optional(&mut **transaction).await + if let Some(conn) = connection { + query.fetch_optional(&mut *conn).await } else { query .fetch_optional(&self.get_connection_pool(vault).await?) @@ -344,7 +550,7 @@ impl Database { &self, vault: &VaultId, document_id: &DocumentId, - transaction: Option<&mut Transaction<'_>>, + connection: Option<&mut SqliteConnection>, ) -> Result> { let document_id = document_id.as_hyphenated(); let query = sqlx::query_as!( @@ -366,8 +572,8 @@ impl Database { document_id ); - if let Some(transaction) = transaction { - query.fetch_optional(&mut **transaction).await + if let Some(conn) = connection { + query.fetch_optional(&mut *conn).await } else { query .fetch_optional(&self.get_connection_pool(vault).await?) @@ -380,7 +586,7 @@ impl Database { &self, vault: &VaultId, vault_update_id: VaultUpdateId, - transaction: Option<&mut Transaction<'_>>, + connection: Option<&mut SqliteConnection>, ) -> Result> { let query = sqlx::query_as!( StoredDocumentVersion, @@ -400,8 +606,8 @@ impl Database { vault_update_id ); - if let Some(transaction) = transaction { - query.fetch_optional(&mut **transaction).await + if let Some(conn) = connection { + query.fetch_optional(&mut *conn).await } else { query .fetch_optional(&self.get_connection_pool(vault).await?) @@ -415,7 +621,7 @@ impl Database { &self, vault_id: &VaultId, version: &StoredDocumentVersion, - transaction: Option>, + transaction: Option, ) -> Result<()> { let document_id = version.document_id.as_hyphenated(); let query = sqlx::query!( @@ -428,9 +634,10 @@ impl Database { content, is_deleted, user_id, - device_id + device_id, + has_been_merged ) - values (?, ?, ?, ?, ?, ?, ?, ?) + values (?, ?, ?, ?, ?, ?, ?, ?, ?) "#, version.vault_update_id, document_id, @@ -439,7 +646,8 @@ impl Database { version.content, version.is_deleted, version.user_id, - version.device_id + version.device_id, + version.has_been_merged ); if let Some(mut transaction) = transaction { @@ -477,38 +685,66 @@ impl Database { /// Cleanup idle connection pools that haven't been accessed in more than 5 minutes async fn cleanup_idle_pools(&self) { - let mut pools = self.connection_pools.lock().await; - let now = Instant::now(); - let idle_timeout = Duration::from_secs(5 * 60); // 5 minutes + // Collect idle vaults and remove them from the map while holding + // the lock briefly. Close pools OUTSIDE the lock so that + // pool.close().await doesn't block other get_connection_pool calls. + let idle_pools: Vec<(VaultId, Arc)> = { + let mut pools = self.connection_pools.lock().await; + let now = Instant::now(); - // Collect vaults to remove - let vaults_to_remove: Vec = pools - .iter() - .filter(|(_, pool_with_timestamp)| { - now.duration_since(pool_with_timestamp.last_accessed) > idle_timeout - }) - .map(|(vault_id, _)| vault_id.clone()) - .collect(); + let vaults_to_remove: Vec = pools + .iter() + .filter(|(_, vp)| { + // If the lock is contested, the pool is actively used — not idle. + let Ok(last) = vp.last_accessed.try_lock() else { + return false; + }; + now.duration_since(*last) > IDLE_POOL_TIMEOUT + }) + .map(|(vault_id, _)| vault_id.clone()) + .collect(); - // Close and remove idle pools - for vault_id in &vaults_to_remove { - if let Some(pool_with_timestamp) = pools.remove(vault_id) { - info!("Closing idle database connection pool for vault `{vault_id}`"); - pool_with_timestamp.pool.close().await; + vaults_to_remove + .into_iter() + .filter_map(|id| pools.remove(&id).map(|vp| (id, vp))) + .collect() + }; + + for (vault_id, vault_pool) in idle_pools { + if let Some(pools) = vault_pool.cell.get() { + // Checkpoint the WAL before closing to reclaim disk space + // and ensure the next open doesn't need a large WAL replay. + // TRUNCATE mode resets the WAL file to zero bytes. + if let Err(e) = sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)") + .execute(&pools.writer) + .await + { + log::warn!("WAL checkpoint failed for vault `{vault_id}`: {e}"); + } + info!("Closing idle database connection pools for vault `{vault_id}`"); + pools.reader.close().await; + pools.writer.close().await; } } } /// Start a background task that periodically cleans up idle connection pools - fn start_idle_pool_cleanup(&self) { + fn start_idle_pool_cleanup(&self, mut shutdown: tokio::sync::watch::Receiver<()>) { let database = self.clone(); tokio::spawn(async move { let mut interval = tokio::time::interval(Duration::from_secs(60)); // Check every minute interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); loop { - interval.tick().await; - database.cleanup_idle_pools().await; + tokio::select! { + _ = interval.tick() => { + database.cleanup_idle_pools().await; + } + _ = shutdown.changed() => { + info!("Idle pool cleanup task shutting down"); + break; + } + } } }); } diff --git a/sync-server/src/app_state/database/models.rs b/sync-server/src/app_state/database/models.rs index f6b35424..59d08c82 100644 --- a/sync-server/src/app_state/database/models.rs +++ b/sync-server/src/app_state/database/models.rs @@ -22,7 +22,6 @@ pub struct StoredDocumentVersion { pub device_id: DeviceId, #[allow(dead_code)] // This is for manual analysis pub has_been_merged: bool, - pub idempotency_key: Option, } impl PartialEq for StoredDocumentVersion { diff --git a/sync-server/src/app_state/websocket/broadcasts.rs b/sync-server/src/app_state/websocket/broadcasts.rs index 60ae0219..cf359497 100644 --- a/sync-server/src/app_state/websocket/broadcasts.rs +++ b/sync-server/src/app_state/websocket/broadcasts.rs @@ -1,35 +1,52 @@ use std::{collections::HashMap, sync::Arc}; -use anyhow::Context; use log::{debug, warn}; use tokio::sync::{Mutex, broadcast}; use super::models::WebSocketServerMessageWithOrigin; -use crate::{ - app_state::database::models::VaultId, config::server_config::ServerConfig, errors::server_error, -}; +use crate::{app_state::database::models::VaultId, config::server_config::ServerConfig}; #[derive(Debug, Clone)] pub struct Broadcasts { - max_clients_per_vault: usize, + broadcast_channel_capacity: usize, tx: Arc>>>, } +type TxMap = HashMap>; + impl Broadcasts { pub fn new(server_config: &ServerConfig) -> Self { Self { - max_clients_per_vault: server_config.max_clients_per_vault, + broadcast_channel_capacity: server_config.broadcast_channel_capacity, tx: Arc::new(Mutex::new(HashMap::new())), } } + /// Remove senders for vaults with no active receivers + fn prune_inactive_vaults(tx_map: &mut TxMap) { + tx_map.retain(|_, sender| sender.receiver_count() > 0); + } + pub async fn get_receiver( &self, vault: VaultId, - ) -> broadcast::Receiver { - let tx = self.get_or_create(vault).await; + max_clients: usize, + ) -> Result, crate::errors::SyncServerError> + { + let mut tx_map = self.tx.lock().await; + Self::prune_inactive_vaults(&mut tx_map); - tx.subscribe() + let sender = tx_map + .entry(vault) + .or_insert_with(|| broadcast::channel(self.broadcast_channel_capacity).0); + + if sender.receiver_count() >= max_clients { + return Err(crate::errors::client_error(anyhow::anyhow!( + "Vault has reached the maximum number of clients ({max_clients})" + ))); + } + + Ok(sender.subscribe()) } /// Notify all clients (who are subscribed to the vault) about an update. @@ -39,31 +56,20 @@ impl Broadcasts { vault: VaultId, document: WebSocketServerMessageWithOrigin, ) { - let tx = self.get_or_create(vault.clone()).await; + let mut tx_map = self.tx.lock().await; + Self::prune_inactive_vaults(&mut tx_map); - if tx.receiver_count() == 0 { + let sender = tx_map + .entry(vault.clone()) + .or_insert_with(|| broadcast::channel(self.broadcast_channel_capacity).0); + + if sender.receiver_count() == 0 { debug!("Skipping broadcast, no clients connected for vault `{vault}`"); return; } - let result = tx - .send(document) - .context("Cannot broadcast server message to websocket listeners") - .map_err(server_error); - - if result.is_err() { - warn!("Failed to send message: {result:?}"); + if let Err(e) = sender.send(document) { + warn!("Failed to broadcast to vault `{vault}`: {e}"); } } - - async fn get_or_create( - &self, - vault: VaultId, - ) -> broadcast::Sender { - let mut tx = self.tx.lock().await; - - tx.entry(vault) - .or_insert_with(|| broadcast::channel(self.max_clients_per_vault).0.clone()) - .clone() - } } diff --git a/sync-server/src/config.rs b/sync-server/src/config.rs index 75d4dba7..26b11a4c 100644 --- a/sync-server/src/config.rs +++ b/sync-server/src/config.rs @@ -27,6 +27,19 @@ pub struct Config { } impl Config { + pub fn validate(&self) -> Result<()> { + self.server + .validate() + .context("Invalid server configuration")?; + self.logging + .validate() + .context("Invalid logging configuration")?; + self.database + .validate() + .context("Invalid database configuration")?; + Ok(()) + } + pub async fn read_or_create(path: &Path) -> Result { let display_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); diff --git a/sync-server/src/config/database_config.rs b/sync-server/src/config/database_config.rs index 20a9a21e..21e79d29 100644 --- a/sync-server/src/config/database_config.rs +++ b/sync-server/src/config/database_config.rs @@ -1,5 +1,6 @@ use std::{path::PathBuf, time::Duration}; +use anyhow::{Result, ensure}; use log::debug; use serde::{Deserialize, Serialize}; @@ -34,6 +35,24 @@ fn default_cursor_timeout() -> Duration { DEFAULT_CURSOR_TIMEOUT } +impl DatabaseConfig { + pub fn validate(&self) -> Result<()> { + ensure!( + self.databases_directory_path.as_os_str().len() > 0, + "databases_directory_path must not be empty" + ); + ensure!( + self.max_connections_per_vault > 0, + "max_connections_per_vault must be greater than 0" + ); + ensure!( + !self.cursor_timeout.is_zero(), + "cursor_timeout must be greater than 0" + ); + Ok(()) + } +} + impl Default for DatabaseConfig { fn default() -> Self { Self { diff --git a/sync-server/src/config/logging_config.rs b/sync-server/src/config/logging_config.rs index ad449d1a..e716518d 100644 --- a/sync-server/src/config/logging_config.rs +++ b/sync-server/src/config/logging_config.rs @@ -1,5 +1,6 @@ use std::time::Duration; +use anyhow::{Result, ensure}; use log::debug; use serde::{Deserialize, Serialize}; @@ -20,6 +21,17 @@ pub struct LoggingConfig { pub log_level: LogLevel, } +impl LoggingConfig { + pub fn validate(&self) -> Result<()> { + ensure!( + !self.log_directory.is_empty(), + "log_directory must not be an empty string" + ); + ensure!(self.log_rotation > 0, "log_rotation must be greater than 0"); + Ok(()) + } +} + impl Default for LoggingConfig { fn default() -> Self { Self { diff --git a/sync-server/src/config/server_config.rs b/sync-server/src/config/server_config.rs index 4a9da0f4..4132d336 100644 --- a/sync-server/src/config/server_config.rs +++ b/sync-server/src/config/server_config.rs @@ -1,10 +1,13 @@ +use anyhow::{Result, ensure}; use log::debug; use serde::{Deserialize, Serialize}; use std::time::Duration; use crate::consts::{ - DEFAULT_HOST, DEFAULT_MAX_BODY_SIZE_MB, DEFAULT_MAX_CLIENTS_PER_VAULT, - DEFAULT_MERGEABLE_FILE_EXTENSIONS, DEFAULT_PORT, DEFAULT_RESPONSE_TIMEOUT_SECONDS, + DEFAULT_ALLOWED_ORIGINS, DEFAULT_BROADCAST_CHANNEL_CAPACITY, DEFAULT_HOST, + DEFAULT_MAX_BODY_SIZE_MB, DEFAULT_MAX_CLIENTS_PER_VAULT, DEFAULT_MAX_PENDING_WS_CONNECTIONS, + DEFAULT_MERGEABLE_FILE_EXTENSIONS, DEFAULT_PORT, DEFAULT_RATE_LIMIT_PER_USER_PER_SECOND, + DEFAULT_RESPONSE_TIMEOUT_SECONDS, }; #[derive(Debug, Deserialize, Serialize, Clone, Default)] @@ -21,11 +24,56 @@ pub struct ServerConfig { #[serde(default = "default_max_clients_per_vault")] pub max_clients_per_vault: usize, + #[serde(default = "default_broadcast_channel_capacity")] + pub broadcast_channel_capacity: usize, + #[serde(default = "default_response_timeout", with = "humantime_serde")] pub response_timeout: Duration, #[serde(default = "default_mergeable_file_extensions")] pub mergeable_file_extensions: Vec, + + /// Per-user maximum requests per second (keyed by bearer token). + /// `None` disables rate limiting. + #[serde(default = "DEFAULT_RATE_LIMIT_PER_USER_PER_SECOND")] + pub rate_limit_per_user_per_second: Option, + + /// Allowed CORS origins. Default: `["*"]` (allow all). + #[serde(default = "default_allowed_origins")] + pub allowed_origins: Vec, + + /// Maximum concurrent unauthenticated WebSocket connections waiting for + /// handshake. Limits resource consumption from clients that connect but + /// never authenticate. + #[serde(default = "default_max_pending_websocket_connections")] + pub max_pending_websocket_connections: usize, +} + +impl ServerConfig { + pub fn validate(&self) -> Result<()> { + ensure!( + self.response_timeout > 0, + "response_timeout must be greater than 0" + ); + ensure!( + self.max_body_size_mb > 0, + "max_body_size_mb must be greater than 0" + ); + ensure!( + self.max_clients_per_vault > 0, + "max_clients_per_vault must be greater than 0" + ); + ensure!( + self.broadcast_channel_capacity > 0, + "broadcast_channel_capacity must be greater than 0" + ); + ensure!( + self.max_pending_websocket_connections > 0, + "max_pending_websocket_connections must be greater than 0" + ); + + Ok(()) + } } fn default_host() -> String { @@ -48,6 +96,11 @@ fn default_max_clients_per_vault() -> usize { DEFAULT_MAX_CLIENTS_PER_VAULT } +fn default_broadcast_channel_capacity() -> usize { + debug!("Using default broadcast channel capacity: {DEFAULT_BROADCAST_CHANNEL_CAPACITY}"); + DEFAULT_BROADCAST_CHANNEL_CAPACITY +} + fn default_response_timeout() -> Duration { debug!("Using default response timeout: {DEFAULT_RESPONSE_TIMEOUT_SECONDS:?}"); DEFAULT_RESPONSE_TIMEOUT_SECONDS @@ -60,3 +113,21 @@ fn default_mergeable_file_extensions() -> Vec { .map(|s| (*s).to_owned()) .collect() } + +fn DEFAULT_RATE_LIMIT_PER_USER_PER_SECOND() -> Option { + debug!("Using default rate limit per second: {DEFAULT_RATE_LIMIT_PER_USER_PER_SECOND:?}"); + DEFAULT_RATE_LIMIT_PER_USER_PER_SECOND +} + +fn default_allowed_origins() -> Vec { + debug!("Using default allowed origins: {DEFAULT_ALLOWED_ORIGINS:?}"); + DEFAULT_ALLOWED_ORIGINS + .iter() + .map(|s| (*s).to_owned()) + .collect() +} + +fn default_max_pending_websocket_connections() -> usize { + debug!("Using default max pending WebSocket connections: {DEFAULT_MAX_PENDING_WS_CONNECTIONS}"); + DEFAULT_MAX_PENDING_WS_CONNECTIONS +} diff --git a/sync-server/src/consts.rs b/sync-server/src/consts.rs index 98ed1c1f..715763d9 100644 --- a/sync-server/src/consts.rs +++ b/sync-server/src/consts.rs @@ -5,19 +5,31 @@ use crate::utils::log_level::LogLevel; pub const DEFAULT_CONFIG_PATH: &str = "config.yml"; pub const DEFAULT_DATABASES_DIRECTORY_PATH: &str = "databases"; -pub const DEFAULT_MAX_CONNECTIONS_PER_VAULT: u32 = 12; +pub const DEFAULT_MAX_CONNECTIONS_PER_VAULT: u32 = 6; pub const DEFAULT_CURSOR_TIMEOUT: Duration = Duration::from_secs(60); pub const DEFAULT_HOST: &str = "127.0.0.1"; pub const DEFAULT_PORT: u16 = 3000; pub const DEFAULT_MAX_BODY_SIZE_MB: usize = 4096; -pub const DEFAULT_RESPONSE_TIMEOUT_SECONDS: Duration = Duration::from_secs(1800); +pub const DEFAULT_RESPONSE_TIMEOUT_SECONDS: Duration = Duration::from_mins(30); pub const DEFAULT_MAX_CLIENTS_PER_VAULT: usize = 256; +pub const DEFAULT_BROADCAST_CHANNEL_CAPACITY: usize = 4096; +pub const DEFAULT_MAX_PENDING_WS_CONNECTIONS: usize = 128; pub const DEFAULT_LOG_DIRECTORY: &str = "logs"; -pub const DEFAULT_LOG_ROTATION_INTERVAL: Duration = Duration::from_secs(60 * 60 * 24); // 1 day +pub const DEFAULT_LOG_ROTATION_INTERVAL: Duration = Duration::from_hours(24); +pub const IDLE_POOL_TIMEOUT: Duration = Duration::from_mins(5); +pub const GRACEFUL_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10); +pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); + +pub const MAX_CURSOR_DOCUMENTS: usize = 1000; +pub const MAX_CURSORS_PER_DOCUMENT: usize = 100; +pub const MAX_RELATIVE_PATH_LEN: usize = 4096; + pub const DEFAULT_LOG_LEVEL: LogLevel = LogLevel::Info; pub const DEFAULT_MERGEABLE_FILE_EXTENSIONS: &[&str] = &["md", "txt"]; -pub const SUPPORTED_API_VERSION: u32 = 2; +pub const DEFAULT_RATE_LIMIT_PER_USER_PER_SECOND: Option = None; +pub const DEFAULT_ALLOWED_ORIGINS: &[&str] = &["*"]; +pub const SUPPORTED_API_VERSION: u32 = 3; diff --git a/sync-server/src/main.rs b/sync-server/src/main.rs index 1285ed7b..621717bf 100644 --- a/sync-server/src/main.rs +++ b/sync-server/src/main.rs @@ -41,11 +41,12 @@ async fn main() -> ExitCode { } }; - let mut result = set_up_logging(&args, &config.logging); - - if result.is_ok() { - result = start_server(config).await; + let result = async { + config.validate().map_err(init_error)?; + set_up_logging(&args, &config.logging)?; + start_server(config).await } + .await; match result { Ok(()) => ExitCode::SUCCESS, diff --git a/sync-server/src/server.rs b/sync-server/src/server.rs index 01b09cf6..95b0038b 100644 --- a/sync-server/src/server.rs +++ b/sync-server/src/server.rs @@ -8,6 +8,7 @@ mod fetch_latest_document_version; mod fetch_latest_documents; mod index; mod ping; +mod rate_limit; mod requests; mod responses; mod update_document; @@ -24,7 +25,7 @@ use axum::{ routing::{IntoMakeService, delete, get, post, put}, }; use device_id_header::DEVICE_ID_HEADER_NAME; -use log::info; +use log::{info, warn}; use tokio::signal; use tower_http::{ LatencyUnit, @@ -41,7 +42,7 @@ use tracing::{Level, info_span}; use crate::{ app_state::AppState, config::{Config, server_config::ServerConfig}, - errors::{client_error, not_found_error}, + consts::GRACEFUL_SHUTDOWN_TIMEOUT, }; pub async fn create_server(config: Config) -> Result<()> { @@ -56,21 +57,26 @@ pub async fn create_server(config: Config) -> Result<()> { .route("/", get(index::index)) .route("/vaults/:vault_id/ping", get(ping::ping)) .route("/vaults/:vault_id/ws", get(websocket::websocket_handler)) + .fallback(index::spa_fallback); + + let cors_layer = build_cors_layer(&server_config).context("Invalid CORS configuration")?; + + if let Some(rate_limit) = server_config.rate_limit_per_user_per_second { + info!("Rate limiting enabled: {rate_limit} requests/second per user"); + let limiter = rate_limit::RateLimiter::new(rate_limit); + app = app.layer(middleware::from_fn_with_state( + limiter, + rate_limit::rate_limit_middleware, + )); + } + + let app = app .layer(DefaultBodyLimit::disable()) .layer(RequestBodyLimitLayer::new( app_state.config.server.max_body_size_mb * 1024 * 1024, )) .layer(TimeoutLayer::new(server_config.response_timeout)) - .layer( - CorsLayer::new() - .allow_origin("*".parse::().expect("Failed to parse origin")) - .allow_headers([ - http::header::CONTENT_TYPE, - http::header::AUTHORIZATION, - DEVICE_ID_HEADER_NAME.clone(), - ]) - .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE]), - ) + .layer(cors_layer) .layer( TraceLayer::new_for_http() .make_span_with(|request: &Request<_>| { @@ -90,12 +96,39 @@ pub async fn create_server(config: Config) -> Result<()> { .on_eos(DefaultOnEos::new()) .on_failure(DefaultOnFailure::new().level(Level::ERROR)), ) - .with_state(app_state) - .fallback(handle_404) - .fallback(handle_405) + .with_state(app_state.clone()) .into_make_service(); - start_server(app, &server_config).await + start_server(app, &server_config, app_state).await +} + +fn build_cors_layer(server_config: &ServerConfig) -> Result { + let origins = &server_config.allowed_origins; + + let cors = if origins.len() == 1 && origins[0] == "*" { + info!("CORS: allowing all origins (wildcard)"); + let header: HeaderValue = "*" + .parse() + .context("Failed to parse wildcard CORS origin")?; + CorsLayer::new().allow_origin(header) + } else { + let parsed: Vec = origins + .iter() + .map(|o| { + o.parse::() + .with_context(|| format!("Failed to parse CORS origin: `{o}`")) + }) + .collect::>>()?; + CorsLayer::new().allow_origin(parsed) + }; + + Ok(cors + .allow_headers([ + http::header::CONTENT_TYPE, + http::header::AUTHORIZATION, + DEVICE_ID_HEADER_NAME.clone(), + ]) + .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])) } fn get_authed_routes(app_state: AppState) -> Router { @@ -135,7 +168,11 @@ fn get_authed_routes(app_state: AppState) -> Router { .layer(middleware::from_fn_with_state(app_state, auth_middleware)) } -async fn start_server(app: IntoMakeService, config: &ServerConfig) -> Result<()> { +async fn start_server( + app: IntoMakeService, + config: &ServerConfig, + app_state: AppState, +) -> Result<()> { let address = format!("{}:{}", config.host, config.port); let listener = tokio::net::TcpListener::bind(address.clone()) .await @@ -148,26 +185,46 @@ async fn start_server(app: IntoMakeService, config: &ServerConfig) .context("Failed to get local address")? ); - axum::serve(listener, app) - .with_graceful_shutdown(shutdown_signal()) - .tcp_nodelay(true) - .await - .context("Failed to start server") + let mut shutdown_rx = app_state.subscribe_shutdown(); + + let server = axum::serve(listener, app) + .with_graceful_shutdown(async move { + shutdown_signal().await; + app_state.shutdown(); + }) + .tcp_nodelay(true); + + tokio::select! { + result = server => result.context("Failed to start server"), + () = async { + let _ = shutdown_rx.changed().await; + info!( + "Shutdown signal received, waiting up to {}s for in-flight requests to complete...", + GRACEFUL_SHUTDOWN_TIMEOUT.as_secs() + ); + tokio::time::sleep(GRACEFUL_SHUTDOWN_TIMEOUT).await; + warn!("Graceful shutdown timed out, forcing exit"); + } => Ok(()), + } } async fn shutdown_signal() { let ctrl_c = async { - signal::ctrl_c() - .await - .expect("failed to install Ctrl+C handler"); + if let Err(e) = signal::ctrl_c().await { + log::error!("Failed to install Ctrl+C handler: {e}"); + } }; #[cfg(unix)] let terminate = async { - signal::unix::signal(signal::unix::SignalKind::terminate()) - .expect("failed to install signal handler") - .recv() - .await; + match signal::unix::signal(signal::unix::SignalKind::terminate()) { + Ok(mut signal) => { + signal.recv().await; + } + Err(e) => { + log::error!("Failed to install SIGTERM handler: {e}"); + } + } }; #[cfg(not(unix))] @@ -178,11 +235,3 @@ async fn shutdown_signal() { () = terminate => {}, } } - -async fn handle_404() -> impl IntoResponse { - not_found_error(anyhow!("Page not found")) -} - -async fn handle_405() -> impl IntoResponse { - client_error(anyhow!("Method not allowed")) -} diff --git a/sync-server/src/server/create_document.rs b/sync-server/src/server/create_document.rs index 859c0db4..39560ef8 100644 --- a/sync-server/src/server/create_document.rs +++ b/sync-server/src/server/create_document.rs @@ -1,3 +1,4 @@ +use anyhow::Context as _; use axum::{ Extension, Json, extract::{Path, State}, @@ -5,18 +6,21 @@ use axum::{ use axum_extra::TypedHeader; use axum_typed_multipart::TypedMultipart; use log::{debug, info}; +use reconcile_text::{BuiltinTokenizer, reconcile}; use serde::Deserialize; use super::{device_id_header::DeviceIdHeader, requests::CreateDocumentVersion}; use crate::{ app_state::{ AppState, - database::models::{DocumentVersionWithoutContent, StoredDocumentVersion, VaultId}, + database::models::{StoredDocumentVersion, VaultId}, }, config::user_config::User, - errors::{SyncServerError, client_error, server_error}, + errors::{SyncServerError, client_error, server_error, write_transaction_error}, + server::{responses::DocumentUpdateResponse, update_document}, utils::{ - find_first_available_path::find_first_available_path, normalize::normalize, + find_first_available_path::find_first_available_path, is_binary::is_binary, + is_file_type_mergable::is_file_type_mergable, normalize::normalize, sanitize_path::sanitize_path, }, }; @@ -30,48 +34,75 @@ pub struct CreateDocumentPathParams { /// Create a new document in case a document with the same doesn't exist /// already. If a document with the same path exists, a new version is created /// with their content merged. +/// +/// Text content must be UTF-8 encoded. Clients are responsible for +/// transcoding other encodings (e.g. UTF-16) to UTF-8 before sending. #[axum::debug_handler] +#[allow(clippy::too_many_lines)] pub async fn create_document( Path(CreateDocumentPathParams { vault_id }): Path, Extension(user): Extension, TypedHeader(device_id): TypedHeader, State(state): State, TypedMultipart(request): TypedMultipart, -) -> Result, SyncServerError> { +) -> Result, SyncServerError> { debug!("Creating document in vault `{vault_id}`"); let mut transaction = state .database .create_write_transaction(&vault_id) .await - .map_err(server_error)?; + .map_err(write_transaction_error)?; - let document_id = match request.document_id { - Some(document_id) => { - let existing_version = state - .database - .get_latest_document(&vault_id, &document_id, Some(&mut transaction)) - .await - .map_err(server_error)?; + let sanitized_relative_path = sanitize_path(&request.relative_path).map_err(client_error)?; + let new_content = request.content.contents.to_vec(); - if existing_version.is_some() { - return Err(client_error(anyhow::anyhow!( - "Document with the same ID `{document_id}` already exists" - ))); - } - - document_id - } - None => uuid::Uuid::new_v4(), - }; - - let last_update_id = state + let latest_version = state .database - .get_max_update_id_in_vault(&vault_id, Some(&mut transaction)) + .get_latest_non_deleted_document_by_path( + &vault_id, + &sanitized_relative_path, + Some(&mut *transaction), + ) + .await + .map_err(server_error)?; + + if let Some(latest_version) = latest_version { + let is_mergeable_text = is_file_type_mergable( + &sanitized_relative_path, + &state.config.server.mergeable_file_extensions, + ) && !is_binary(&latest_version.content) + && !is_binary(&new_content); + + if is_mergeable_text || new_content == latest_version.content { + return update_document::update_document( + &sanitized_relative_path, + Vec::new(), + vault_id, + latest_version.document_id, + &request.relative_path, + new_content, + user, + device_id, + state, + transaction, + ) + .await; + } + + // For non-mergeable (binary) files with different content, don't + // merge, create a separate document at a deconflicted path so + // neither client's data is silently overwritten. + } + + let document_id = uuid::Uuid::new_v4(); + + let last_update_id = state + .database + .get_max_update_id_in_vault(&vault_id, Some(&mut *transaction)) .await .map_err(server_error)?; - let sanitized_relative_path = sanitize_path(&request.relative_path); let deduped_path = find_first_available_path( &vault_id, &sanitized_relative_path, @@ -91,7 +122,7 @@ pub async fn create_document( vault_update_id: last_update_id + 1, document_id, relative_path: deduped_path, - content: request.content.contents.to_vec(), + content: new_content, updated_date: chrono::Utc::now(), is_deleted: false, user_id: user.name, @@ -105,5 +136,7 @@ pub async fn create_document( .await .map_err(server_error)?; - Ok(Json(new_version.into())) + Ok(Json(DocumentUpdateResponse::FastForwardUpdate( + new_version.into(), + ))) } diff --git a/sync-server/src/server/delete_document.rs b/sync-server/src/server/delete_document.rs index e126d6b5..0083505e 100644 --- a/sync-server/src/server/delete_document.rs +++ b/sync-server/src/server/delete_document.rs @@ -1,4 +1,4 @@ -use anyhow::Context; +use anyhow::{Context, anyhow}; use axum::{ Extension, Json, extract::{Path, State}, @@ -16,7 +16,7 @@ use crate::{ }, }, config::user_config::User, - errors::{SyncServerError, server_error}, + errors::{SyncServerError, client_error, not_found_error, server_error, write_transaction_error}, utils::{normalize::normalize, sanitize_path::sanitize_path}, }; @@ -37,7 +37,7 @@ pub async fn delete_document( Extension(user): Extension, TypedHeader(device_id): TypedHeader, State(state): State, - Json(request): Json, + Json(_request): Json, ) -> Result, SyncServerError> { debug!("Deleting document `{document_id}` in vault `{vault_id}`"); @@ -45,7 +45,7 @@ pub async fn delete_document( .database .create_write_transaction(&vault_id) .await - .map_err(server_error)?; + .map_err(write_transaction_error)?; let last_update_id = state .database @@ -77,7 +77,7 @@ pub async fn delete_document( let new_version = StoredDocumentVersion { vault_update_id: last_update_id + 1, document_id, - relative_path: sanitize_path(&request.relative_path), + relative_path: sanitize_path(&request.relative_path).map_err(client_error)?, content: latest_content, // copy the content from the latest version updated_date: chrono::Utc::now(), is_deleted: true, diff --git a/sync-server/src/server/fetch_document_version.rs b/sync-server/src/server/fetch_document_version.rs index c30f1d76..159cad3a 100644 --- a/sync-server/src/server/fetch_document_version.rs +++ b/sync-server/src/server/fetch_document_version.rs @@ -11,7 +11,7 @@ use crate::{ AppState, database::models::{DocumentId, DocumentVersion, VaultId, VaultUpdateId}, }, - errors::{SyncServerError, not_found_error, server_error}, + errors::{SyncServerError, client_error, not_found_error, server_error}, utils::normalize::normalize, }; @@ -52,7 +52,7 @@ pub async fn fetch_document_version( )?; if result.document_id != document_id { - return Err(not_found_error(anyhow!( + return Err(client_error(anyhow!( "Document with document id `{document_id}` does not have a version with id \ `{vault_update_id}`", ))); diff --git a/sync-server/src/server/fetch_document_version_content.rs b/sync-server/src/server/fetch_document_version_content.rs index 9fdd0ad8..a163b036 100644 --- a/sync-server/src/server/fetch_document_version_content.rs +++ b/sync-server/src/server/fetch_document_version_content.rs @@ -11,7 +11,7 @@ use crate::{ AppState, database::models::{DocumentId, VaultId, VaultUpdateId}, }, - errors::{SyncServerError, not_found_error, server_error}, + errors::{SyncServerError, client_error, not_found_error, server_error}, utils::normalize::normalize, }; @@ -52,7 +52,7 @@ pub async fn fetch_document_version_content( )?; if result.document_id != document_id { - return Err(not_found_error(anyhow!( + return Err(client_error(anyhow!( "Document with document id `{document_id}` does not have a version with id \ `{vault_update_id}`", ))); diff --git a/sync-server/src/server/rate_limit.rs b/sync-server/src/server/rate_limit.rs index 8047adc2..7792a814 100644 --- a/sync-server/src/server/rate_limit.rs +++ b/sync-server/src/server/rate_limit.rs @@ -1,25 +1,37 @@ -use std::sync::{ - Arc, - atomic::{AtomicU64, Ordering}, +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, + time::Instant, }; use axum::{extract::Request, http::StatusCode, middleware::Next, response::Response}; +use axum_extra::{ + TypedHeader, + headers::{Authorization, authorization::Bearer}, +}; -/// Simple token-bucket rate limiter that refills every second. +/// Per-user token-bucket rate limiter. Each bearer token gets its own bucket +/// that refills to `max_per_second` tokens every second. #[derive(Clone, Debug)] pub struct RateLimiter { - inner: Arc, + max_per_second: u64, + buckets: Arc>>>, } #[derive(Debug)] struct TokenBucket { - tokens: AtomicU64, + state: Mutex, max_tokens: u64, } +#[derive(Debug)] +struct BucketState { + tokens: u64, + last_refill: Instant, +} + impl RateLimiter { - /// Create a new rate limiter. Spawns a background task that refills tokens - /// every second. + /// Create a new per-user rate limiter. /// /// # Panics /// @@ -27,44 +39,62 @@ impl RateLimiter { pub fn new(max_per_second: u64) -> Self { assert!( max_per_second > 0, - "max_per_second must be > 0 (use 0 in config to disable rate limiting entirely)" + "max_per_second must be > 0 (set rate_limit_per_user_per_second to null in config to disable)" ); - let bucket = Arc::new(TokenBucket { - tokens: AtomicU64::new(max_per_second), - max_tokens: max_per_second, - }); - - let bucket_clone = bucket.clone(); - tokio::spawn(async move { - let mut interval = tokio::time::interval(std::time::Duration::from_secs(1)); - loop { - interval.tick().await; - bucket_clone - .tokens - .store(bucket_clone.max_tokens, Ordering::Release); - } - }); - - Self { inner: bucket } + Self { + max_per_second, + buckets: Arc::new(Mutex::new(HashMap::new())), + } } - fn try_acquire(&self) -> bool { - self.inner - .tokens - .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { - if current > 0 { Some(current - 1) } else { None } + fn get_or_create_bucket(&self, token: &str) -> Arc { + self.buckets + .lock() + .expect("rate limiter lock poisoned") + .entry(token.to_owned()) + .or_insert_with(|| { + Arc::new(TokenBucket { + state: Mutex::new(BucketState { + tokens: self.max_per_second, + last_refill: Instant::now(), + }), + max_tokens: self.max_per_second, + }) }) - .is_ok() + .clone() + } +} + +impl TokenBucket { + fn try_acquire(&self) -> bool { + let mut state = self.state.lock().expect("token bucket lock poisoned"); + let now = Instant::now(); + if now.duration_since(state.last_refill).as_secs() >= 1 { + state.tokens = self.max_tokens; + state.last_refill = now; + } + if state.tokens > 0 { + state.tokens -= 1; + true + } else { + false + } } } pub async fn rate_limit_middleware( axum::extract::State(limiter): axum::extract::State, + auth_header: Option>>, req: Request, next: Next, ) -> Result { - if limiter.try_acquire() { + let Some(TypedHeader(auth)) = auth_header else { + return Ok(next.run(req).await); + }; + + let bucket = limiter.get_or_create_bucket(auth.token()); + if bucket.try_acquire() { Ok(next.run(req).await) } else { Err(StatusCode::TOO_MANY_REQUESTS) diff --git a/sync-server/src/server/requests.rs b/sync-server/src/server/requests.rs index 2e612234..107c998c 100644 --- a/sync-server/src/server/requests.rs +++ b/sync-server/src/server/requests.rs @@ -14,8 +14,6 @@ pub struct CreateDocumentVersion { #[ts(as = "Vec")] #[form_data(limit = "unlimited")] pub content: FieldData, - - pub idempotency_key: Option, } #[derive(Debug, TryFromMultipart)] diff --git a/sync-server/src/server/update_document.rs b/sync-server/src/server/update_document.rs index 00fbd008..a12ec993 100644 --- a/sync-server/src/server/update_document.rs +++ b/sync-server/src/server/update_document.rs @@ -5,6 +5,7 @@ use axum::{ }; use axum_extra::TypedHeader; use axum_typed_multipart::TypedMultipart; +use futures::io::Write; use log::{debug, info}; use reconcile_text::{BuiltinTokenizer, EditedText, reconcile}; use serde::Deserialize; @@ -16,10 +17,15 @@ use super::{ use crate::{ app_state::{ AppState, - database::models::{DocumentId, StoredDocumentVersion, VaultId, VaultUpdateId}, + database::{ + WriteTransaction, + models::{DocumentId, StoredDocumentVersion, VaultId, VaultUpdateId}, + }, }, config::user_config::User, - errors::{SyncServerError, client_error, not_found_error, server_error}, + errors::{ + SyncServerError, client_error, not_found_error, server_error, write_transaction_error, + }, server::requests::UpdateBinaryDocumentVersion, utils::{ find_first_available_path::find_first_available_path, is_binary::is_binary, @@ -46,18 +52,27 @@ pub async fn update_binary( State(state): State, TypedMultipart(request): TypedMultipart, ) -> Result, SyncServerError> { - let parent_document = get_parent_document(&state, &vault_id, request.parent_version_id).await?; + let parent_document = + get_parent_document(&state, &vault_id, &document_id, request.parent_version_id).await?; let content = request.content.contents.to_vec(); + let mut transaction = state + .database + .create_write_transaction(&vault_id) + .await + .map_err(write_transaction_error)?; + update_document( - parent_document, + &parent_document.relative_path, + parent_document.content, vault_id, document_id, + &request.relative_path, + content, user, device_id, state, - &request.relative_path, - content, + transaction, ) .await } @@ -74,28 +89,36 @@ pub async fn update_text( State(state): State, Json(request): Json, ) -> Result, SyncServerError> { - let parent_document = get_parent_document(&state, &vault_id, request.parent_version_id).await?; + let parent_document = + get_parent_document(&state, &vault_id, &document_id, request.parent_version_id).await?; - let edited_text = EditedText::from_diff( - str::from_utf8(&parent_document.content) - .expect("parent must be valid UTF-8 because it's a text document"), - request.content, - &*BuiltinTokenizer::Word, - ) - .context("Failed to apply given diff to parent document") - .map_err(client_error)?; + let parent_text = str::from_utf8(&parent_document.content) + .context("Parent version contains binary content; use putBinary instead of putText") + .map_err(client_error)?; + + let edited_text = EditedText::from_diff(parent_text, request.content, &*BuiltinTokenizer::Word) + .context("Failed to apply given diff to parent document") + .map_err(client_error)?; let content = edited_text.apply().text().into_bytes(); + let mut transaction = state + .database + .create_write_transaction(&vault_id) + .await + .map_err(write_transaction_error)?; + update_document( - parent_document, + &parent_document.relative_path, + parent_document.content, vault_id, document_id, + &request.relative_path, + content, user, device_id, state, - &request.relative_path, - content, + transaction, ) .await } @@ -103,9 +126,10 @@ pub async fn update_text( async fn get_parent_document( state: &AppState, vault_id: &VaultId, + document_id: &DocumentId, parent_version_id: VaultUpdateId, ) -> Result { - state + let parent = state .database .get_document_version(vault_id, parent_version_id, None) .await @@ -117,29 +141,33 @@ async fn get_parent_document( ))) }, Ok, - ) + )?; + + if &parent.document_id != document_id { + return Err(client_error(anyhow!( + "Parent version `{parent_version_id}` does not belong to document `{document_id}`" + ))); + } + + Ok(parent) } #[allow(clippy::too_many_lines, clippy::too_many_arguments)] -async fn update_document( - parent_document: StoredDocumentVersion, +pub async fn update_document( + parent_relative_path: &str, + parent_content: Vec, vault_id: VaultId, document_id: DocumentId, + relative_path: &str, + content: Vec, user: User, device_id: DeviceIdHeader, state: AppState, - relative_path: &str, - content: Vec, + mut transaction: WriteTransaction, ) -> Result, SyncServerError> { debug!("Updating document `{document_id}` in vault `{vault_id}`"); - let sanitized_relative_path = sanitize_path(relative_path); - - let mut transaction = state - .database - .create_write_transaction(&vault_id) - .await - .map_err(server_error)?; + let sanitized_relative_path = sanitize_path(relative_path).map_err(client_error)?; let last_update_id = state .database @@ -195,35 +223,44 @@ async fn update_document( let are_all_participants_mergable = is_file_type_mergable( &sanitized_relative_path, &state.config.server.mergeable_file_extensions, - ) && !is_binary(&parent_document.content) + ) && !is_binary(&parent_content) && !is_binary(&latest_version.content) && !is_binary(&content); - let merged_content = if are_all_participants_mergable { + let (merged_content, is_different_from_request_content) = if are_all_participants_mergable { info!("Merging changes for document `{document_id}` in vault `{vault_id}`"); - reconcile( - str::from_utf8(&parent_document.content) - .expect("parent must be valid UTF-8 because it's not binary"), - &str::from_utf8(&latest_version.content) - .expect("latest_version must be valid UTF-8 because it's not binary") - .into(), - &str::from_utf8(&content) - .expect("content must be valid UTF-8 because it's not binary") - .into(), + let parent_text = str::from_utf8(&parent_content) + .context("Parent document content is not valid UTF-8") + .map_err(client_error)?; + let latest_text = str::from_utf8(&latest_version.content) + .context("Latest version content is not valid UTF-8") + .map_err(client_error)?; + let new_text = str::from_utf8(&content) + .context("New content is not valid UTF-8") + .map_err(client_error)?; + let merged = reconcile( + parent_text, + &latest_text.into(), + &new_text.into(), &*BuiltinTokenizer::Word, ) .apply() .text() - .into_bytes() + .into_bytes(); + let is_different = merged != content; + (merged, is_different) } else { - content.clone() + (content, false) // false means that the client doesn't need to refetch the file as we can ensure the remote and local versions are the same as LWW is the merging method for binary files }; - let is_different_from_request_content = merged_content != content; - - // We can only update the relative path if we're the first one to do so - let new_relative_path = if parent_document.relative_path == latest_version.relative_path - && latest_version.relative_path != sanitized_relative_path + // Rename resolution: only apply the client's rename if the document's path + // hasn't changed since this client's parent version. Check the parent + // version's path against the latest version's path. If they differ, another + // client already renamed the document — keep the latest path (first rename + // wins). Content changes from both clients are still merged correctly via + // the 3-way reconcile above, independent of which rename wins. + let new_relative_path = if parent_relative_path == latest_version.relative_path + && sanitized_relative_path != latest_version.relative_path { let new_path = find_first_available_path( &vault_id, diff --git a/sync-server/src/server/websocket.rs b/sync-server/src/server/websocket.rs index bb10b49f..a0d15c10 100644 --- a/sync-server/src/server/websocket.rs +++ b/sync-server/src/server/websocket.rs @@ -6,10 +6,10 @@ use axum::{ }, response::Response, }; +use futures::sink::SinkExt; use futures::stream::StreamExt; -use log::{debug, info}; +use log::{debug, info, warn}; use serde::Deserialize; - use crate::{ app_state::{ AppState, @@ -24,10 +24,26 @@ use crate::{ }, }, }, + consts::{ + HANDSHAKE_TIMEOUT, MAX_CURSORS_PER_DOCUMENT, MAX_CURSOR_DOCUMENTS, + MAX_RELATIVE_PATH_LEN, + }, errors::{SyncServerError, client_error, server_error}, utils::normalize::normalize, }; +/// Tracks a pending (not yet authenticated) WebSocket connection. +/// Decrements the counter when dropped, ensuring cleanup even if +/// the upgrade never completes or auth fails. +struct PendingWsGuard(std::sync::Arc); + +impl Drop for PendingWsGuard { + fn drop(&mut self) { + self.0 + .fetch_sub(1, std::sync::atomic::Ordering::Relaxed); + } +} + #[derive(Deserialize)] pub struct WebSocketPathParams { #[serde(deserialize_with = "normalize")] @@ -39,13 +55,31 @@ pub async fn websocket_handler( Path(WebSocketPathParams { vault_id }): Path, State(state): State, ) -> Result { - Ok(ws.on_upgrade(move |socket| websocket_wrapped(state, socket, vault_id))) + let current = state + .pending_ws_connections + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if current >= state.config.server.max_pending_websocket_connections { + state + .pending_ws_connections + .fetch_sub(1, std::sync::atomic::Ordering::Relaxed); + return Err(client_error(anyhow::anyhow!( + "Too many pending WebSocket connections" + ))); + } + + let guard = PendingWsGuard(state.pending_ws_connections.clone()); + Ok(ws.on_upgrade(move |socket| websocket_wrapped(state, socket, vault_id, guard))) } -async fn websocket_wrapped(state: AppState, stream: WebSocket, vault_id: VaultId) { +async fn websocket_wrapped( + state: AppState, + stream: WebSocket, + vault_id: VaultId, + pending_guard: PendingWsGuard, +) { info!("WebSocket connection opened on vault `{vault_id}`"); - let result = websocket(state, stream, vault_id.clone()).await; + let result = websocket(state, stream, vault_id.clone(), pending_guard).await; if let Err(err) = result { debug!("WebSocket connection error on vault `{vault_id}`: {err}"); @@ -57,25 +91,53 @@ async fn websocket( state: AppState, stream: WebSocket, vault_id: VaultId, + pending_guard: PendingWsGuard, ) -> Result<(), SyncServerError> { let (mut sender, mut websocket_receiver) = stream.split(); - let authed_handshake = get_authenticated_handshake( - &state, - &vault_id, - websocket_receiver - .next() - .await - .transpose() - .unwrap_or_default(), - )?; + let handshake_msg = tokio::time::timeout(HANDSHAKE_TIMEOUT, websocket_receiver.next()) + .await + .map_err(|_| client_error(anyhow::anyhow!("WebSocket handshake timed out")))? + .transpose() + .map_err(|e| client_error(anyhow::anyhow!("WebSocket error during handshake: {e}")))?; + + let authed_handshake = get_authenticated_handshake(&state, &vault_id, handshake_msg)?; info!( "WebSocket handshake successful for vault `{vault_id}` for `{}`", authed_handshake.handshake.device_id ); - let mut broadcast_receiver = state.broadcasts.get_receiver(vault_id.clone()).await; + // Auth complete — no longer a pending connection. + drop(pending_guard); + + let max_clients = state.config.server.max_clients_per_vault; + let mut broadcast_receiver = match state + .broadcasts + .get_receiver(vault_id.clone(), max_clients) + .await + { + Ok(receiver) => receiver, + Err(err) => { + warn!( + "Vault `{vault_id}` has reached the maximum number of clients ({max_clients}), rejecting connection from `{}`", + authed_handshake.handshake.device_id + ); + if let Err(e) = sender + .send(Message::Close(Some(axum::extract::ws::CloseFrame { + code: 4000, + reason: format!( + "Vault has reached the maximum number of clients ({max_clients})" + ) + .into(), + }))) + .await + { + warn!("Failed to send WebSocket close frame: {e}"); + } + return Err(err); + } + }; send_update_over_websocket( &WebSocketServerMessage::VaultUpdate(WebSocketVaultUpdate { @@ -101,24 +163,35 @@ async fn websocket( let device_id = authed_handshake.handshake.device_id.clone(); let mut send_task = tokio::spawn(async move { - while let Ok(update) = broadcast_receiver.recv().await { - if Some(&device_id) == update.origin_device_id.as_ref() { - continue; - } + loop { + match broadcast_receiver.recv().await { + Ok(update) => { + if Some(&device_id) == update.origin_device_id.as_ref() { + continue; + } - let message = match update.message { - WebSocketServerMessage::CursorPositions(CursorPositionFromServer { clients }) => { - WebSocketServerMessage::CursorPositions(CursorPositionFromServer { - clients: clients - .into_iter() - .filter(|client| client.device_id != device_id) - .collect(), - }) + let message = match update.message { + WebSocketServerMessage::CursorPositions(CursorPositionFromServer { + clients, + }) => WebSocketServerMessage::CursorPositions(CursorPositionFromServer { + clients: clients + .into_iter() + .filter(|client| client.device_id != device_id) + .collect(), + }), + WebSocketServerMessage::VaultUpdate(_) => update.message, + }; + + send_update_over_websocket(&message, &mut sender).await?; } - WebSocketServerMessage::VaultUpdate(_) => update.message, - }; - - send_update_over_websocket(&message, &mut sender).await?; + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + warn!( + "WebSocket receiver lagged, dropped {n} messages — disconnecting client to force full resync" + ); + break; + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } } Ok::<(), SyncServerError>(()) @@ -128,26 +201,57 @@ async fn websocket( let vault_id_clone = vault_id.clone(); let cursor_manager = state.cursors.clone(); let mut receive_task = tokio::spawn(async move { - while let Some(Ok(Message::Text(message))) = websocket_receiver.next().await { - let message: WebSocketClientMessage = serde_json::from_str(&message) - .context("Failed to parse WebSocket message from client") - .map_err(server_error)?; + while let Some(msg) = websocket_receiver.next().await { + match msg { + Ok(Message::Text(message)) => { + let message: WebSocketClientMessage = serde_json::from_str(&message) + .context("Failed to parse WebSocket message from client") + .map_err(client_error)?; - match message { - WebSocketClientMessage::Handshake(_) => { - return Err(client_error(anyhow::anyhow!( - "Unexpected handshake message" - ))); + match message { + WebSocketClientMessage::Handshake(_) => { + return Err(client_error(anyhow::anyhow!( + "Unexpected handshake message" + ))); + } + WebSocketClientMessage::CursorPositions(cursors) => { + let docs = cursors.documents_with_cursors; + if docs.len() > MAX_CURSOR_DOCUMENTS { + warn!( + "Cursor update rejected: {} documents exceeds limit of {MAX_CURSOR_DOCUMENTS}", + docs.len() + ); + continue; + } + + let valid = docs.iter().all(|doc| { + doc.cursors.len() <= MAX_CURSORS_PER_DOCUMENT + && doc.relative_path.len() <= MAX_RELATIVE_PATH_LEN + }); + if !valid { + warn!("Cursor update rejected: a document exceeds cursor or path length limits"); + continue; + } + + cursor_manager + .update_cursors( + vault_id_clone.clone(), + authed_handshake.user.name.clone(), + &device_id, + docs, + ) + .await; + } + } } - WebSocketClientMessage::CursorPositions(cursors) => { - cursor_manager - .update_cursors( - vault_id_clone.clone(), - authed_handshake.user.name.clone(), - &device_id, - cursors.documents_with_cursors, - ) - .await; + Ok(Message::Close(_)) => break, + Ok(Message::Binary(_)) => { + warn!("Received unexpected binary WebSocket message, ignoring"); + } + Ok(_) => {} // Ping/Pong frames handled by axum + Err(e) => { + debug!("WebSocket receive error: {e}"); + break; } } } @@ -155,38 +259,47 @@ async fn websocket( Ok::<(), SyncServerError>(()) }); - tokio::select! { - _ = &mut send_task => receive_task.abort(), - _ = &mut receive_task => send_task.abort(), + let result: Result<(), SyncServerError> = tokio::select! { + send_result = &mut send_task => { + receive_task.abort(); + let _ = receive_task.await; + match send_result { + Err(e) => Err(server_error( + anyhow::Error::from(e).context("WebSocket send task failed"), + )), + Ok(inner) => inner, + } + }, + receive_result = &mut receive_task => { + send_task.abort(); + let _ = send_task.await; + match receive_result { + Err(e) => Err(server_error( + anyhow::Error::from(e).context("WebSocket receive task failed"), + )), + Ok(inner) => inner, + } + }, }; - let result: Result<(), SyncServerError> = (async { - send_task - .await - .context("WebSocket send task failed") - .map_err(client_error) - .and_then(|err| err)?; - - receive_task - .await - .context("WebSocket receive task failed") - .map_err(client_error) - .and_then(|err| err)?; - - Ok(()) - }) - .await; - state .cursors .remove_cursors_of_device(&vault_id, &authed_handshake.handshake.device_id) .await; - if result.is_err() { - info!( - "WebSocket disconnected on vault `{vault_id}` for `{}`", - authed_handshake.handshake.device_id - ); + match &result { + Ok(()) => { + info!( + "WebSocket disconnected on vault `{vault_id}` for `{}`", + authed_handshake.handshake.device_id + ); + } + Err(err) => { + warn!( + "WebSocket error on vault `{vault_id}` for `{}`: {err}", + authed_handshake.handshake.device_id + ); + } } result diff --git a/sync-server/src/utils/dedup_paths.rs b/sync-server/src/utils/dedup_paths.rs index bc687f6a..0baf8ba8 100644 --- a/sync-server/src/utils/dedup_paths.rs +++ b/sync-server/src/utils/dedup_paths.rs @@ -1,8 +1,17 @@ +use std::sync::LazyLock; + use regex::Regex; +static DEDUP_SUFFIX_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r" \((\d+)\)$").expect("invalid regex")); + pub fn dedup_paths(path: &str) -> impl Iterator { let mut path_parts = path.split('/').collect::>(); - let file_name = path_parts.pop().unwrap().to_owned(); + let file_name = path_parts + .pop() + .filter(|s| !s.is_empty()) + .unwrap_or(path) + .to_owned(); let mut directory = path_parts.join("/"); if !directory.is_empty() { @@ -29,14 +38,13 @@ pub fn dedup_paths(path: &str) -> impl Iterator { } }; - let regex = Regex::new(r" \((\d+)\)$").unwrap(); - let start_number = regex + let start_number = DEDUP_SUFFIX_REGEX .captures(&stem) .and_then(|caps| caps.get(1)) .and_then(|m| m.as_str().parse::().ok()) .unwrap_or(0); - let clean_stem = regex.replace(&stem, "").to_string(); + let clean_stem = DEDUP_SUFFIX_REGEX.replace(&stem, "").to_string(); (start_number..).map(move |dedup_number| { if dedup_number == 0 { diff --git a/sync-server/src/utils/find_first_available_path.rs b/sync-server/src/utils/find_first_available_path.rs index d80564b0..caaa1624 100644 --- a/sync-server/src/utils/find_first_available_path.rs +++ b/sync-server/src/utils/find_first_available_path.rs @@ -1,7 +1,7 @@ use crate::app_state::database::models::VaultId; use crate::utils::dedup_paths::dedup_paths; -use anyhow::{Result, bail}; -use log::info; +use anyhow::Result; +use log::{debug, info}; use sqlx::sqlite::SqliteConnection; diff --git a/sync-server/src/utils/sanitize_path.rs b/sync-server/src/utils/sanitize_path.rs index 9703225c..e8a2a335 100644 --- a/sync-server/src/utils/sanitize_path.rs +++ b/sync-server/src/utils/sanitize_path.rs @@ -1,14 +1,17 @@ +use anyhow::{Result, ensure}; + /// Sanitize the document's path to allow all clients to create the same path in /// their filesystem. If we didn't do this server-side, client's would need to /// deal with mapping invalid names to valid ones and then back. -pub fn sanitize_path(path: &str) -> String { +pub fn sanitize_path(path: &str) -> Result { let options = sanitize_filename::Options { truncate: true, windows: true, // Windows is the lowest common denominator replacement: "", }; - path.split('/') + let result = path + .split('/') .map(|part| { let proposal = sanitize_filename::sanitize_with_options(part, options.clone()); if !part.is_empty() && proposal.is_empty() { @@ -18,7 +21,10 @@ pub fn sanitize_path(path: &str) -> String { } }) .collect::>() - .join("/") + .join("/"); + + ensure!(!result.is_empty(), "Relative path is empty after sanitization"); + Ok(result) } #[cfg(test)] @@ -27,8 +33,32 @@ mod test { #[test] fn test_sanitize_path() { - assert_eq!(sanitize_path("/my/path/what?"), "/my/path/what"); - assert_eq!(sanitize_path("file (1).md"), "file (1).md"); - assert_eq!(sanitize_path("/my/path/\\\\:?"), "/my/path/_"); + assert_eq!(sanitize_path("/my/path/what?").unwrap(), "/my/path/what"); + assert_eq!(sanitize_path("file (1).md").unwrap(), "file (1).md"); + assert_eq!(sanitize_path("/my/path/\\\\:?").unwrap(), "/my/path/_"); + } + + #[test] + fn test_sanitize_path_empty() { + assert!(sanitize_path("").is_err()); + } + + #[test] + fn test_sanitize_path_idempotent_simple() { + let mut result = sanitize_path("notes/my file.md").unwrap(); + for _ in 0..5 { + result = sanitize_path(&result).unwrap(); + } + assert_eq!(result, "notes/my file.md"); + } + + #[test] + fn test_sanitize_path_idempotent_special_chars() { + let first = sanitize_path("/my/path/what?/file:name<>.md").unwrap(); + let mut result = first.clone(); + for _ in 0..5 { + result = sanitize_path(&result).unwrap(); + } + assert_eq!(result, first); } } From e0f2286a3c7165eff118c6fd6b6fb4f601777035 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 28 Mar 2026 10:31:25 +0000 Subject: [PATCH 06/10] Migrate to forgejo --- .forgejo/workflows/check.yml | 35 ++++++++++ .forgejo/workflows/deploy-docs.yml | 38 +++++++++++ .forgejo/workflows/e2e.yml | 71 ++++++++++++++++++++ .forgejo/workflows/publish-cli-docker.yml | 51 ++++++++++++++ .forgejo/workflows/publish-plugin.yml | 71 ++++++++++++++++++++ .forgejo/workflows/publish-server-docker.yml | 51 ++++++++++++++ 6 files changed, 317 insertions(+) create mode 100644 .forgejo/workflows/check.yml create mode 100644 .forgejo/workflows/deploy-docs.yml create mode 100644 .forgejo/workflows/e2e.yml create mode 100644 .forgejo/workflows/publish-cli-docker.yml create mode 100644 .forgejo/workflows/publish-plugin.yml create mode 100644 .forgejo/workflows/publish-server-docker.yml diff --git a/.forgejo/workflows/check.yml b/.forgejo/workflows/check.yml new file mode 100644 index 00000000..40e01dea --- /dev/null +++ b/.forgejo/workflows/check.yml @@ -0,0 +1,35 @@ +name: Check + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: "-Dwarnings" + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js environment + uses: actions/setup-node@v4 + with: + node-version: "25.x" + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.92.0" + components: clippy, rustfmt + + - name: Lint & test + run: scripts/check.sh diff --git a/.forgejo/workflows/deploy-docs.yml b/.forgejo/workflows/deploy-docs.yml new file mode 100644 index 00000000..c49d0379 --- /dev/null +++ b/.forgejo/workflows/deploy-docs.yml @@ -0,0 +1,38 @@ +name: Deploy Documentation + +on: + push: + branches: + - main + paths: + - "docs/**" + - ".forgejo/workflows/deploy-docs.yml" + workflow_dispatch: + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js environment + uses: actions/setup-node@v4 + with: + node-version: "25.x" + + - name: Build docs + run: scripts/build-docs.sh + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: docs + path: docs/.vitepress/dist diff --git a/.forgejo/workflows/e2e.yml b/.forgejo/workflows/e2e.yml new file mode 100644 index 00000000..eb8d1e54 --- /dev/null +++ b/.forgejo/workflows/e2e.yml @@ -0,0 +1,71 @@ +name: E2E tests + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + schedule: + - cron: "0 * * * *" + workflow_dispatch: + +concurrency: + group: e2e-tests + cancel-in-progress: false + +env: + RUSTFLAGS: "-Dwarnings" + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js environment + uses: actions/setup-node@v4 + with: + node-version: "25.x" + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.92.0" + components: clippy, rustfmt + + - name: Setup rust + run: | + which sqlx || cargo install sqlx-cli + cd sync-server + sqlx database create --database-url sqlite://db.sqlite3 + sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3 + + - name: E2E tests + run: | + cd sync-server + cargo run config-e2e.yml --color never & + SERVER_PID=$! + cd .. + + scripts/e2e.sh 8 + EXIT_CODE=$? + + kill $SERVER_PID 2>/dev/null || true + wait $SERVER_PID 2>/dev/null || true + + exit $EXIT_CODE + + - name: Upload e2e logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-logs + path: logs/ + retention-days: 30 + + - name: Cleanup + if: always() + run: scripts/clean-up.sh diff --git a/.forgejo/workflows/publish-cli-docker.yml b/.forgejo/workflows/publish-cli-docker.yml new file mode 100644 index 00000000..265283ab --- /dev/null +++ b/.forgejo/workflows/publish-cli-docker.yml @@ -0,0 +1,51 @@ +name: Publish CLI + +on: + push: + branches: ["main"] + tags: ["*"] + pull_request: + branches: ["main"] + +jobs: + publish-docker: + runs-on: ubuntu-docker + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Extract registry hostname + id: registry + run: echo "host=$(echo '${{ github.server_url }}' | sed 's|https\?://||')" >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log into container registry + uses: docker/login-action@v3 + with: + registry: ${{ steps.registry.outputs.host }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ steps.registry.outputs.host }}/${{ github.repository }}-cli + + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@v5 + with: + context: frontend + file: frontend/local-client-cli/Dockerfile + platforms: linux/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=registry,ref=${{ steps.registry.outputs.host }}/${{ github.repository }}-cli:buildcache + cache-to: type=registry,ref=${{ steps.registry.outputs.host }}/${{ github.repository }}-cli:buildcache,mode=max diff --git a/.forgejo/workflows/publish-plugin.yml b/.forgejo/workflows/publish-plugin.yml new file mode 100644 index 00000000..25a652aa --- /dev/null +++ b/.forgejo/workflows/publish-plugin.yml @@ -0,0 +1,71 @@ +name: Publish Obsidian plugin + +on: + push: + tags: ["*"] + +env: + CARGO_TERM_COLOR: always + +jobs: + publish-plugin: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js environment + uses: actions/setup-node@v4 + with: + node-version: "25.x" + + - name: Build plugin + run: | + cd frontend + npm ci + npm run build + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.92.0" + components: clippy, rustfmt + + - name: Install cross-compilation tools + run: | + apt update + apt install -y gcc-aarch64-linux-gnu musl-tools gcc-mingw-w64-x86-64 jq + + - name: Build Linux and Windows binaries + run: ./scripts/build-sync-server-binaries.sh + + - name: Create release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SERVER_URL: ${{ github.server_url }} + REPO: ${{ github.repository }} + run: | + tag="${GITHUB_REF#refs/tags/}" + + mkdir -p release + cp frontend/obsidian-plugin/dist/* release/ + cp sync-server/artifacts/sync-server-* release/ + + # Create draft release via Forgejo API + RELEASE_ID=$(curl -s -X POST \ + "${SERVER_URL}/api/v1/repos/${REPO}/releases" \ + -H "Authorization: token ${GITHUB_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\": \"${tag}\", \"name\": \"${tag}\", \"draft\": true}" \ + | jq -r '.id') + + # Upload release assets + for file in release/*; do + filename=$(basename "$file") + curl -s -X POST \ + "${SERVER_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}" \ + -H "Authorization: token ${GITHUB_TOKEN}" \ + -F "attachment=@${file}" + done diff --git a/.forgejo/workflows/publish-server-docker.yml b/.forgejo/workflows/publish-server-docker.yml new file mode 100644 index 00000000..23852e56 --- /dev/null +++ b/.forgejo/workflows/publish-server-docker.yml @@ -0,0 +1,51 @@ +name: Publish server Docker image + +on: + push: + branches: ["main"] + tags: ["*"] + pull_request: + branches: ["main"] + +jobs: + publish-docker: + runs-on: ubuntu-docker + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Extract registry hostname + id: registry + run: echo "host=$(echo '${{ github.server_url }}' | sed 's|https\?://||')" >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log into container registry + if: github.ref_type == 'tag' + uses: docker/login-action@v3 + with: + registry: ${{ steps.registry.outputs.host }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ steps.registry.outputs.host }}/${{ github.repository }} + + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@v5 + with: + context: sync-server + platforms: linux/amd64,linux/arm64 + push: ${{ github.ref_type == 'tag' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=registry,ref=${{ steps.registry.outputs.host }}/${{ github.repository }}:buildcache + cache-to: type=registry,ref=${{ steps.registry.outputs.host }}/${{ github.repository }}:buildcache,mode=max From b83031e3e6eaf5f40bc9b694a05ed0059da2c584 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 28 Mar 2026 10:50:37 +0000 Subject: [PATCH 07/10] Support env vars, line endings, add glob ignore patterns, clean up deps --- frontend/local-client-cli/Dockerfile | 4 +- frontend/local-client-cli/README.md | 50 ++-- frontend/local-client-cli/package.json | 16 +- frontend/local-client-cli/src/args.test.ts | 226 +++++++++++++++++- frontend/local-client-cli/src/args.ts | 189 +++++++++++---- frontend/local-client-cli/src/cli.ts | 178 ++++++++------ frontend/local-client-cli/src/file-watcher.ts | 31 +-- frontend/local-client-cli/src/healthcheck.ts | 1 + .../src/logger-formatter.test.ts | 50 ++++ .../local-client-cli/src/logger-formatter.ts | 19 +- .../local-client-cli/src/node-filesystem.ts | 65 +++-- .../local-client-cli/src/path-utils.test.ts | 60 +++++ frontend/local-client-cli/src/path-utils.ts | 18 ++ 13 files changed, 683 insertions(+), 224 deletions(-) create mode 100644 frontend/local-client-cli/src/logger-formatter.test.ts create mode 100644 frontend/local-client-cli/src/path-utils.test.ts create mode 100644 frontend/local-client-cli/src/path-utils.ts diff --git a/frontend/local-client-cli/Dockerfile b/frontend/local-client-cli/Dockerfile index 695ab587..0dfa7055 100644 --- a/frontend/local-client-cli/Dockerfile +++ b/frontend/local-client-cli/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-slim AS builder +FROM node:25-slim AS builder WORKDIR /build @@ -7,7 +7,7 @@ COPY . . RUN npm ci RUN npm run build -FROM node:22-alpine +FROM node:25-alpine LABEL org.opencontainers.image.title="VaultLink Local CLI" LABEL org.opencontainers.image.description="Standalone CLI for VaultLink sync client" diff --git a/frontend/local-client-cli/README.md b/frontend/local-client-cli/README.md index 0585bacc..e91322f9 100644 --- a/frontend/local-client-cli/README.md +++ b/frontend/local-client-cli/README.md @@ -47,24 +47,25 @@ vaultlink \ ### Required -| Option | Description | -|--------|-------------| -| `-l, --local-path ` | Local directory to sync | -| `-r, --remote-uri ` | Remote server WebSocket URI (ws:// or wss://) | -| `-t, --token ` | Authentication token | -| `-v, --vault-name ` | Vault name on server | +| Option | Description | +| ------------------------- | --------------------------------------------- | +| `-l, --local-path ` | Local directory to sync | +| `-r, --remote-uri ` | Remote server WebSocket URI (ws:// or wss://) | +| `-t, --token ` | Authentication token | +| `-v, --vault-name ` | Vault name on server | ### Optional -| Option | Default | Description | -|--------|---------|-------------| -| `--sync-concurrency ` | `1` | Concurrent sync operations | -| `--max-file-size-mb ` | `10` | Maximum file size in MB | -| `--ignore-pattern ` | - | Glob pattern to ignore (repeatable) | -| `--websocket-retry-interval-ms ` | `3500` | WebSocket reconnection interval | -| `--log-level ` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR | -| `-h, --help` | - | Show help | -| `-V, --version` | - | Show version | +| Option | Default | Description | +| ------------------------------------ | ------- | ----------------------------------------------- | +| `--max-file-size-mb ` | `10` | Maximum file size in MB | +| `--ignore-pattern ` | - | Glob pattern to ignore (repeatable) | +| `--websocket-retry-interval-ms ` | `3500` | WebSocket reconnection interval | +| `--log-level ` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR | +| `--line-endings ` | `auto` | Line ending style: auto, lf, crlf | +| `-q, --quiet` | - | Suppress startup banner for non-interactive use | +| `-h, --help` | - | Show help | +| `-V, --version` | - | Show version | ### Auto-Ignored Patterns @@ -74,22 +75,32 @@ vaultlink \ ### Examples Basic usage: + ```bash vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default ``` With ignore patterns: + ```bash vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \ - --ignore-pattern "*.tmp" \ + --ignore-pattern "**/*.tmp" \ --ignore-pattern ".DS_Store" \ --ignore-pattern "node_modules/**" ``` -With debug logging: +With debug logging and quiet startup: + ```bash vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \ - --log-level DEBUG + --log-level DEBUG --quiet +``` + +Force LF line endings (useful for cross-platform vaults): + +```bash +vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \ + --line-endings lf ``` ## Docker Deployment @@ -176,6 +187,7 @@ services: ## Development Build: + ```bash npm run build # or from the parent folder, run @@ -183,11 +195,13 @@ docker build -f local-client-cli/Dockerfile . ``` Test: + ```bash npm test ``` Docker build: + ```bash cd frontend docker build -f local-client-cli/Dockerfile -t vault-link-cli:test . diff --git a/frontend/local-client-cli/package.json b/frontend/local-client-cli/package.json index cade4990..a862b297 100644 --- a/frontend/local-client-cli/package.json +++ b/frontend/local-client-cli/package.json @@ -11,18 +11,16 @@ "build": "webpack --mode production", "test": "tsx --test 'src/**/*.test.ts'" }, - "dependencies": { - "commander": "^14.0.2", - "watcher": "^2.3.1" - }, "devDependencies": { - "@types/node": "^24.8.1", + "commander": "^14.0.2", + "watcher": "^2.3.1", + "@types/node": "^25.0.2", "sync-client": "file:../sync-client", - "ts-loader": "^9.5.2", + "ts-loader": "^9.5.4", "tslib": "2.8.1", - "tsx": "^4.20.6", - "typescript": "5.8.3", - "webpack": "^5.99.9", + "tsx": "^4.21.0", + "typescript": "5.9.3", + "webpack": "^5.103.0", "webpack-cli": "^6.0.1" } } diff --git a/frontend/local-client-cli/src/args.test.ts b/frontend/local-client-cli/src/args.test.ts index eb195538..c075d193 100644 --- a/frontend/local-client-cli/src/args.test.ts +++ b/frontend/local-client-cli/src/args.test.ts @@ -55,13 +55,10 @@ test("parseArgs - parse with optional arguments", () => { "mytoken", "-v", "default", - "--sync-concurrency", - "5", "--max-file-size-mb", "20" ]); - assert.equal(args.syncConcurrency, 5); assert.equal(args.maxFileSizeMB, 20); }); @@ -228,3 +225,226 @@ test("parseArgs - throws on invalid log level", () => { ]); }, /Invalid log level/); }); + +test("parseArgs - reads required options from environment variables", () => { + process.env.VAULTLINK_LOCAL_PATH = "/env/path"; + process.env.VAULTLINK_REMOTE_URI = "https://env.example.com"; + process.env.VAULTLINK_TOKEN = "env-token"; + process.env.VAULTLINK_VAULT_NAME = "env-vault"; + + try { + const args = parseArgs(["node", "cli.js"]); + assert.equal(args.localPath, "/env/path"); + assert.equal(args.remoteUri, "https://env.example.com"); + assert.equal(args.token, "env-token"); + assert.equal(args.vaultName, "env-vault"); + } finally { + delete process.env.VAULTLINK_LOCAL_PATH; + delete process.env.VAULTLINK_REMOTE_URI; + delete process.env.VAULTLINK_TOKEN; + delete process.env.VAULTLINK_VAULT_NAME; + } +}); + +test("parseArgs - CLI arguments take precedence over environment variables", () => { + process.env.VAULTLINK_TOKEN = "env-token"; + + try { + const args = parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "https://sync.example.com", + "-t", + "cli-token", + "-v", + "default" + ]); + assert.equal(args.token, "cli-token"); + } finally { + delete process.env.VAULTLINK_TOKEN; + } +}); + +test("parseArgs - reads log level from environment variable", () => { + process.env.VAULTLINK_LOG_LEVEL = "DEBUG"; + + try { + const args = parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "https://sync.example.com", + "-t", + "mytoken", + "-v", + "default" + ]); + assert.equal(args.logLevel, LogLevel.DEBUG); + } finally { + delete process.env.VAULTLINK_LOG_LEVEL; + } +}); + +test("parseArgs - quiet defaults to false", () => { + const args = parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "https://sync.example.com", + "-t", + "mytoken", + "-v", + "default" + ]); + + assert.equal(args.quiet, false); +}); + +test("parseArgs - parse --quiet flag", () => { + const args = parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "https://sync.example.com", + "-t", + "mytoken", + "-v", + "default", + "--quiet" + ]); + + assert.equal(args.quiet, true); +}); + +test("parseArgs - parse -q short flag", () => { + const args = parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "https://sync.example.com", + "-t", + "mytoken", + "-v", + "default", + "-q" + ]); + + assert.equal(args.quiet, true); +}); + +test("parseArgs - line-endings defaults to auto", () => { + const args = parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "https://sync.example.com", + "-t", + "mytoken", + "-v", + "default" + ]); + + assert.equal(args.lineEndings, "auto"); +}); + +test("parseArgs - parse --line-endings lf", () => { + const args = parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "https://sync.example.com", + "-t", + "mytoken", + "-v", + "default", + "--line-endings", + "lf" + ]); + + assert.equal(args.lineEndings, "lf"); +}); + +test("parseArgs - parse --line-endings crlf", () => { + const args = parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "https://sync.example.com", + "-t", + "mytoken", + "-v", + "default", + "--line-endings", + "crlf" + ]); + + assert.equal(args.lineEndings, "crlf"); +}); + +test("parseArgs - throws on invalid remote URI protocol", () => { + assert.throws(() => { + parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "ftp://sync.example.com", + "-t", + "mytoken", + "-v", + "default" + ]); + }, /Invalid remote URI/); +}); + +test("parseArgs - accepts http:// remote URI", () => { + const args = parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "http://localhost:3000", + "-t", + "mytoken", + "-v", + "default" + ]); + + assert.equal(args.remoteUri, "http://localhost:3000"); +}); + +test("parseArgs - accepts wss:// remote URI", () => { + const args = parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "wss://sync.example.com", + "-t", + "mytoken", + "-v", + "default" + ]); + + assert.equal(args.remoteUri, "wss://sync.example.com"); +}); diff --git a/frontend/local-client-cli/src/args.ts b/frontend/local-client-cli/src/args.ts index 615b9d71..5124b72f 100644 --- a/frontend/local-client-cli/src/args.ts +++ b/frontend/local-client-cli/src/args.ts @@ -1,21 +1,26 @@ -import { Command } from "commander"; +import { Command, Option } from "commander"; import packageJson from "../package.json"; import { LogLevel } from "sync-client"; +export type LineEndingMode = "auto" | "lf" | "crlf"; + export interface CliArgs { remoteUri: string; token: string; vaultName: string; localPath: string; - syncConcurrency?: number; maxFileSizeMB?: number; ignorePatterns?: string[]; webSocketRetryIntervalMs?: number; logLevel: LogLevel; health?: string; enableTelemetry?: boolean; + quiet: boolean; + lineEndings: LineEndingMode; } +const VALID_PROTOCOLS = ["http://", "https://", "ws://", "wss://"]; + export function parseArgs(argv: string[]): CliArgs { const program = new Command(); @@ -25,41 +30,86 @@ export function parseArgs(argv: string[]): CliArgs { "VaultLink Local CLI - Sync your vault to the local filesystem" ) .version(packageJson.version) - .option("-l, --local-path ", "Local directory path to sync") - .option("-r, --remote-uri ", "Remote server URI") - .option("-t, --token ", "Authentication token") - .option("-v, --vault-name ", "Vault name") - .option( - "--sync-concurrency ", - "[OPTIONAL] Number of concurrent sync operations", - parseInt + .addOption( + new Option( + "-l, --local-path ", + "Local directory path to sync" + ).env("VAULTLINK_LOCAL_PATH") ) - .option( - "--max-file-size-mb ", - "[OPTIONAL] Maximum file size in MB", - parseInt + .addOption( + new Option( + "-r, --remote-uri ", + "Remote server URI" + ).env("VAULTLINK_REMOTE_URI") ) - .option( - "--ignore-pattern ", - "[OPTIONAL] Patterns to ignore (can be specified multiple times)" + .addOption( + new Option( + "-t, --token ", + "Authentication token" + ).env("VAULTLINK_TOKEN") ) - .option( - "--websocket-retry-interval-ms ", - "[OPTIONAL] WebSocket retry interval in milliseconds", - parseInt + .addOption( + new Option( + "-v, --vault-name ", + "Vault name" + ).env("VAULTLINK_VAULT_NAME") ) - .option( - "--log-level ", - "[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)", - "INFO" + .addOption( + new Option( + "--max-file-size-mb ", + "[OPTIONAL] Maximum file size in MB" + ) + .argParser(parseInt) + .env("VAULTLINK_MAX_FILE_SIZE_MB") ) - .option( - "--health ", - "[OPTIONAL] Path to health status file for Docker healthcheck" + .addOption( + new Option( + "--ignore-pattern ", + "[OPTIONAL] Patterns to ignore (can be specified multiple times)" + ).env("VAULTLINK_IGNORE_PATTERNS") ) - .option( - "--enable-telemetry", - "[OPTIONAL] Enable telemetry (disabled by default)" + .addOption( + new Option( + "--websocket-retry-interval-ms ", + "[OPTIONAL] WebSocket retry interval in milliseconds" + ) + .argParser(parseInt) + .env("VAULTLINK_WEBSOCKET_RETRY_INTERVAL_MS") + ) + .addOption( + new Option( + "--log-level ", + "[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)" + ) + .default("INFO") + .env("VAULTLINK_LOG_LEVEL") + ) + .addOption( + new Option( + "--health ", + "[OPTIONAL] Path to health status file for Docker healthcheck" + ).env("VAULTLINK_HEALTH") + ) + .addOption( + new Option( + "--enable-telemetry", + "[OPTIONAL] Enable telemetry (disabled by default)" + ).env("VAULTLINK_ENABLE_TELEMETRY") + ) + .addOption( + new Option( + "-q, --quiet", + "[OPTIONAL] Suppress startup banner for non-interactive use" + ).env("VAULTLINK_QUIET") + ) + .addOption( + new Option( + "--line-endings ", + "[OPTIONAL] Line ending style: auto (platform default), lf, crlf" + ) + .default("auto") + .choices(["auto", "lf", "crlf"]) + .env("VAULTLINK_LINE_ENDINGS") ) .addHelpText( "after", @@ -67,9 +117,13 @@ export function parseArgs(argv: string[]): CliArgs { Examples: $ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default $ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\ - --ignore-pattern ".git/**" --ignore-pattern "*.tmp" + --ignore-pattern ".git/**" --ignore-pattern "**/*.tmp" $ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\ - --log-level DEBUG + --log-level DEBUG --quiet + +Environment variables: + All options can be configured via VAULTLINK_ prefixed environment variables. + CLI arguments take precedence over environment variables. ` ); @@ -81,7 +135,6 @@ Examples: const remoteUri = opts.remoteUri as string | undefined; const token = opts.token as string | undefined; const vaultName = opts.vaultName as string | undefined; - const syncConcurrency = opts.syncConcurrency as number | undefined; const maxFileSizeMb = opts.maxFileSizeMb as number | undefined; const ignorePattern = opts.ignorePattern as string[] | undefined; const websocketRetryIntervalMs = opts.websocketRetryIntervalMs as @@ -90,22 +143,44 @@ Examples: const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO"; const health = opts.health as string | undefined; const enableTelemetry = opts.enableTelemetry as boolean | undefined; + const quiet = (opts.quiet as boolean | undefined) ?? false; + const lineEndingsStr = (opts.lineEndings as string | undefined) ?? "auto"; /* eslint-enable @typescript-eslint/no-unsafe-type-assertion */ - if (localPath === undefined) { + const requireOption = ( + value: T | undefined, + name: string + ): T => { + if (value === undefined) { + const option = program.options.find( + (o) => o.attributeName() === name + ); + const envHint = + option?.envVar !== undefined + ? ` (or set ${option.envVar})` + : ""; + throw new Error( + `required option '${option?.flags ?? name}' not specified${envHint}` + ); + } + return value; + }; + + const requiredLocalPath = requireOption(localPath, "localPath"); + const requiredRemoteUri = requireOption(remoteUri, "remoteUri"); + const requiredToken = requireOption(token, "token"); + const requiredVaultName = requireOption(vaultName, "vaultName"); + + // Validate remote URI protocol + if ( + !VALID_PROTOCOLS.some((prefix) => + requiredRemoteUri.startsWith(prefix) + ) + ) { throw new Error( - "required option '-l, --local-path ' not specified" + `Invalid remote URI '${requiredRemoteUri}'. Must start with ${VALID_PROTOCOLS.join(", ")}` ); } - if (remoteUri === undefined) { - throw new Error("required option '--remote-uri ' not specified"); - } - if (token === undefined) { - throw new Error("required option '--token ' not specified"); - } - if (vaultName === undefined) { - throw new Error("required option '--vault-name ' not specified"); - } // Validate and parse log level const logLevelUpper = logLevelStr.toUpperCase(); @@ -120,17 +195,29 @@ Examples: } const logLevel = logLevelUpper; + const validLineEndings: readonly string[] = ["auto", "lf", "crlf"]; + const isLineEndingMode = (value: string): value is LineEndingMode => { + return validLineEndings.includes(value); + }; + if (!isLineEndingMode(lineEndingsStr)) { + throw new Error( + `Invalid line endings mode '${lineEndingsStr}'. Valid values are: ${validLineEndings.join(", ")}` + ); + } + const lineEndings = lineEndingsStr; + return { - localPath, - remoteUri, - token, - vaultName, - syncConcurrency, + localPath: requiredLocalPath, + remoteUri: requiredRemoteUri, + token: requiredToken, + vaultName: requiredVaultName, maxFileSizeMB: maxFileSizeMb, ignorePatterns: ignorePattern, webSocketRetryIntervalMs: websocketRetryIntervalMs, logLevel, health, - enableTelemetry + enableTelemetry, + quiet, + lineEndings }; } diff --git a/frontend/local-client-cli/src/cli.ts b/frontend/local-client-cli/src/cli.ts index 48fd8954..1a8b1e83 100644 --- a/frontend/local-client-cli/src/cli.ts +++ b/frontend/local-client-cli/src/cli.ts @@ -5,24 +5,27 @@ import type { NetworkConnectionStatus } from "sync-client"; import { SyncClient, DEFAULT_SETTINGS, + Logger, LogLevel, + type LogLine, type SyncSettings, type StoredDatabase } from "sync-client"; import { parseArgs } from "./args"; import { NodeFileSystemOperations } from "./node-filesystem"; import { FileWatcher } from "./file-watcher"; -import { formatLogLine, colorize, styleText } from "./logger-formatter"; +import { formatLogLine } from "./logger-formatter"; import packageJson from "../package.json"; function writeHealthStatus( + logger: Logger, filePath: string, connectionStatus: NetworkConnectionStatus ): void { try { fsSync.writeFileSync(filePath, JSON.stringify(connectionStatus)); } catch (error) { - console.error( + logger.error( `Failed to write health status to ${filePath}: ${error instanceof Error ? error.message : String(error)}` ); } @@ -35,12 +38,39 @@ const LOG_LEVEL_ORDER = { [LogLevel.ERROR]: 3 }; +function createLogHandler(minLevel: LogLevel): (logLine: LogLine) => void { + return (logLine: LogLine): void => { + if (LOG_LEVEL_ORDER[logLine.level] >= LOG_LEVEL_ORDER[minLevel]) { + // eslint-disable-next-line no-console + console.log(formatLogLine(logLine)); + } + }; +} + const HEALTH_CHECK_INTERVAL_MS = 30 * 1000; +const PROGRESS_LOG_INTERVAL_MS = 2000; + +function resolveLineEndings( + mode: "auto" | "lf" | "crlf" +): string { + switch (mode) { + case "lf": + return "\n"; + case "crlf": + return "\r\n"; + case "auto": + return process.platform === "win32" ? "\r\n" : "\n"; + } +} async function main(): Promise { const args = parseArgs(process.argv); const absolutePath = path.resolve(args.localPath); + const logger = new Logger(); + const logHandler = createLogHandler(args.logLevel); + logger.onLogEmitted.add(logHandler); + if (!fsSync.existsSync(absolutePath)) { fsSync.mkdirSync(absolutePath, { recursive: true }); } @@ -48,36 +78,27 @@ async function main(): Promise { try { const stats = await fs.stat(absolutePath); if (!stats.isDirectory()) { - console.error( - colorize(`Error: ${absolutePath} is not a directory`, "red") - ); + logger.error(`${absolutePath} is not a directory`); process.exit(1); } } catch (error) { - console.error( - colorize( - `Error: Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`, - "red" - ) + logger.error( + `Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}` ); process.exit(1); } - console.log( - styleText("VaultLink Local CLI", "bold", "cyan") + - colorize(` v${packageJson.version}`, "dim") - ); - console.log(colorize("=".repeat(50), "dim")); - console.log( - `${colorize("Local path:", "dim")} ${colorize(absolutePath, "green")}` - ); - console.log( - `${colorize("Remote URI:", "dim")} ${colorize(args.remoteUri, "cyan")}` - ); - console.log( - `${colorize("Vault name:", "dim")} ${colorize(args.vaultName, "green")}` - ); - console.log(""); + if (!args.quiet) { + logger.info(`VaultLink Local CLI v${packageJson.version}`); + logger.info(`Local path: ${absolutePath}`); + logger.info(`Remote URI: ${args.remoteUri}`); + logger.info(`Vault name: ${args.vaultName}`); + if (args.lineEndings !== "auto") { + logger.info( + `Line endings: ${args.lineEndings.toUpperCase()}` + ); + } + } const dataDir = path.join(absolutePath, ".vaultlink"); const dataFile = path.join(dataDir, "sync-data.json"); @@ -97,8 +118,6 @@ async function main(): Promise { remoteUri: args.remoteUri, token: args.token, vaultName: args.vaultName, - syncConcurrency: - args.syncConcurrency ?? DEFAULT_SETTINGS.syncConcurrency, maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB, ignorePatterns, webSocketRetryIntervalMs: @@ -119,11 +138,8 @@ async function main(): Promise { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion database = JSON.parse(content) as Partial; } catch { - console.error( - colorize( - `Cannot read data file at ${dataFile}`, - "yellow" - ) + logger.warn( + `Cannot read data file at ${dataFile}` ); } @@ -133,23 +149,27 @@ async function main(): Promise { }; }, save: async ({ database: persistedDatabase }) => { - // settings can't be updated when running with this CLI await fs.writeFile( dataFile, JSON.stringify(persistedDatabase, null, 2) ); } }, - nativeLineEndings: process.platform === "win32" ? "\r\n" : "\n" + nativeLineEndings: resolveLineEndings(args.lineEndings) }); if (args.health !== undefined) { const healthFile = args.health; - const healthInterval = setInterval(() => { + const writeHealth = (): void => { void client.checkConnection().then((status) => { - writeHealthStatus(healthFile, status); + writeHealthStatus(client.logger, healthFile, status); }); - }, HEALTH_CHECK_INTERVAL_MS); + }; + writeHealth(); + const healthInterval = setInterval( + writeHealth, + HEALTH_CHECK_INTERVAL_MS + ); const clearHealthInterval = (): void => { clearInterval(healthInterval); }; @@ -158,17 +178,10 @@ async function main(): Promise { process.on("exit", clearHealthInterval); } - // Add colored log formatter with level filtering - client.logger.onLogEmitted.add((logLine) => { - // Only show messages at or above the configured log level - if (LOG_LEVEL_ORDER[logLine.level] >= LOG_LEVEL_ORDER[args.logLevel]) { - console.log(formatLogLine(logLine)); - } - }); - + client.logger.onLogEmitted.add(logHandler); client.logger.info("Starting sync client"); - const fileWatcher = new FileWatcher(absolutePath, client); + const fileWatcher = new FileWatcher(absolutePath, client, ignorePatterns); client.onWebSocketStatusChanged.add(() => { const isConnected = client.isWebSocketConnected; @@ -177,26 +190,56 @@ async function main(): Promise { ); }); + let syncBatchSize = 0; + let totalSyncOps = 0; + let lastProgressLogTime = 0; + client.onRemainingOperationsCountChanged.add((remaining) => { + if (remaining > syncBatchSize) { + syncBatchSize = remaining; + } + if (remaining === 0) { - client.logger.info("All sync operations completed"); + if (syncBatchSize > 0) { + totalSyncOps += syncBatchSize; + client.logger.info( + `Sync batch complete (${syncBatchSize} operations)` + ); + syncBatchSize = 0; + } } else { - client.logger.info(`${remaining} sync operations remaining`); + const now = Date.now(); + if (now - lastProgressLogTime >= PROGRESS_LOG_INTERVAL_MS) { + client.logger.info( + `Syncing: ${remaining} operations remaining` + ); + lastProgressLogTime = now; + } } }); + let isShuttingDown = false; const gracefulShutdown = async (signal: string): Promise => { - console.log( - colorize( - `\n${signal} received. Shutting down gracefully...`, - "yellow" - ) + if (isShuttingDown) { + return; + } + isShuttingDown = true; + + client.logger.info( + `${signal} received, shutting down gracefully` ); fileWatcher.stop(); await client.waitUntilFinished(); await client.destroy(); - console.log(colorize("Shutdown complete", "green")); + + if (totalSyncOps > 0) { + client.logger.info( + `Shutdown complete (${totalSyncOps} operations synced)` + ); + } else { + client.logger.info("Shutdown complete"); + } process.exit(0); }; @@ -210,27 +253,21 @@ async function main(): Promise { try { const connectionStatus = await client.checkConnection(); if (!connectionStatus.isSuccessful) { - console.error( - colorize( - `Error: Cannot connect to server: ${connectionStatus.serverMessage}`, - "red" - ) + client.logger.error( + `Cannot connect to server: ${connectionStatus.serverMessage}` ); process.exit(1); } - console.log(`${colorize("✓", "green")} Server connection successful`); - console.log(colorize("Press Ctrl+C to stop", "dim")); - console.log(""); + if (!args.quiet) { + client.logger.info("Server connection successful"); + } await client.start(); fileWatcher.start(); } catch (error) { - console.error( - colorize( - `Fatal error: ${error instanceof Error ? error.message : String(error)}`, - "red" - ) + client.logger.error( + `Fatal error: ${error instanceof Error ? error.message : String(error)}` ); fileWatcher.stop(); @@ -240,11 +277,10 @@ async function main(): Promise { } main().catch((error: unknown) => { + // Last-resort handler before the logger exists + // eslint-disable-next-line no-console console.error( - colorize( - `Unexpected error: ${error instanceof Error ? error.message : String(error)}`, - "red" - ) + `Unexpected error: ${error instanceof Error ? error.message : String(error)}` ); process.exit(1); }); diff --git a/frontend/local-client-cli/src/file-watcher.ts b/frontend/local-client-cli/src/file-watcher.ts index e781d18f..16f397c5 100644 --- a/frontend/local-client-cli/src/file-watcher.ts +++ b/frontend/local-client-cli/src/file-watcher.ts @@ -1,15 +1,20 @@ import Watcher from "watcher"; import * as path from "path"; import type { SyncClient, RelativePath } from "sync-client"; +import { toUnixPath, matchesGlob } from "./path-utils"; export class FileWatcher { private watcher: Watcher | undefined; private isRunning = false; + private readonly ignorePatterns: string[]; public constructor( private readonly basePath: string, - private readonly client: SyncClient - ) {} + private readonly client: SyncClient, + ignorePatterns: string[] = [] + ) { + this.ignorePatterns = ignorePatterns; + } public start(): void { if (this.isRunning) { @@ -22,7 +27,9 @@ export class FileWatcher { recursive: true, renameDetection: true, renameTimeout: 125, - ignoreInitial: true + ignoreInitial: true, + ignore: (filePath: string): boolean => + this.shouldIgnore(filePath) }); this.watcher.on("add", (filePath: string) => { @@ -56,6 +63,11 @@ export class FileWatcher { this.client.logger.info("File watcher stopped"); } + private shouldIgnore(filePath: string): boolean { + const rel = toUnixPath(path.relative(this.basePath, filePath)); + return this.ignorePatterns.some((pattern) => matchesGlob(rel, pattern)); + } + private handleCreate(relativePath: RelativePath): void { this.client .syncLocallyCreatedFile(relativePath) @@ -101,18 +113,7 @@ export class FileWatcher { } private toRelativePath(absolutePath: string): RelativePath { - const relative = path.relative(this.basePath, absolutePath); - return this.toUnixPath(relative); - } - - /** - * Convert a native platform path to forward slashes - */ - private toUnixPath(nativePath: string): string { - if (path.sep === "\\") { - return nativePath.replace(/\\/g, "/"); - } - return nativePath; + return toUnixPath(path.relative(this.basePath, absolutePath)); } private formatError(err: unknown): string { diff --git a/frontend/local-client-cli/src/healthcheck.ts b/frontend/local-client-cli/src/healthcheck.ts index 2dd9e721..d7211c88 100644 --- a/frontend/local-client-cli/src/healthcheck.ts +++ b/frontend/local-client-cli/src/healthcheck.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +/* eslint-disable no-console */ /** * Healthcheck script for Docker container diff --git a/frontend/local-client-cli/src/logger-formatter.test.ts b/frontend/local-client-cli/src/logger-formatter.test.ts new file mode 100644 index 00000000..f3078242 --- /dev/null +++ b/frontend/local-client-cli/src/logger-formatter.test.ts @@ -0,0 +1,50 @@ +import { test } from "node:test"; +import * as assert from "node:assert/strict"; +import { formatLogLine } from "./logger-formatter"; +import { LogLevel } from "sync-client"; + +test("formatLogLine - includes level and message", () => { + const logLine = { + timestamp: new Date("2024-01-15T10:30:45.123Z"), + level: LogLevel.INFO, + message: "Test message" + }; + + const result = formatLogLine(logLine); + assert.ok(result.includes("INFO")); + assert.ok(result.includes("Test message")); +}); + +test("formatLogLine - ERROR level messages contain bold escape", () => { + const logLine = { + timestamp: new Date("2024-01-15T10:30:45.123Z"), + level: LogLevel.ERROR, + message: "Error occurred" + }; + + const result = formatLogLine(logLine); + assert.ok(result.includes("\x1b[1m")); +}); + +test("formatLogLine - highlights file paths in quotes", () => { + const logLine = { + timestamp: new Date("2024-01-15T10:30:45.123Z"), + level: LogLevel.INFO, + message: 'Syncing "notes/test.md"' + }; + + const result = formatLogLine(logLine); + assert.ok(result.includes("\x1b[35m")); +}); + +test("formatLogLine - highlights standalone numbers but not numbers in versions", () => { + const logLine = { + timestamp: new Date("2024-01-15T10:30:45.123Z"), + level: LogLevel.INFO, + message: "Listed 42 files from v1.2.3" + }; + + const result = formatLogLine(logLine); + assert.ok(result.includes("\x1b[36m42\x1b[0m")); + assert.ok(!result.includes("\x1b[36m1\x1b[0m.")); +}); diff --git a/frontend/local-client-cli/src/logger-formatter.ts b/frontend/local-client-cli/src/logger-formatter.ts index 9f237103..b98415b6 100644 --- a/frontend/local-client-cli/src/logger-formatter.ts +++ b/frontend/local-client-cli/src/logger-formatter.ts @@ -1,36 +1,21 @@ import { LogLevel, type LogLine } from "sync-client"; -// ANSI color codes -export const colors = { +const colors = { reset: "\x1b[0m", bold: "\x1b[1m", - dim: "\x1b[2m", - // Foreground colors red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m", - blue: "\x1b[34m", magenta: "\x1b[35m", cyan: "\x1b[36m", gray: "\x1b[90m" } as const; -export function colorize(text: string, color: keyof typeof colors): string { +function colorize(text: string, color: keyof typeof colors): string { return `${colors[color]}${text}${colors.reset}`; } -/** - * Helper function to apply multiple color modifiers to text - */ -export function styleText( - text: string, - ...modifiers: (keyof typeof colors)[] -): string { - const prefix = modifiers.map((m) => colors[m]).join(""); - return `${prefix}${text}${colors.reset}`; -} - function formatTimestamp(date: Date): string { const [time] = date.toTimeString().split(" "); const ms = date.getMilliseconds().toString().padStart(3, "0"); diff --git a/frontend/local-client-cli/src/node-filesystem.ts b/frontend/local-client-cli/src/node-filesystem.ts index 3da8fc3a..f84cbdb8 100644 --- a/frontend/local-client-cli/src/node-filesystem.ts +++ b/frontend/local-client-cli/src/node-filesystem.ts @@ -6,6 +6,7 @@ import type { RelativePath, TextWithCursors } from "sync-client"; +import { toUnixPath } from "./path-utils"; export class NodeFileSystemOperations implements FileSystemOperations { public constructor(private readonly basePath: string) {} @@ -15,7 +16,7 @@ export class NodeFileSystemOperations implements FileSystemOperations { ): Promise { const files: RelativePath[] = []; await this.walkDirectory( - directory !== undefined ? this.toNativePath(directory) : "", + directory ?? "", files ); return files; @@ -24,7 +25,7 @@ export class NodeFileSystemOperations implements FileSystemOperations { public async read(relativePath: RelativePath): Promise { const fullPath = path.join( this.basePath, - this.toNativePath(relativePath) + relativePath ); try { return await fs.readFile(fullPath); @@ -41,13 +42,13 @@ export class NodeFileSystemOperations implements FileSystemOperations { ): Promise { const fullPath = path.join( this.basePath, - this.toNativePath(relativePath) + relativePath ); const dir = path.dirname(fullPath); try { await fs.mkdir(dir, { recursive: true }); - await fs.writeFile(fullPath, content); + await this.atomicWrite(fullPath, content); } catch (error) { throw new Error( `Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}` @@ -61,13 +62,13 @@ export class NodeFileSystemOperations implements FileSystemOperations { ): Promise { const fullPath = path.join( this.basePath, - this.toNativePath(relativePath) + relativePath ); try { const currentContent = await fs.readFile(fullPath, "utf-8"); const result = updater({ text: currentContent, cursors: [] }); - await fs.writeFile(fullPath, result.text, "utf-8"); + await this.atomicWrite(fullPath, result.text, "utf-8"); return result.text; } catch (error) { throw new Error( @@ -79,7 +80,7 @@ export class NodeFileSystemOperations implements FileSystemOperations { public async getFileSize(relativePath: RelativePath): Promise { const fullPath = path.join( this.basePath, - this.toNativePath(relativePath) + relativePath ); try { const stats = await fs.stat(fullPath); @@ -94,7 +95,7 @@ export class NodeFileSystemOperations implements FileSystemOperations { public async exists(relativePath: RelativePath): Promise { const fullPath = path.join( this.basePath, - this.toNativePath(relativePath) + relativePath ); try { await fs.access(fullPath); @@ -107,7 +108,7 @@ export class NodeFileSystemOperations implements FileSystemOperations { public async createDirectory(relativePath: RelativePath): Promise { const fullPath = path.join( this.basePath, - this.toNativePath(relativePath) + relativePath ); try { await fs.mkdir(fullPath, { recursive: false }); @@ -121,7 +122,7 @@ export class NodeFileSystemOperations implements FileSystemOperations { public async delete(relativePath: RelativePath): Promise { const fullPath = path.join( this.basePath, - this.toNativePath(relativePath) + relativePath ); try { await fs.unlink(fullPath); @@ -136,14 +137,8 @@ export class NodeFileSystemOperations implements FileSystemOperations { oldPath: RelativePath, newPath: RelativePath ): Promise { - const oldFullPath = path.join( - this.basePath, - this.toNativePath(oldPath) - ); - const newFullPath = path.join( - this.basePath, - this.toNativePath(newPath) - ); + const oldFullPath = path.join(this.basePath, oldPath); + const newFullPath = path.join(this.basePath, newPath); const newDir = path.dirname(newFullPath); try { @@ -156,6 +151,19 @@ export class NodeFileSystemOperations implements FileSystemOperations { } } + private async atomicWrite( + fullPath: string, + content: Uint8Array | string, + encoding?: BufferEncoding + ): Promise { + const tmpPath = fullPath + ".tmp"; + await fs.writeFile(tmpPath, content, encoding); + const fd = await fs.open(tmpPath, "r"); + await fd.datasync(); + await fd.close(); + await fs.rename(tmpPath, fullPath); + } + private async walkDirectory( relativePath: string, files: RelativePath[] @@ -179,28 +187,9 @@ export class NodeFileSystemOperations implements FileSystemOperations { await this.walkDirectory(entryRelativePath, files); } else if (entry.isFile()) { // Always return forward slashes - files.push(this.toUnixPath(entryRelativePath)); + files.push(toUnixPath(entryRelativePath)); } } } - /** - * Convert a forward-slash path to native platform path separators - */ - private toNativePath(relativePath: string): string { - if (path.sep === "\\") { - return relativePath.replace(/\//g, "\\"); - } - return relativePath; - } - - /** - * Convert a native platform path to forward slashes - */ - private toUnixPath(nativePath: string): string { - if (path.sep === "\\") { - return nativePath.replace(/\\/g, "/"); - } - return nativePath; - } } diff --git a/frontend/local-client-cli/src/path-utils.test.ts b/frontend/local-client-cli/src/path-utils.test.ts new file mode 100644 index 00000000..13d33e6e --- /dev/null +++ b/frontend/local-client-cli/src/path-utils.test.ts @@ -0,0 +1,60 @@ +import { test } from "node:test"; +import * as assert from "node:assert/strict"; +import { matchesGlob, toUnixPath } from "./path-utils"; + +test("matchesGlob - exact match", () => { + assert.equal(matchesGlob(".DS_Store", ".DS_Store"), true); + assert.equal(matchesGlob("other", ".DS_Store"), false); +}); + +test("matchesGlob - dir/** matches directory and contents", () => { + assert.equal(matchesGlob(".git", ".git/**"), true); + assert.equal(matchesGlob(".git/config", ".git/**"), true); + assert.equal(matchesGlob(".git/refs/heads/main", ".git/**"), true); + assert.equal(matchesGlob(".gitignore", ".git/**"), false); +}); + +test("matchesGlob - * matches within a single segment", () => { + assert.equal(matchesGlob("foo.tmp", "*.tmp"), true); + assert.equal(matchesGlob("bar.tmp", "*.tmp"), true); + assert.equal(matchesGlob("foo.md", "*.tmp"), false); + assert.equal(matchesGlob("dir/foo.tmp", "*.tmp"), false); +}); + +test("matchesGlob - **/*.ext matches at any depth", () => { + assert.equal(matchesGlob("foo.tmp", "**/*.tmp"), true); + assert.equal(matchesGlob("dir/foo.tmp", "**/*.tmp"), true); + assert.equal(matchesGlob("a/b/c/foo.tmp", "**/*.tmp"), true); + assert.equal(matchesGlob("foo.md", "**/*.tmp"), false); +}); + +test("matchesGlob - ? matches single character", () => { + assert.equal(matchesGlob("a.md", "?.md"), true); + assert.equal(matchesGlob("ab.md", "?.md"), false); + assert.equal(matchesGlob(".md", "?.md"), false); +}); + +test("matchesGlob - dots are literal", () => { + assert.equal(matchesGlob(".DS_Store", ".DS_Store"), true); + assert.equal(matchesGlob("xDS_Store", ".DS_Store"), false); +}); + +test("matchesGlob - node_modules/** matches directory tree", () => { + assert.equal(matchesGlob("node_modules", "node_modules/**"), true); + assert.equal(matchesGlob("node_modules/foo", "node_modules/**"), true); + assert.equal( + matchesGlob("node_modules/foo/bar/baz.js", "node_modules/**"), + true + ); + assert.equal(matchesGlob("not_node_modules", "node_modules/**"), false); +}); + +test("matchesGlob - **/ prefix matches zero or more segments", () => { + assert.equal(matchesGlob("test.log", "**/test.log"), true); + assert.equal(matchesGlob("dir/test.log", "**/test.log"), true); + assert.equal(matchesGlob("a/b/test.log", "**/test.log"), true); +}); + +test("toUnixPath - forward slashes unchanged", () => { + assert.equal(toUnixPath("foo/bar/baz"), "foo/bar/baz"); +}); diff --git a/frontend/local-client-cli/src/path-utils.ts b/frontend/local-client-cli/src/path-utils.ts new file mode 100644 index 00000000..a781b746 --- /dev/null +++ b/frontend/local-client-cli/src/path-utils.ts @@ -0,0 +1,18 @@ +import * as path from "path"; + +// Convert a native platform path to forward slashes (no-op on non-Windows) +export function toUnixPath(nativePath: string): string { + return nativePath.split(path.sep).join(path.posix.sep); +} + +// Match a file path against a glob pattern +// Extends path.matchesGlob so that "dir/**" also matches the directory itself +export function matchesGlob(filePath: string, pattern: string): boolean { + if ( + pattern.endsWith("/**") && + filePath === pattern.slice(0, -3) + ) { + return true; + } + return path.matchesGlob(filePath, pattern); +} From 9233a4f314d01cd15d4c8013a39c4ec22bd10c5e Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 28 Mar 2026 10:58:30 +0000 Subject: [PATCH 08/10] Rerender types --- .../src/services/types/CreateDocumentVersion.ts | 12 +----------- .../src/services/types/DeleteDocumentVersion.ts | 4 +--- .../src/services/types/VaultHistoryResponse.ts | 7 +++++++ .../src/services/types/WebSocketClientMessage.ts | 4 +--- 4 files changed, 10 insertions(+), 17 deletions(-) create mode 100644 frontend/sync-client/src/services/types/VaultHistoryResponse.ts diff --git a/frontend/sync-client/src/services/types/CreateDocumentVersion.ts b/frontend/sync-client/src/services/types/CreateDocumentVersion.ts index ed921f18..d4ed2831 100644 --- a/frontend/sync-client/src/services/types/CreateDocumentVersion.ts +++ b/frontend/sync-client/src/services/types/CreateDocumentVersion.ts @@ -1,13 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export interface CreateDocumentVersion { - /** - * The client can decide the document id (if it wishes to) in order - * to help with syncing. If the client does not provide a document id, - * the server will generate one. If the client provides a document id - * it must not already exist in the database. - */ - document_id: string | null; - relative_path: string; - content: number[]; -} +export interface CreateDocumentVersion { relative_path: string, content: number[], } diff --git a/frontend/sync-client/src/services/types/DeleteDocumentVersion.ts b/frontend/sync-client/src/services/types/DeleteDocumentVersion.ts index 99ecc9e7..f160406f 100644 --- a/frontend/sync-client/src/services/types/DeleteDocumentVersion.ts +++ b/frontend/sync-client/src/services/types/DeleteDocumentVersion.ts @@ -1,5 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export interface DeleteDocumentVersion { - relativePath: string; -} +export type DeleteDocumentVersion = Record; diff --git a/frontend/sync-client/src/services/types/VaultHistoryResponse.ts b/frontend/sync-client/src/services/types/VaultHistoryResponse.ts new file mode 100644 index 00000000..93d6ec6c --- /dev/null +++ b/frontend/sync-client/src/services/types/VaultHistoryResponse.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; + +/** + * Response to a vault history request (paginated). + */ +export interface VaultHistoryResponse { versions: DocumentVersionWithoutContent[], hasMore: boolean, } diff --git a/frontend/sync-client/src/services/types/WebSocketClientMessage.ts b/frontend/sync-client/src/services/types/WebSocketClientMessage.ts index 9608f3af..5765a0d0 100644 --- a/frontend/sync-client/src/services/types/WebSocketClientMessage.ts +++ b/frontend/sync-client/src/services/types/WebSocketClientMessage.ts @@ -2,6 +2,4 @@ import type { CursorPositionFromClient } from "./CursorPositionFromClient"; import type { WebSocketHandshake } from "./WebSocketHandshake"; -export type WebSocketClientMessage = - | ({ type: "handshake" } & WebSocketHandshake) - | ({ type: "cursorPositions" } & CursorPositionFromClient); +export type WebSocketClientMessage = { "type": "handshake" } & WebSocketHandshake | { "type": "cursorPositions" } & CursorPositionFromClient; From 7b9287ca5296fb1fc0e7c5b151a9d0ce6e29028d Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 28 Mar 2026 10:59:51 +0000 Subject: [PATCH 09/10] Fix Rust compile --- sync-server/src/app_state/websocket/models.rs | 1 - sync-server/src/config/logging_config.rs | 4 ++-- sync-server/src/config/server_config.rs | 8 ++++---- sync-server/src/consts.rs | 2 ++ sync-server/src/server/update_document.rs | 5 ++--- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/sync-server/src/app_state/websocket/models.rs b/sync-server/src/app_state/websocket/models.rs index fb1d24b9..116c2b84 100644 --- a/sync-server/src/app_state/websocket/models.rs +++ b/sync-server/src/app_state/websocket/models.rs @@ -70,7 +70,6 @@ pub struct WebSocketVaultUpdate { pub enum WebSocketClientMessage { Handshake(WebSocketHandshake), CursorPositions(CursorPositionFromClient), - Ping {}, } #[derive(TS, Serialize, Clone, Debug)] diff --git a/sync-server/src/config/logging_config.rs b/sync-server/src/config/logging_config.rs index e716518d..016dbc46 100644 --- a/sync-server/src/config/logging_config.rs +++ b/sync-server/src/config/logging_config.rs @@ -5,7 +5,7 @@ use log::debug; use serde::{Deserialize, Serialize}; use crate::{ - consts::{DEFAULT_LOG_DIRECTORY, DEFAULT_LOG_LEVEL, DEFAULT_LOG_ROTATION_INTERVAL}, + consts::{DEFAULT_LOG_DIRECTORY, DEFAULT_LOG_LEVEL, DEFAULT_LOG_ROTATION_INTERVAL, DURATION_ZERO}, utils::log_level::LogLevel, }; @@ -27,7 +27,7 @@ impl LoggingConfig { !self.log_directory.is_empty(), "log_directory must not be an empty string" ); - ensure!(self.log_rotation > 0, "log_rotation must be greater than 0"); + ensure!(self.log_rotation > DURATION_ZERO, "log_rotation must be greater than 0"); Ok(()) } } diff --git a/sync-server/src/config/server_config.rs b/sync-server/src/config/server_config.rs index 4132d336..715d216c 100644 --- a/sync-server/src/config/server_config.rs +++ b/sync-server/src/config/server_config.rs @@ -7,7 +7,7 @@ use crate::consts::{ DEFAULT_ALLOWED_ORIGINS, DEFAULT_BROADCAST_CHANNEL_CAPACITY, DEFAULT_HOST, DEFAULT_MAX_BODY_SIZE_MB, DEFAULT_MAX_CLIENTS_PER_VAULT, DEFAULT_MAX_PENDING_WS_CONNECTIONS, DEFAULT_MERGEABLE_FILE_EXTENSIONS, DEFAULT_PORT, DEFAULT_RATE_LIMIT_PER_USER_PER_SECOND, - DEFAULT_RESPONSE_TIMEOUT_SECONDS, + DEFAULT_RESPONSE_TIMEOUT_SECONDS, DURATION_ZERO, }; #[derive(Debug, Deserialize, Serialize, Clone, Default)] @@ -35,7 +35,7 @@ pub struct ServerConfig { /// Per-user maximum requests per second (keyed by bearer token). /// `None` disables rate limiting. - #[serde(default = "DEFAULT_RATE_LIMIT_PER_USER_PER_SECOND")] + #[serde(default = "default_rate_limit_per_user_per_second")] pub rate_limit_per_user_per_second: Option, /// Allowed CORS origins. Default: `["*"]` (allow all). @@ -52,7 +52,7 @@ pub struct ServerConfig { impl ServerConfig { pub fn validate(&self) -> Result<()> { ensure!( - self.response_timeout > 0, + self.response_timeout > DURATION_ZERO, "response_timeout must be greater than 0" ); ensure!( @@ -114,7 +114,7 @@ fn default_mergeable_file_extensions() -> Vec { .collect() } -fn DEFAULT_RATE_LIMIT_PER_USER_PER_SECOND() -> Option { +fn default_rate_limit_per_user_per_second() -> Option { debug!("Using default rate limit per second: {DEFAULT_RATE_LIMIT_PER_USER_PER_SECOND:?}"); DEFAULT_RATE_LIMIT_PER_USER_PER_SECOND } diff --git a/sync-server/src/consts.rs b/sync-server/src/consts.rs index 715763d9..e03b848f 100644 --- a/sync-server/src/consts.rs +++ b/sync-server/src/consts.rs @@ -2,6 +2,8 @@ use std::time::Duration; use crate::utils::log_level::LogLevel; +pub const DURATION_ZERO: Duration = Duration::from_secs(0); + pub const DEFAULT_CONFIG_PATH: &str = "config.yml"; pub const DEFAULT_DATABASES_DIRECTORY_PATH: &str = "databases"; diff --git a/sync-server/src/server/update_document.rs b/sync-server/src/server/update_document.rs index a12ec993..bd6c6586 100644 --- a/sync-server/src/server/update_document.rs +++ b/sync-server/src/server/update_document.rs @@ -5,7 +5,6 @@ use axum::{ }; use axum_extra::TypedHeader; use axum_typed_multipart::TypedMultipart; -use futures::io::Write; use log::{debug, info}; use reconcile_text::{BuiltinTokenizer, EditedText, reconcile}; use serde::Deserialize; @@ -56,7 +55,7 @@ pub async fn update_binary( get_parent_document(&state, &vault_id, &document_id, request.parent_version_id).await?; let content = request.content.contents.to_vec(); - let mut transaction = state + let transaction = state .database .create_write_transaction(&vault_id) .await @@ -102,7 +101,7 @@ pub async fn update_text( let content = edited_text.apply().text().into_bytes(); - let mut transaction = state + let transaction = state .database .create_write_transaction(&vault_id) .await From f36a84b275cc889c154cd238e4d6dbfca8644824 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 28 Mar 2026 11:12:43 +0000 Subject: [PATCH 10/10] Clean up deterministic tests --- frontend/deterministic-tests/README.md | 30 ++-- frontend/deterministic-tests/src/cli.ts | 49 +++--- .../src/deterministic-agent.ts | 161 ++++++++++++------ .../deterministic-tests/src/server-control.ts | 2 +- .../deterministic-tests/src/server-manager.ts | 2 +- .../src/test-definition.ts | 9 +- .../deterministic-tests/src/test-registry.ts | 102 +---------- .../deterministic-tests/src/test-runner.ts | 102 +++++------ ...-text-pending-create-not-displaced.test.ts | 18 +- ...concurrent-update-diff-consistency.test.ts | 1 - ...ser-parenthesized-file-not-deleted.test.ts | 46 +++++ .../src/tests/11-create-delete-noop.test.ts | 5 +- .../src/tests/12-create-merge-delete.test.ts | 5 +- ...3-move-identical-content-ambiguity.test.ts | 14 -- .../src/tests/14-write-write-conflict.test.ts | 24 --- ...reate-update-coalesce-server-pause.test.ts | 1 - .../16-create-during-reconciliation.test.ts | 1 - ...ate-merge-preserves-renamed-update.test.ts | 44 +++++ .../18-create-rename-create-same-path.test.ts | 34 ++++ .../tests/19-move-chain-three-files.test.ts | 41 +++++ ...inary-pending-create-not-displaced.test.ts | 15 +- ...sce-update-remote-update-data-loss.test.ts | 1 - ...esced-remote-update-watermark-loss.test.ts | 22 +-- ...urrent-delete-during-remote-update.test.ts | 2 - ...oncurrent-edit-exact-same-position.test.ts | 7 - ...urrent-rename-and-create-at-target.test.ts | 1 - ...urrent-rename-and-create-at-target.test.ts | 1 - .../9-concurrent-rename-same-target.test.ts | 1 - .../tests/binary-to-text-transition.test.ts | 47 +++++ .../concurrent-rename-first-wins.test.ts | 36 ++++ .../src/tests/create-merge-delete.test.ts | 52 ------ ...ate-merge-preserves-renamed-update.test.ts | 82 --------- .../create-rename-create-same-path.test.ts | 80 --------- .../create-rename-response-skips-file.test.ts | 41 +---- .../tests/create-while-server-paused.test.ts | 33 ---- ...lete-by-other-client-then-recreate.test.ts | 24 +++ .../delete-during-pending-create.test.ts | 42 +---- .../delete-recreate-concurrent-update.test.ts | 37 +--- .../delete-recreate-different-content.test.ts | 56 +----- .../tests/delete-recreate-same-path.test.ts | 22 +-- .../src/tests/delete-rename-conflict.test.ts | 55 +----- .../src/tests/double-offline-cycle.test.ts | 66 +------ .../tests/failed-vfs-move-falls-back.test.ts | 29 +--- .../idempotency-after-server-pause.test.ts | 39 +---- .../src/tests/interleaved-operations.test.ts | 39 ----- .../tests/interrupted-delete-retry.test.ts | 31 +--- .../tests/key-migration-event-drop.test.ts | 48 +----- .../src/tests/large-file-count.test.ts | 54 ------ ...ocal-edit-lost-during-create-merge.test.ts | 52 +----- ...mc-cross-create-rename-same-target.test.ts | 94 ++-------- .../mc-delete-then-offline-rename.test.ts | 79 +-------- .../mc-multi-delete-offline-rename.test.ts | 56 ++---- ...three-client-rename-offline-update.test.ts | 40 +---- .../migrate-key-preserves-existing.test.ts | 37 +--- .../move-and-concurrent-remote-update.test.ts | 60 +------ .../src/tests/move-chain-three-files.test.ts | 78 --------- .../move-identical-content-ambiguity.test.ts | 104 ----------- .../move-preserves-remote-update.test.ts | 48 ++---- .../move-remote-update-reverts-rename.test.ts | 62 ++----- .../tests/move-then-delete-stale-path.test.ts | 52 +----- .../src/tests/multi-file-operations.test.ts | 63 ++----- .../tests/multiple-updates-coalesce.test.ts | 43 ----- .../tests/offline-concurrent-renames.test.ts | 68 ++------ .../offline-create-rename-create.test.ts | 71 -------- ...e-create-same-path-binary-conflict.test.ts | 62 ++----- .../offline-delete-remote-rename.test.ts | 56 ++---- .../offline-delete-vs-remote-update.test.ts | 57 +------ .../tests/offline-edit-remote-rename.test.ts | 58 ++----- ...ffline-edit-then-move-same-content.test.ts | 58 ++----- .../tests/offline-mixed-operations.test.ts | 89 ++-------- .../offline-move-then-remote-delete.test.ts | 56 ++---- .../src/tests/offline-multiple-edits.test.ts | 48 +----- .../src/tests/offline-rename-and-edit.test.ts | 49 ++---- ...line-rename-remote-create-old-path.test.ts | 44 ++--- ...ffline-update-both-then-delete-one.test.ts | 73 ++------ ...te-rename-concurrent-create-orphan.test.ts | 30 ++++ ...online-delete-recreate-rapid-cycle.test.ts | 34 ++++ .../online-edit-vs-delete-convergence.test.ts | 27 +++ .../overlapping-edits-same-section.test.ts | 55 +----- ...e-reset-loses-coalesced-local-edit.test.ts | 63 +------ .../rapid-create-update-delete-cycle.test.ts | 48 +----- ...pid-edit-delete-online-convergence.test.ts | 44 +++++ .../src/tests/rapid-sync-toggle.test.ts | 36 ---- .../tests/rapid-updates-after-merge.test.ts | 40 +---- ...ently-deleted-cleared-on-reconnect.test.ts | 39 +---- .../tests/rename-chain-then-delete.test.ts | 31 +--- .../src/tests/rename-chain.test.ts | 22 +-- .../src/tests/rename-circular.test.ts | 86 ++-------- .../src/tests/rename-create-conflict.test.ts | 40 ++--- ...ame-pending-create-before-response.test.ts | 55 +----- .../src/tests/rename-roundtrip.test.ts | 55 ++---- .../src/tests/rename-swap.test.ts | 48 ++---- .../src/tests/rename-to-existing-path.test.ts | 33 +--- ...name-to-path-of-unconfirmed-delete.test.ts | 59 ++----- .../rename-to-pending-path-fallback.test.ts | 64 +------ .../rename-to-recently-deleted-path.test.ts | 49 ++---- .../src/tests/rename-update-conflict.test.ts | 45 ++--- ...ears-recently-deleted-resurrection.test.ts | 50 +----- ...equential-create-duplicate-content.test.ts | 58 ++----- .../server-pause-both-clients-create.test.ts | 51 +----- .../server-pause-both-edit-same-file.test.ts | 70 ++------ .../server-pause-delete-recreate.test.ts | 32 ++++ .../server-pause-rename-edit-resume.test.ts | 56 ++---- .../server-pause-update-and-create.test.ts | 61 ++----- ...multaneous-create-delete-same-path.test.ts | 46 ++--- .../three-client-rename-create-delete.test.ts | 56 +----- .../update-during-create-processing.test.ts | 55 +----- .../tests/update-during-server-pause.test.ts | 43 ----- .../update-survives-remote-delete.test.ts | 41 +---- .../src/tests/update-then-rename.test.ts | 33 ---- .../tests/watermark-advances-on-skip.test.ts | 34 +--- ...ark-gap-remote-update-not-recorded.test.ts | 69 ++------ .../src/utils/assertable-state.ts | 132 ++++++++++++++ 113 files changed, 1366 insertions(+), 3835 deletions(-) create mode 100644 frontend/deterministic-tests/src/tests/10-user-parenthesized-file-not-deleted.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/14-write-write-conflict.test.ts create mode 100644 frontend/deterministic-tests/src/tests/17-create-merge-preserves-renamed-update.test.ts create mode 100644 frontend/deterministic-tests/src/tests/18-create-rename-create-same-path.test.ts create mode 100644 frontend/deterministic-tests/src/tests/19-move-chain-three-files.test.ts create mode 100644 frontend/deterministic-tests/src/tests/binary-to-text-transition.test.ts create mode 100644 frontend/deterministic-tests/src/tests/concurrent-rename-first-wins.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/create-merge-delete.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/create-merge-preserves-renamed-update.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/create-rename-create-same-path.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/create-while-server-paused.test.ts create mode 100644 frontend/deterministic-tests/src/tests/delete-by-other-client-then-recreate.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/interleaved-operations.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/large-file-count.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/move-chain-three-files.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/move-identical-content-ambiguity.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/multiple-updates-coalesce.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/offline-create-rename-create.test.ts create mode 100644 frontend/deterministic-tests/src/tests/online-create-rename-concurrent-create-orphan.test.ts create mode 100644 frontend/deterministic-tests/src/tests/online-delete-recreate-rapid-cycle.test.ts create mode 100644 frontend/deterministic-tests/src/tests/online-edit-vs-delete-convergence.test.ts create mode 100644 frontend/deterministic-tests/src/tests/rapid-edit-delete-online-convergence.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/rapid-sync-toggle.test.ts create mode 100644 frontend/deterministic-tests/src/tests/server-pause-delete-recreate.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/update-during-server-pause.test.ts delete mode 100644 frontend/deterministic-tests/src/tests/update-then-rename.test.ts create mode 100644 frontend/deterministic-tests/src/utils/assertable-state.ts diff --git a/frontend/deterministic-tests/README.md b/frontend/deterministic-tests/README.md index 71578ed1..5c835326 100644 --- a/frontend/deterministic-tests/README.md +++ b/frontend/deterministic-tests/README.md @@ -6,7 +6,7 @@ Complements the fuzz-based E2E tests (`test-client`): fuzz tests discover bugs t ## How it works -Each test is a `TestDefinition`: a name, a client count, and an ordered list of steps. The `TestRunner` spins up N `DeterministicAgent` instances (each wrapping a real `SyncClient` with an `InMemoryFileSystem`) pointed at a shared vault on the server, then executes steps one by one. +Each test is a `TestDefinition`: a client count and an ordered list of steps. The test name is derived from the registry key (which matches the file name). The `TestRunner` spins up N `DeterministicAgent` instances (each wrapping a real `SyncClient` with an `InMemoryFileSystem`) pointed at a shared vault on the server, then executes steps one by one. Tests that don't pause the server share a single server process (vault-name isolation). Tests that use `pause-server`/`resume-server` (SIGSTOP/SIGCONT) each get a dedicated server, since SIGSTOP freezes the entire process. @@ -14,7 +14,7 @@ All tests run in parallel up to a concurrency limit. ## Step types -Clients always start with syincing being disabled. +Clients always start with syncing disabled. **File operations** (per-client, fire-and-forget — sync is enqueued but not awaited): - `create`, `update`, `rename`, `delete` @@ -26,11 +26,9 @@ Clients always start with syincing being disabled. **Server control:** - `pause-server` / `resume-server` — SIGSTOP/SIGCONT the server process -- `wait` — sleep for N milliseconds **Assertions:** -- `assert-content`, `assert-exists`, `assert-not-exists` -- `assert-consistent` — all clients have identical files; optionally takes a custom verify function +- `assert-consistent` — all clients have identical files; optionally takes a custom `verify(state: AssertableState)` callback ## Running @@ -56,18 +54,31 @@ npm run test -w deterministic-tests -- -j 4 import type { TestDefinition } from "../test-definition"; export const myScenarioTest: TestDefinition = { - name: "My Scenario", - description: "What this test verifies", + description: "Client 0 creates A.md offline. After syncing, both clients should have the file.", clients: 2, steps: [ { type: "create", client: 0, path: "A.md", content: "hello" }, - { type: "sync" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, { type: "barrier" }, - { type: "assert-consistent" } + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("A.md", "hello") } ] }; ``` +The `verify` callback receives an `AssertableState` object with chainable assertion methods: + +```typescript +s.assertFileCount(n) // exact file count +s.assertFileExists("path") // file must exist +s.assertFileNotExists("path") // file must not exist +s.assertContent("path", "expected") // exact content match +s.assertContains("path", "a", "b") // all substrings present +s.assertAnyFileContains("text") // substring in any file +s.assertContentInAtMostOneFile("text") // no duplicate content +s.ifFileExists("path", (s) => ...) // conditional assertion +``` + 2. Register it in `src/test-registry.ts`: ```typescript @@ -78,4 +89,3 @@ const TESTS = { "my-scenario": myScenarioTest }; ``` - diff --git a/frontend/deterministic-tests/src/cli.ts b/frontend/deterministic-tests/src/cli.ts index 2815abae..57cee963 100644 --- a/frontend/deterministic-tests/src/cli.ts +++ b/frontend/deterministic-tests/src/cli.ts @@ -34,7 +34,7 @@ function testUsesPauseServer(test: TestDefinition): boolean { } interface NamedTestResult { - test: TestDefinition; + name: string; result: TestResult; } @@ -64,13 +64,13 @@ async function main(): Promise { const filterArg = process.argv.find((a) => a.startsWith("--filter=")); const filter = filterArg?.slice("--filter=".length); - const testsToRun: TestDefinition[] = []; + const testsToRun: [string, TestDefinition][] = []; for (const [key, test] of Object.entries(TESTS)) { if (test) { - if (filter && !key.includes(filter) && !test.name.toLowerCase().includes(filter.toLowerCase())) { + if (filter && !key.includes(filter)) { continue; } - testsToRun.push(test); + testsToRun.push([key, test]); } } @@ -84,8 +84,10 @@ async function main(): Promise { } const concurrency = parseConcurrency(); - const regularTests = testsToRun.filter((t) => !testUsesPauseServer(t)); - const pauseTests = testsToRun.filter((t) => testUsesPauseServer(t)); + const regularTests = testsToRun.filter( + ([, t]) => !testUsesPauseServer(t) + ); + const pauseTests = testsToRun.filter(([, t]) => testUsesPauseServer(t)); logger.info(`Server: ${serverPath}`); logger.info(`Config: ${configPath}`); @@ -113,7 +115,8 @@ async function main(): Promise { const results = await runWithConcurrency( regularTests, concurrency, - async (test) => runSharedServerTest(test, sharedServer) + async ([name, test]) => + runSharedServerTest(name, test, sharedServer) ); allResults.push(...results); @@ -137,7 +140,8 @@ async function main(): Promise { const results = await runWithConcurrency( pauseTests, concurrency, - async (test) => runDedicatedServerTest(test, serverPath, configPath) + async ([name, test]) => + runDedicatedServerTest(name, test, serverPath, configPath) ); allResults.push(...results); @@ -149,8 +153,8 @@ async function main(): Promise { logger.info(`\n--- Results: ${passed.length}/${allResults.length} passed ---`); if (failed.length > 0) { - for (const { test, result } of failed) { - logger.error(` FAILED: ${test.name}: ${result.error}`); + for (const { name, result } of failed) { + logger.error(` FAILED: ${name}: ${result.error}`); } process.exit(1); } else { @@ -165,27 +169,25 @@ main().catch((err: unknown) => { }); -/** - * Run a test on a shared server (for tests that don't use pause-server). - */ async function runSharedServerTest( + name: string, test: TestDefinition, sharedServer: ServerControl ): Promise { - const testLogger = new PrefixedLogger(logger, test.name); + const testLogger = new PrefixedLogger(logger, name); const runner = new TestRunner( sharedServer, testLogger, TOKEN, sharedServer.remoteUri ); - const result = await runner.runTest(test); + const result = await runner.runTest(name, test); if (result.success) { - logger.info(`PASSED: ${test.name} (${result.duration}ms)`); + logger.info(`PASSED: ${name} (${result.duration}ms)`); } else { - logger.error(`FAILED: ${test.name} - ${result.error}`); + logger.error(`FAILED: ${name} - ${result.error}`); } - return { test, result }; + return { name, result }; } /** @@ -194,11 +196,12 @@ async function runSharedServerTest( * isolated servers to avoid interfering with other tests. */ async function runDedicatedServerTest( + name: string, test: TestDefinition, serverPath: string, configPath: string ): Promise { - const testLogger = new PrefixedLogger(logger, test.name); + const testLogger = new PrefixedLogger(logger, name); const server = new ServerControl(serverPath, configPath, testLogger); serverManager.track(server); @@ -210,13 +213,13 @@ async function runDedicatedServerTest( TOKEN, server.remoteUri ); - const result = await runner.runTest(test); + const result = await runner.runTest(name, test); if (result.success) { - logger.info(`PASSED: ${test.name} (${result.duration}ms)`); + logger.info(`PASSED: ${name} (${result.duration}ms)`); } else { - logger.error(`FAILED: ${test.name} - ${result.error}`); + logger.error(`FAILED: ${name} - ${result.error}`); } - return { test, result }; + return { name, result }; } finally { try { await server.stop(); diff --git a/frontend/deterministic-tests/src/deterministic-agent.ts b/frontend/deterministic-tests/src/deterministic-agent.ts index 3f4631b2..136d5ed8 100644 --- a/frontend/deterministic-tests/src/deterministic-agent.ts +++ b/frontend/deterministic-tests/src/deterministic-agent.ts @@ -1,4 +1,4 @@ -import type { StoredDatabase, SyncSettings, RelativePath } from "sync-client"; +import type { StoredDatabase, SyncSettings, RelativePath, TextWithCursors } from "sync-client"; import { SyncClient, debugging, LogLevel } from "sync-client"; import { assert } from "./utils/assert"; import { sleep } from "./utils/sleep"; @@ -16,6 +16,8 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { database: Partial; }> = {}; private isSyncEnabled = IS_SYNC_ENABLED_DEFAULT; + private readonly syncErrors: Error[] = []; + private readonly pendingSyncOperations = new Set>(); public constructor( clientId: number, @@ -81,9 +83,11 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { const contentBytes = new TextEncoder().encode(content); this.files.set(path, contentBytes); - this.enqueueSync(async () => - this.client.syncLocallyCreatedFile(path) - ); + if (this.isSyncEnabled) { + this.enqueueSync(async () => + this.client.syncLocallyCreatedFile(path) + ); + } } public async updateFile(path: string, content: string): Promise { @@ -96,9 +100,11 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { const contentBytes = new TextEncoder().encode(content); this.files.set(path, contentBytes); - this.enqueueSync(async () => - this.client.syncLocallyUpdatedFile({ relativePath: path }) - ); + if (this.isSyncEnabled) { + this.enqueueSync(async () => + this.client.syncLocallyUpdatedFile({ relativePath: path }) + ); + } } public async renameFile(oldPath: string, newPath: string): Promise { @@ -109,11 +115,6 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { `File ${oldPath} does not exist on client ${this.clientId}` ); } - if (oldPath !== newPath && this.files.has(newPath)) { - this.log( - `Target path ${newPath} already exists, will be overwritten (ensureClearPath)` - ); - } this.files.set(newPath, file); if (oldPath !== newPath) { this.files.delete(oldPath); @@ -140,18 +141,47 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { public async waitForSync(): Promise { this.log("Waiting for sync to complete..."); + // Drain agent-level sync operations first. These are the fire-and-forget + // promises from enqueueSync() that call into the SyncClient's methods. + // Without this, waitUntilFinished() might return before the SyncClient + // has even been told about the operation. + await this.drainPendingSyncOperations(); await withTimeout( this.client.waitUntilFinished(), WAIT_TIMEOUT_MS, `Client ${this.clientId} waitForSync timed out after ${WAIT_TIMEOUT_MS}ms` ); + if (this.syncErrors.length > 0) { + const errors = this.syncErrors.splice(0); + throw new Error( + `Client ${this.clientId} had ${errors.length} sync error(s):\n${errors.map((e) => e.message).join("\n")}` + ); + } this.log("Sync complete"); } public async disableSync(): Promise { this.log("Disabling sync"); + // Drain pending enqueued operations before disabling so the SyncClient + // knows about all operations that were enqueued while sync was enabled. + await this.drainPendingSyncOperations(); await this.client.setSetting("isSyncEnabled", false); this.isSyncEnabled = false; + // Wait for in-flight operations to drain. Disabling sync triggers + // a reset, which aborts in-flight fetches with SyncResetError. + try { + await withTimeout( + this.client.waitUntilFinished(), + WAIT_TIMEOUT_MS, + `Client ${this.clientId} disableSync drain timed out` + ); + } catch (error) { + if (error instanceof Error && error.name === "SyncResetError") { + this.log("Disable sync drain interrupted by reset (expected)"); + } else { + throw error; + } + } } public async enableSync(): Promise { @@ -161,44 +191,6 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { await this.waitForWebSocket(); } - public async assertContent( - path: string, - expectedContent: string - ): Promise { - this.log(`Asserting content of ${path} equals "${expectedContent}"`); - const actualBytes = await this.read(path).catch(() => { - throw new Error( - `File ${path} does not exist on client ${this.clientId}` - ); - }); - const actualContent = new TextDecoder().decode(actualBytes); - assert( - actualContent === expectedContent, - `Content mismatch on client ${this.clientId} for ${path}:\nExpected: "${expectedContent}"\nActual: "${actualContent}"` - ); - this.log(`✓ Content assertion passed for ${path}`); - } - - public async assertExists(path: string): Promise { - this.log(`Asserting ${path} exists`); - const exists = await this.exists(path); - assert( - exists, - `File ${path} does not exist on client ${this.clientId}` - ); - this.log(`✓ File ${path} exists`); - } - - public async assertNotExists(path: string): Promise { - this.log(`Asserting ${path} does not exist`); - const exists = await this.exists(path); - assert( - !exists, - `File ${path} exists on client ${this.clientId} but should not` - ); - this.log(`✓ File ${path} does not exist`); - } - public async getFiles(): Promise { return this.listFilesRecursively(); } @@ -217,6 +209,7 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { return; } try { + await this.drainPendingSyncOperations(); await withTimeout( this.client.waitUntilFinished(), WAIT_TIMEOUT_MS, @@ -233,6 +226,49 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { this.log("Cleanup complete"); } + // Yield the event loop before each FS operation so that the SyncClient's + // async calls create real interleaving points, matching the behavior of + // actual disk I/O. Without this, all FS operations resolve in the same + // microtask, hiding concurrency bugs that only manifest with real latency. + public override async read(path: RelativePath): Promise { + await Promise.resolve(); + return super.read(path); + } + + public override async write( + path: RelativePath, + content: Uint8Array + ): Promise { + await Promise.resolve(); + return super.write(path, content); + } + + public override async atomicUpdateText( + path: RelativePath, + updater: (current: TextWithCursors) => TextWithCursors + ): Promise { + await Promise.resolve(); + return super.atomicUpdateText(path, updater); + } + + public override async exists(path: RelativePath): Promise { + await Promise.resolve(); + return super.exists(path); + } + + public override async delete(path: RelativePath): Promise { + await Promise.resolve(); + return super.delete(path); + } + + public override async rename( + oldPath: RelativePath, + newPath: RelativePath + ): Promise { + await Promise.resolve(); + return super.rename(oldPath, newPath); + } + private async waitForWebSocket(): Promise { const deadline = Date.now() + WEBSOCKET_CONNECT_TIMEOUT_MS; while (!this.client.isWebSocketConnected && Date.now() < deadline) { @@ -244,11 +280,28 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { ); } + /** + * Wait until all agent-level enqueued sync operations have completed. + * Uses a loop because completing one operation can trigger new enqueues. + */ + private async drainPendingSyncOperations(): Promise { + while (this.pendingSyncOperations.size > 0) { + await Promise.all(this.pendingSyncOperations); + } + } + private enqueueSync(operation: () => Promise): void { - void this.executeSyncOperation(operation).catch((error) => { - this.log( - `Background sync failed (will retry on reconnect): ${error}` - ); + const promise = this.executeSyncOperation(operation).catch( + (error: unknown) => { + const err = + error instanceof Error ? error : new Error(String(error)); + this.log(`Background sync failed: ${err.message}`); + this.syncErrors.push(err); + } + ); + this.pendingSyncOperations.add(promise); + void promise.finally(() => { + this.pendingSyncOperations.delete(promise); }); } diff --git a/frontend/deterministic-tests/src/server-control.ts b/frontend/deterministic-tests/src/server-control.ts index 5c8aff17..c2d353db 100644 --- a/frontend/deterministic-tests/src/server-control.ts +++ b/frontend/deterministic-tests/src/server-control.ts @@ -104,7 +104,7 @@ export class ServerControl { public async waitForReady(maxAttempts = 50): Promise { const pingUrl = `${this.remoteUri}/vaults/test/ping`; for (let i = 0; i < maxAttempts; i++) { - if (this.process === null || this.process.exitCode !== null) { + if (this.process?.exitCode !== null) { throw new Error( "Server process died while waiting for it to become ready" ); diff --git a/frontend/deterministic-tests/src/server-manager.ts b/frontend/deterministic-tests/src/server-manager.ts index 51e162ee..8764e669 100644 --- a/frontend/deterministic-tests/src/server-manager.ts +++ b/frontend/deterministic-tests/src/server-manager.ts @@ -1,4 +1,4 @@ -import { ServerControl } from "./server-control"; +import type { ServerControl } from "./server-control"; import type { Logger } from "sync-client"; export class ServerManager { diff --git a/frontend/deterministic-tests/src/test-definition.ts b/frontend/deterministic-tests/src/test-definition.ts index 453a5d01..f8dac1fe 100644 --- a/frontend/deterministic-tests/src/test-definition.ts +++ b/frontend/deterministic-tests/src/test-definition.ts @@ -1,5 +1,8 @@ +import type { AssertableState } from "./utils/assertable-state"; + export interface ClientState { files: Map; + clientFiles: Map[]; } export type TestStep = @@ -13,13 +16,9 @@ export type TestStep = | { type: "pause-server" } | { type: "resume-server" } | { type: "barrier" } - | { type: "assert-content"; client: number; path: string; content: string } - | { type: "assert-exists"; client: number; path: string } - | { type: "assert-not-exists"; client: number; path: string } - | { type: "assert-consistent"; verify?: (state: ClientState) => void }; + | { type: "assert-consistent"; verify?: (state: AssertableState) => void }; export interface TestDefinition { - name: string; description?: string; clients: number; steps: TestStep[]; diff --git a/frontend/deterministic-tests/src/test-registry.ts b/frontend/deterministic-tests/src/test-registry.ts index 6ff5c9d3..0785926b 100644 --- a/frontend/deterministic-tests/src/test-registry.ts +++ b/frontend/deterministic-tests/src/test-registry.ts @@ -1,86 +1,49 @@ import type { TestDefinition } from "./test-definition"; -import { writeWriteConflictTest } from "./tests/write-write-conflict.test"; import { renameCreateConflictTest } from "./tests/rename-create-conflict.test"; -import { createDeleteNoopTest } from "./tests/create-delete-noop.test"; import { renameChainTest } from "./tests/rename-chain.test"; -import { serverPauseResumeTest } from "./tests/server-pause-resume.test"; -import { createMergeDeleteTest } from "./tests/create-merge-delete.test"; import { renameUpdateConflictTest } from "./tests/rename-update-conflict.test"; import { deleteRenameConflictTest } from "./tests/delete-rename-conflict.test"; import { multiFileOperationsTest } from "./tests/multi-file-operations.test"; -import { duplicateContentFilesTest } from "./tests/duplicate-content-files.test"; import { deleteRecreateSamePathTest } from "./tests/delete-recreate-same-path.test"; -import { rapidSyncToggleTest } from "./tests/rapid-sync-toggle.test"; -import { concurrentDeleteUpdateTest } from "./tests/concurrent-delete-update.test"; import { offlineRenameAndEditTest } from "./tests/offline-rename-and-edit.test"; -import { threeClientConvergenceTest } from "./tests/three-client-convergence.test"; -import { updateDuringServerPauseTest } from "./tests/update-during-server-pause.test"; -import { emptyFileSyncTest } from "./tests/empty-file-sync.test"; import { renameToExistingPathTest } from "./tests/rename-to-existing-path.test"; -import { concurrentRenameSameTargetTest } from "./tests/concurrent-rename-same-target.test"; -import { multipleUpdatesCoalesceTest } from "./tests/multiple-updates-coalesce.test"; -import { deleteNonexistentFileTest } from "./tests/delete-nonexistent-file.test"; -import { createWhileServerPausedTest } from "./tests/create-while-server-paused.test"; -import { interleavedOperationsTest } from "./tests/interleaved-operations.test"; import { simultaneousCreateDeleteSamePathTest } from "./tests/simultaneous-create-delete-same-path.test"; -import { largeFileCountTest } from "./tests/large-file-count.test"; -import { offlineOperationsBothClientsTest } from "./tests/offline-operations-both-clients.test"; -import { updateThenRenameTest } from "./tests/update-then-rename.test"; import { idempotencyAfterServerPauseTest } from "./tests/idempotency-after-server-pause.test"; -import { concurrentCreateSamePathMergeTest } from "./tests/concurrent-create-same-path-merge.test"; import { sequentialCreateDuplicateContentTest } from "./tests/sequential-create-duplicate-content.test"; -import { offlineMultiUpdateCatchupTest } from "./tests/offline-multi-update-catchup.test"; import { mcThreeClientRenameOfflineUpdateTest } from "./tests/mc-three-client-rename-offline-update.test"; import { mcMultiDeleteOfflineRenameTest } from "./tests/mc-multi-delete-offline-rename.test"; import { mcCrossCreateRenameSameTargetTest } from "./tests/mc-cross-create-rename-same-target.test"; import { mcDeleteThenOfflineRenameTest } from "./tests/mc-delete-then-offline-rename.test"; import { offlineMixedOperationsTest } from "./tests/offline-mixed-operations.test"; -import { offlineCreateRenameCreateTest } from "./tests/offline-create-rename-create.test"; import { offlineConcurrentRenamesTest } from "./tests/offline-concurrent-renames.test"; import { offlineMultipleEditsTest } from "./tests/offline-multiple-edits.test"; import { serverPauseBothClientsCreateTest } from "./tests/server-pause-both-clients-create.test"; -import { serverPauseRenameTest } from "./tests/server-pause-rename-propagation.test"; -import { serverPauseConcurrentCreatesTest } from "./tests/server-pause-concurrent-creates.test"; import { serverPauseUpdateAndCreateTest } from "./tests/server-pause-update-and-create.test"; import { renameSwapTest } from "./tests/rename-swap.test"; import { renameCircularTest } from "./tests/rename-circular.test"; -import { renameNestedPathTest } from "./tests/rename-nested-path.test"; import { renameRoundtripTest } from "./tests/rename-roundtrip.test"; import { offlineRenameRemoteCreateOldPathTest } from "./tests/offline-rename-remote-create-old-path.test"; import { offlineEditRemoteRenameTest } from "./tests/offline-edit-remote-rename.test"; import { renameChainThenDeleteTest } from "./tests/rename-chain-then-delete.test"; import { offlineDeleteRemoteRenameTest } from "./tests/offline-delete-remote-rename.test"; import { renameToRecentlyDeletedPathTest } from "./tests/rename-to-recently-deleted-path.test"; -import { createUpdateCoalesceServerPauseTest } from "./tests/create-update-coalesce-server-pause.test"; import { overlappingEditsSameSectionTest } from "./tests/overlapping-edits-same-section.test"; import { rapidUpdatesAfterMergeTest } from "./tests/rapid-updates-after-merge.test"; -import { offlineRenamePendingCreateTest } from "./tests/offline-rename-pending-create.test"; import { deleteRecreateConcurrentUpdateTest } from "./tests/delete-recreate-concurrent-update.test"; import { moveAndConcurrentRemoteUpdateTest } from "./tests/move-and-concurrent-remote-update.test"; import { offlineDeleteVsRemoteUpdateTest } from "./tests/offline-delete-vs-remote-update.test"; import { doubleOfflineCycleTest } from "./tests/double-offline-cycle.test"; -import { createRenameCreateSamePathTest } from "./tests/create-rename-create-same-path.test"; -import { concurrentEditExactSamePositionTest } from "./tests/concurrent-edit-exact-same-position.test"; import { serverPauseRenameEditResumeTest } from "./tests/server-pause-rename-edit-resume.test"; -import { renameTrackedToOccupiedPendingPathTest } from "./tests/rename-tracked-to-occupied-pending-path.test"; import { offlineUpdateBothThenDeleteOneTest } from "./tests/offline-update-both-then-delete-one.test"; -import { moveIdenticalContentAmbiguityTest } from "./tests/move-identical-content-ambiguity.test"; -import { coalesceUpdateRemoteUpdateDataLossTest } from "./tests/coalesce-update-remote-update-data-loss.test"; import { offlineCreateSamePathMergeableTest } from "./tests/offline-create-same-path-binary-conflict.test"; import { deleteDuringPendingCreateTest } from "./tests/delete-during-pending-create.test"; import { threeClientRenameCreateDeleteTest } from "./tests/three-client-rename-create-delete.test"; import { keyMigrationEventDropTest } from "./tests/key-migration-event-drop.test"; import { renameToPathOfUnconfirmedDeleteTest } from "./tests/rename-to-path-of-unconfirmed-delete.test"; import { offlineEditThenMoveSameContentTest } from "./tests/offline-edit-then-move-same-content.test"; -import { concurrentRenameAndCreateAtTargetTest } from "./tests/concurrent-rename-and-create-at-target.test"; -import { createRenameCreateSamePathOfflineTest } from "./tests/create-rename-create-same-path-offline.test"; import { rapidCreateUpdateDeleteCycleTest } from "./tests/rapid-create-update-delete-cycle.test"; import { serverPauseBothEditSameFileTest } from "./tests/server-pause-both-edit-same-file.test"; -import { reconcilePendingAtOccupiedPathTest } from "./tests/reconcile-pending-at-occupied-path.test"; -import { offlineRenameBothClientsSameSourceTest } from "./tests/offline-rename-both-clients-same-source.test"; -import { createDuringReconciliationTest } from "./tests/create-during-reconciliation.test"; import { deleteRecreateDifferentContentTest } from "./tests/delete-recreate-different-content.test"; -import { moveChainThreeFilesTest } from "./tests/move-chain-three-files.test"; import { updateDuringCreateProcessingTest } from "./tests/update-during-create-processing.test"; import { offlineMoveThenRemoteDeleteTest } from "./tests/offline-move-then-remote-delete.test"; import { resetClearsRecentlyDeletedResurrectionTest } from "./tests/reset-clears-recently-deleted-resurrection.test"; @@ -90,109 +53,64 @@ import { updateSurvivesRemoteDeleteTest } from "./tests/update-survives-remote-d import { movePreservesRemoteUpdateTest } from "./tests/move-preserves-remote-update.test"; import { recentlyDeletedClearedOnReconnectTest } from "./tests/recently-deleted-cleared-on-reconnect.test"; import { migrateKeyPreservesExistingTest } from "./tests/migrate-key-preserves-existing.test"; -import { userParenthesizedFileNotDeletedTest } from "./tests/user-parenthesized-file-not-deleted.test"; -import { concurrentUpdateDiffConsistencyTest } from "./tests/concurrent-update-diff-consistency.test"; -import { concurrentDeleteDuringRemoteUpdateTest } from "./tests/concurrent-delete-during-remote-update.test"; -import { binaryPendingCreateNotDisplacedTest } from "./tests/binary-pending-create-not-displaced.test"; import { failedVfsMoveFallsBackTest } from "./tests/failed-vfs-move-falls-back.test"; import { watermarkAdvancesOnSkipTest } from "./tests/watermark-advances-on-skip.test"; -import { remoteDeleteCoalesceLosesLocalUpdateTest } from "./tests/remote-delete-coalesce-loses-local-update.test"; -import { updateVsRemoteDeleteDataLossTest } from "./tests/update-vs-remote-delete-data-loss.test"; import { watermarkGapRemoteUpdateNotRecordedTest } from "./tests/watermark-gap-remote-update-not-recorded.test"; -import { renameEmptyFileLosesIdentityTest } from "./tests/rename-empty-file-loses-identity.test"; import { queueResetLosesCoalescedLocalEditTest } from "./tests/queue-reset-loses-coalesced-local-edit.test"; import { renameToPendingPathFallbackTest } from "./tests/rename-to-pending-path-fallback.test"; -import { coalescedRemoteUpdateWatermarkLossTest } from "./tests/coalesced-remote-update-watermark-loss.test"; import { moveRemoteUpdateRevertsRenameTest } from "./tests/move-remote-update-reverts-rename.test"; -import { createMergePreservesRenamedUpdateTest } from "./tests/create-merge-preserves-renamed-update.test"; import { localEditLostDuringCreateMergeTest } from "./tests/local-edit-lost-during-create-merge.test"; -import { concurrentBinaryCreateDeconflictionTest } from "./tests/concurrent-binary-create-deconfliction.test"; import { renamePendingCreateBeforeResponseTest } from "./tests/rename-pending-create-before-response.test"; import { createRenameResponseSkipsFileTest } from "./tests/create-rename-response-skips-file.test"; -import { staleDocOrphanDuplicateContentTest } from "./tests/stale-doc-orphan-duplicate-content.test"; +import { onlineCreateRenameConcurrentCreateOrphanTest } from "./tests/online-create-rename-concurrent-create-orphan.test"; +import { concurrentRenameFirstWinsTest } from "./tests/concurrent-rename-first-wins.test"; +import { binaryToTextTransitionTest } from "./tests/binary-to-text-transition.test"; export const TESTS: Partial> = { - "write-write-conflict": writeWriteConflictTest, "rename-create-conflict": renameCreateConflictTest, - "create-delete-noop": createDeleteNoopTest, "rename-chain": renameChainTest, - "server-pause-resume": serverPauseResumeTest, - "create-merge-delete": createMergeDeleteTest, "rename-update-conflict": renameUpdateConflictTest, "delete-rename-conflict": deleteRenameConflictTest, "multi-file-operations": multiFileOperationsTest, - "duplicate-content-files": duplicateContentFilesTest, "delete-recreate-same-path": deleteRecreateSamePathTest, - "rapid-sync-toggle": rapidSyncToggleTest, - "concurrent-delete-update": concurrentDeleteUpdateTest, "offline-rename-and-edit": offlineRenameAndEditTest, - "three-client-convergence": threeClientConvergenceTest, - "update-during-server-pause": updateDuringServerPauseTest, - "empty-file-sync": emptyFileSyncTest, "rename-to-existing-path": renameToExistingPathTest, - "concurrent-rename-same-target": concurrentRenameSameTargetTest, - "multiple-updates-coalesce": multipleUpdatesCoalesceTest, - "delete-nonexistent-file": deleteNonexistentFileTest, - "create-while-server-paused": createWhileServerPausedTest, - "interleaved-operations": interleavedOperationsTest, "simultaneous-create-delete-same-path": simultaneousCreateDeleteSamePathTest, - "large-file-count": largeFileCountTest, - "offline-operations-both-clients": offlineOperationsBothClientsTest, - "update-then-rename": updateThenRenameTest, "idempotency-after-server-pause": idempotencyAfterServerPauseTest, - "concurrent-create-same-path-merge": concurrentCreateSamePathMergeTest, "sequential-create-duplicate-content": sequentialCreateDuplicateContentTest, - "offline-multi-update-catchup": offlineMultiUpdateCatchupTest, "mc-three-client-rename-offline-update": mcThreeClientRenameOfflineUpdateTest, "mc-multi-delete-offline-rename": mcMultiDeleteOfflineRenameTest, "mc-cross-create-rename-same-target": mcCrossCreateRenameSameTargetTest, "mc-delete-then-offline-rename": mcDeleteThenOfflineRenameTest, "offline-mixed-operations": offlineMixedOperationsTest, - "offline-create-rename-create": offlineCreateRenameCreateTest, "offline-concurrent-renames": offlineConcurrentRenamesTest, "offline-multiple-edits": offlineMultipleEditsTest, "server-pause-both-clients-create": serverPauseBothClientsCreateTest, - "server-pause-rename-propagation": serverPauseRenameTest, - "server-pause-concurrent-creates": serverPauseConcurrentCreatesTest, "server-pause-update-and-create": serverPauseUpdateAndCreateTest, "rename-swap": renameSwapTest, "rename-circular": renameCircularTest, - "rename-nested-path": renameNestedPathTest, "rename-roundtrip": renameRoundtripTest, "offline-rename-remote-create-old-path": offlineRenameRemoteCreateOldPathTest, "offline-edit-remote-rename": offlineEditRemoteRenameTest, "rename-chain-then-delete": renameChainThenDeleteTest, "offline-delete-remote-rename": offlineDeleteRemoteRenameTest, "rename-to-recently-deleted-path": renameToRecentlyDeletedPathTest, - "create-update-coalesce-server-pause": createUpdateCoalesceServerPauseTest, "overlapping-edits-same-section": overlappingEditsSameSectionTest, "rapid-updates-after-merge": rapidUpdatesAfterMergeTest, - "offline-rename-pending-create": offlineRenamePendingCreateTest, "delete-recreate-concurrent-update": deleteRecreateConcurrentUpdateTest, "move-and-concurrent-remote-update": moveAndConcurrentRemoteUpdateTest, "double-offline-cycle": doubleOfflineCycleTest, - "create-rename-create-same-path": createRenameCreateSamePathTest, - "concurrent-edit-exact-same-position": concurrentEditExactSamePositionTest, "server-pause-rename-edit-resume": serverPauseRenameEditResumeTest, - "rename-tracked-to-occupied-pending-path": renameTrackedToOccupiedPendingPathTest, "offline-update-both-then-delete-one": offlineUpdateBothThenDeleteOneTest, - "move-identical-content-ambiguity": moveIdenticalContentAmbiguityTest, - "coalesce-update-remote-update-data-loss": coalesceUpdateRemoteUpdateDataLossTest, "offline-create-same-path-mergeable": offlineCreateSamePathMergeableTest, "delete-during-pending-create": deleteDuringPendingCreateTest, "three-client-rename-create-delete": threeClientRenameCreateDeleteTest, "key-migration-event-drop": keyMigrationEventDropTest, "rename-to-path-of-unconfirmed-delete": renameToPathOfUnconfirmedDeleteTest, "offline-edit-then-move-same-content": offlineEditThenMoveSameContentTest, - "concurrent-rename-and-create-at-target": concurrentRenameAndCreateAtTargetTest, - "create-rename-create-same-path-offline": createRenameCreateSamePathOfflineTest, "rapid-create-update-delete-cycle": rapidCreateUpdateDeleteCycleTest, "server-pause-both-edit-same-file": serverPauseBothEditSameFileTest, - "reconcile-pending-at-occupied-path": reconcilePendingAtOccupiedPathTest, - "offline-rename-both-clients-same-source": offlineRenameBothClientsSameSourceTest, - "create-during-reconciliation": createDuringReconciliationTest, "delete-recreate-different-content": deleteRecreateDifferentContentTest, - "move-chain-three-files": moveChainThreeFilesTest, "update-during-create-processing": updateDuringCreateProcessingTest, "offline-move-then-remote-delete": offlineMoveThenRemoteDeleteTest, "reset-clears-recently-deleted-resurrection": resetClearsRecentlyDeletedResurrectionTest, @@ -203,24 +121,16 @@ export const TESTS: Partial> = { "move-preserves-remote-update": movePreservesRemoteUpdateTest, "recently-deleted-cleared-on-reconnect": recentlyDeletedClearedOnReconnectTest, "migrate-key-preserves-existing": migrateKeyPreservesExistingTest, - "user-parenthesized-file-not-deleted": userParenthesizedFileNotDeletedTest, - "concurrent-update-diff-consistency": concurrentUpdateDiffConsistencyTest, - "concurrent-delete-during-remote-update": concurrentDeleteDuringRemoteUpdateTest, - "binary-pending-create-not-displaced": binaryPendingCreateNotDisplacedTest, "failed-vfs-move-falls-back": failedVfsMoveFallsBackTest, "watermark-advances-on-skip": watermarkAdvancesOnSkipTest, - "remote-delete-coalesce-loses-local-update": remoteDeleteCoalesceLosesLocalUpdateTest, - "update-vs-remote-delete-data-loss": updateVsRemoteDeleteDataLossTest, "watermark-gap-remote-update-not-recorded": watermarkGapRemoteUpdateNotRecordedTest, - "rename-empty-file-loses-identity": renameEmptyFileLosesIdentityTest, "queue-reset-loses-coalesced-local-edit": queueResetLosesCoalescedLocalEditTest, "rename-to-pending-path-fallback": renameToPendingPathFallbackTest, - "coalesced-remote-update-watermark-loss": coalescedRemoteUpdateWatermarkLossTest, "move-remote-update-reverts-rename": moveRemoteUpdateRevertsRenameTest, - "create-merge-preserves-renamed-update": createMergePreservesRenamedUpdateTest, "local-edit-lost-during-create-merge": localEditLostDuringCreateMergeTest, - "concurrent-binary-create-deconfliction": concurrentBinaryCreateDeconflictionTest, "rename-pending-create-before-response": renamePendingCreateBeforeResponseTest, "create-rename-response-skips-file": createRenameResponseSkipsFileTest, - "stale-doc-orphan-duplicate-content": staleDocOrphanDuplicateContentTest + "online-create-rename-concurrent-create-orphan": onlineCreateRenameConcurrentCreateOrphanTest, + "concurrent-rename-first-wins": concurrentRenameFirstWinsTest, + "binary-to-text-transition": binaryToTextTransitionTest, }; diff --git a/frontend/deterministic-tests/src/test-runner.ts b/frontend/deterministic-tests/src/test-runner.ts index d9a42fa0..05ac1611 100644 --- a/frontend/deterministic-tests/src/test-runner.ts +++ b/frontend/deterministic-tests/src/test-runner.ts @@ -1,13 +1,13 @@ import type { TestDefinition, TestResult, - TestStep, - ClientState + TestStep } from "./test-definition"; import { DeterministicAgent } from "./deterministic-agent"; import type { ServerControl } from "./server-control"; import type { SyncSettings, Logger } from "sync-client"; import { assert } from "./utils/assert"; +import { AssertableState } from "./utils/assertable-state"; import { sleep } from "./utils/sleep"; import { withTimeout } from "./utils/with-timeout"; import { @@ -37,9 +37,12 @@ export class TestRunner { this.remoteUri = remoteUri; } - public async runTest(test: TestDefinition): Promise { + public async runTest( + name: string, + test: TestDefinition + ): Promise { const startTime = Date.now(); - this.logger.info(`Running test: ${test.name}`); + this.logger.info(`Running test: ${name}`); if (test.description !== undefined && test.description !== "") { this.logger.info(`Description: ${test.description}`); } @@ -65,7 +68,7 @@ export class TestRunner { await this.cleanup(); const duration = Date.now() - startTime; - this.logger.info(`\n✓ Test passed: ${test.name} (${duration}ms)`); + this.logger.info(`\n✓ Test passed: ${name} (${duration}ms)`); return { success: true, @@ -75,7 +78,7 @@ export class TestRunner { const duration = Date.now() - startTime; const errorMessage = error instanceof Error ? error.message : String(error); - this.logger.info(`\n✗ Test failed: ${test.name}`); + this.logger.info(`\n✗ Test failed: ${name}`); this.logger.info(`Error: ${errorMessage}`); await this.cleanup(); @@ -192,21 +195,6 @@ export class TestRunner { await this.waitForConvergence(); break; - case "assert-content": - await this.getAgent(step.client).assertContent( - step.path, - step.content - ); - break; - - case "assert-exists": - await this.getAgent(step.client).assertExists(step.path); - break; - - case "assert-not-exists": - await this.getAgent(step.client).assertNotExists(step.path); - break; - case "assert-consistent": await this.assertConsistent(step.verify); break; @@ -263,17 +251,21 @@ export class TestRunner { } /** - * Wait for all agents to be simultaneously idle. Two full rounds are - * needed because completing work on agent A can trigger a server - * broadcast that enqueues new work on agent B, and vice versa. - * - * However, the 2nd sync may result in merges which can trigger another - * round of syncs, so this function should be called in a loop with a - * timeout to ensure true convergence rather than just waiting for the - * current round of syncs to complete. + * Wait for all agents to be simultaneously idle. + * + * Completing work on agent A can trigger a server broadcast that + * enqueues new work on agent B, which can cascade further. With N + * agents the worst-case cascade depth is N (a chain A→B→C→…→A), + * so we run N+1 sequential passes to drain it. Extra passes are + * essentially free when there is no outstanding work. + * + * The outer {@link waitForConvergence} loop with consistency checks + * remains the ultimate guarantee — this method just minimizes how + * many slow retry iterations are needed. */ private async waitAllAgentsSettled(): Promise { - for (let round = 0; round < 2; round++) { + const rounds = this.agents.length + 1; + for (let round = 0; round < rounds; round++) { for (const agent of this.agents) { await agent.waitForSync(); } @@ -281,47 +273,52 @@ export class TestRunner { } private async assertConsistent( - verify?: (state: ClientState) => void + verify?: (state: AssertableState) => void ): Promise { this.logger.info("Asserting all clients are consistent..."); assert(this.agents.length >= 2, "Need at least 2 agents for consistency check"); - const [referenceAgent] = this.agents; - const referenceFiles = (await referenceAgent.getFiles()).sort(); - const referenceState: ClientState = { files: new Map() }; - - for (const file of referenceFiles) { - const content = await referenceAgent.getFileContent(file); - referenceState.files.set(file, content); + // Snapshot all agents' file states upfront to minimize the window + // where background sync could mutate state between reads. + const clientFiles: Map[] = []; + for (const agent of this.agents) { + const sortedFiles = (await agent.getFiles()).sort(); + const fileMap = new Map(); + for (const file of sortedFiles) { + const content = await agent.getFileContent(file); + fileMap.set(file, content); + } + clientFiles.push(fileMap); } + const referenceFiles = Array.from(clientFiles[0].keys()); + this.logger.info( `Reference client has ${referenceFiles.length} files: ${referenceFiles.join(", ")}` ); - for (let i = 1; i < this.agents.length; i++) { - const agent = this.agents[i]; - const files = (await agent.getFiles()).sort(); + for (let i = 1; i < clientFiles.length; i++) { + const agentFileKeys = Array.from(clientFiles[i].keys()); this.logger.info( - `Client ${i} has ${files.length} files: ${files.join(", ")}` + `Client ${i} has ${agentFileKeys.length} files: ${agentFileKeys.join(", ")}` ); assert( - files.length === referenceFiles.length, - `File count mismatch: client 0 has ${referenceFiles.length} files, client ${i} has ${files.length} files` + agentFileKeys.length === referenceFiles.length, + `File count mismatch: client 0 has ${referenceFiles.length} files, client ${i} has ${agentFileKeys.length} files` ); - for (let j = 0; j < files.length; j++) { + for (let j = 0; j < agentFileKeys.length; j++) { assert( - files[j] === referenceFiles[j], - `File list mismatch at index ${j}: client 0 has "${referenceFiles[j]}", client ${i} has "${files[j]}"` + agentFileKeys[j] === referenceFiles[j], + `File list mismatch at index ${j}: client 0 has "${referenceFiles[j]}", client ${i} has "${agentFileKeys[j]}"` ); } for (const file of referenceFiles) { - const referenceContent = referenceState.files.get(file); - const agentContent = await agent.getFileContent(file); + const referenceContent = clientFiles[0].get(file); + const agentContent = clientFiles[i].get(file); assert( referenceContent === agentContent, @@ -335,7 +332,12 @@ export class TestRunner { if (verify) { this.logger.info("Running custom verification..."); try { - verify(referenceState); + verify( + new AssertableState({ + files: clientFiles[0], + clientFiles + }) + ); } catch (error) { const msg = error instanceof Error ? error.message : String(error); diff --git a/frontend/deterministic-tests/src/tests/1-text-pending-create-not-displaced.test.ts b/frontend/deterministic-tests/src/tests/1-text-pending-create-not-displaced.test.ts index 506e2b59..77f053ff 100644 --- a/frontend/deterministic-tests/src/tests/1-text-pending-create-not-displaced.test.ts +++ b/frontend/deterministic-tests/src/tests/1-text-pending-create-not-displaced.test.ts @@ -1,11 +1,9 @@ import type { TestDefinition } from "../test-definition"; -import type { AssertableState } from "../utils/assertable-state"; export const textPendingCreateNotDisplacedTest: TestDefinition = { - name: "Both offline binary creates at same path survive sync", description: - "Two clients each create a binary file at the same path while offline. " + - "After syncing, both files should exist on both clients at separate paths.", + "Two clients each create a text file at the same path while offline. " + + "After syncing, the file should contain merged content from both clients.", clients: 2, steps: [ { @@ -25,16 +23,6 @@ export const textPendingCreateNotDisplacedTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyBothFilesExist } + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertFileExists("data.txt").assertAnyFileContains("data from client 0", "data from client 1") } ] }; - -function verifyBothFilesExist(state: AssertableState): void { - state - .assertFileCount(1) - .assertFileExists("data.txt") - .assertAnyFileContains( - "data from client 0", - "data from client 1" - ); -} diff --git a/frontend/deterministic-tests/src/tests/10-concurrent-update-diff-consistency.test.ts b/frontend/deterministic-tests/src/tests/10-concurrent-update-diff-consistency.test.ts index baa8bc52..94e6914e 100644 --- a/frontend/deterministic-tests/src/tests/10-concurrent-update-diff-consistency.test.ts +++ b/frontend/deterministic-tests/src/tests/10-concurrent-update-diff-consistency.test.ts @@ -1,7 +1,6 @@ import type { TestDefinition } from "../test-definition"; export const concurrentUpdateDiffConsistencyTest: TestDefinition = { - name: "Concurrent edits to different sections merge correctly", description: "Both clients edit different sections of the same file while offline. " + "After syncing, the merged file should contain both edits.", diff --git a/frontend/deterministic-tests/src/tests/10-user-parenthesized-file-not-deleted.test.ts b/frontend/deterministic-tests/src/tests/10-user-parenthesized-file-not-deleted.test.ts new file mode 100644 index 00000000..8be438e2 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/10-user-parenthesized-file-not-deleted.test.ts @@ -0,0 +1,46 @@ +import type { TestDefinition } from "../test-definition"; + +export const userParenthesizedFileNotDeletedTest: TestDefinition = { + description: + "A user-created file named 'Chapter (1).bin' alongside 'Chapter.bin' should not " + + "be mistakenly removed when another client creates a conflicting file.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + + { + type: "create", + client: 0, + path: "Chapter.bin", + content: "chapter one" + }, + { + type: "create", + client: 0, + path: "Chapter (1).bin", + content: "chapter one notes" + }, + + { type: "sync", client: 0 }, + + { + type: "create", + client: 1, + path: "Chapter.bin", + content: "chapter one notes" + }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state) => { + state + .assertFileCount(3) + .assertFileExists("Chapter.bin") + .assertFileExists("Chapter (1).bin") + .assertFileExists("Chapter (2).bin"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/11-create-delete-noop.test.ts b/frontend/deterministic-tests/src/tests/11-create-delete-noop.test.ts index f575fc79..b1239217 100644 --- a/frontend/deterministic-tests/src/tests/11-create-delete-noop.test.ts +++ b/frontend/deterministic-tests/src/tests/11-create-delete-noop.test.ts @@ -1,7 +1,6 @@ import type { TestDefinition } from "../test-definition"; export const createDeleteNoopTest: TestDefinition = { - name: "Offline create then delete results in no file", description: "A client creates a file, updates it multiple times, then deletes it, all while " + "offline. After syncing, neither client should have the file.", @@ -17,8 +16,6 @@ export const createDeleteNoopTest: TestDefinition = { { type: "enable-sync", client: 0 }, { type: "barrier" }, - { type: "assert-not-exists", client: 0, path: "temp.md" }, - { type: "assert-not-exists", client: 1, path: "temp.md" }, - { type: "assert-consistent" } + { type: "assert-consistent", verify: (s) => s.assertFileNotExists("temp.md") } ] }; diff --git a/frontend/deterministic-tests/src/tests/12-create-merge-delete.test.ts b/frontend/deterministic-tests/src/tests/12-create-merge-delete.test.ts index 4a40b59f..4b121939 100644 --- a/frontend/deterministic-tests/src/tests/12-create-merge-delete.test.ts +++ b/frontend/deterministic-tests/src/tests/12-create-merge-delete.test.ts @@ -1,7 +1,6 @@ import type { TestDefinition } from "../test-definition"; export const createMergeDeleteTest: TestDefinition = { - name: "Concurrent Create, Merge, Then Delete", description: "Two clients create A.md offline with different content. Both come online and " + "the content is merged. Then one client deletes A.md. Both clients should " + @@ -23,8 +22,6 @@ export const createMergeDeleteTest: TestDefinition = { { type: "delete", client: 0, path: "A.md" }, { type: "barrier" }, - { type: "assert-not-exists", client: 0, path: "A.md" }, - { type: "assert-not-exists", client: 1, path: "A.md" }, - { type: "assert-consistent", verify: (state) => state.assertFileCount(0) } + { type: "assert-consistent", verify: (s) => s.assertFileCount(0).assertFileNotExists("A.md") } ] }; diff --git a/frontend/deterministic-tests/src/tests/13-move-identical-content-ambiguity.test.ts b/frontend/deterministic-tests/src/tests/13-move-identical-content-ambiguity.test.ts index 91a52496..9c0f7245 100644 --- a/frontend/deterministic-tests/src/tests/13-move-identical-content-ambiguity.test.ts +++ b/frontend/deterministic-tests/src/tests/13-move-identical-content-ambiguity.test.ts @@ -1,7 +1,6 @@ import type { TestDefinition } from "../test-definition"; export const moveIdenticalContentAmbiguityTest: TestDefinition = { - name: "Move Detection Ambiguity With Identical Content", description: "Two files with identical content exist. One is deleted and the other renamed " + "while offline. The system should still converge correctly despite the ambiguity.", @@ -23,19 +22,6 @@ export const moveIdenticalContentAmbiguityTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "barrier" }, - { - type: "assert-content", - client: 1, - path: "A.md", - content: "identical content" - }, - { - type: "assert-content", - client: 1, - path: "B.md", - content: "identical content" - }, - { type: "disable-sync", client: 1 }, { type: "delete", client: 1, path: "A.md" }, { type: "rename", client: 1, oldPath: "B.md", newPath: "C.md" }, diff --git a/frontend/deterministic-tests/src/tests/14-write-write-conflict.test.ts b/frontend/deterministic-tests/src/tests/14-write-write-conflict.test.ts deleted file mode 100644 index f51370a6..00000000 --- a/frontend/deterministic-tests/src/tests/14-write-write-conflict.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { TestDefinition } from "../test-definition"; - -export const writeWriteConflictTest: TestDefinition = { - name: "Write/Write Conflict", - description: - "Two clients simultaneously create the same file with different content. " + - "Both contributions should be preserved in the merged result without duplication.", - clients: 2, - steps: [ - { type: "create", client: 0, path: "A.md", content: "hello" }, - { type: "create", client: 1, path: "A.md", content: "hello" }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "barrier" }, - { - type: "assert-consistent", - verify: (state) => { - state - .assertFileCount(1) - .assertContent("A.md", "hello") - } - } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/15-create-update-coalesce-server-pause.test.ts b/frontend/deterministic-tests/src/tests/15-create-update-coalesce-server-pause.test.ts index 26931478..608f845d 100644 --- a/frontend/deterministic-tests/src/tests/15-create-update-coalesce-server-pause.test.ts +++ b/frontend/deterministic-tests/src/tests/15-create-update-coalesce-server-pause.test.ts @@ -1,7 +1,6 @@ import type { TestDefinition } from "../test-definition"; export const createUpdateCoalesceServerPauseTest: TestDefinition = { - name: "Create and Immediate Update While Server Is Paused", description: "Client creates a file and immediately updates it while the server is " + "paused. When the server resumes, both clients should have the final " + diff --git a/frontend/deterministic-tests/src/tests/16-create-during-reconciliation.test.ts b/frontend/deterministic-tests/src/tests/16-create-during-reconciliation.test.ts index 988832c5..54dc3f98 100644 --- a/frontend/deterministic-tests/src/tests/16-create-during-reconciliation.test.ts +++ b/frontend/deterministic-tests/src/tests/16-create-during-reconciliation.test.ts @@ -1,7 +1,6 @@ import type { TestDefinition } from "../test-definition"; export const createDuringReconciliationTest: TestDefinition = { - name: "File Created Right After Reconnect Syncs Correctly", description: "Client creates two files while offline, reconnects, then immediately " + "creates a third file. All three files should sync to the other client.", diff --git a/frontend/deterministic-tests/src/tests/17-create-merge-preserves-renamed-update.test.ts b/frontend/deterministic-tests/src/tests/17-create-merge-preserves-renamed-update.test.ts new file mode 100644 index 00000000..f600c40e --- /dev/null +++ b/frontend/deterministic-tests/src/tests/17-create-merge-preserves-renamed-update.test.ts @@ -0,0 +1,44 @@ +import type { TestDefinition } from "../test-definition"; + +export const createMergePreservesRenamedUpdateTest: TestDefinition = { + description: + "Both clients create the same file, which gets merged. One client goes " + + "offline, renames the file, updates it, and creates a new file at the " + + "original path. After reconnecting, the updated content must be preserved.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "doc.md", content: "alpha" }, + { type: "create", client: 1, path: "doc.md", content: "beta" }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 1 }, + + { + type: "rename", + client: 1, + oldPath: "doc.md", + newPath: "moved.md" + }, + { + type: "update", + client: 1, + path: "moved.md", + content: "alpha beta extra-update" + }, + + { + type: "create", + client: 1, + path: "doc.md", + content: "new-content" + }, + + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "assert-consistent", verify: (state) => state.assertContent("moved.md", "alpha beta extra-update").assertContent("doc.md", "new-content") } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/18-create-rename-create-same-path.test.ts b/frontend/deterministic-tests/src/tests/18-create-rename-create-same-path.test.ts new file mode 100644 index 00000000..2b169a1d --- /dev/null +++ b/frontend/deterministic-tests/src/tests/18-create-rename-create-same-path.test.ts @@ -0,0 +1,34 @@ +import type { TestDefinition } from "../test-definition"; + +export const createRenameCreateSamePathTest: TestDefinition = { + description: + "Client creates A.md, renames to B.md, creates new A.md, renames " + + "to C.md, creates yet another A.md. All three files should exist " + + "as separate documents on both clients.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "first file" }, + { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, + + { type: "create", client: 0, path: "A.md", content: "second file" }, + { type: "rename", client: 0, oldPath: "A.md", newPath: "C.md" }, + + { type: "create", client: 0, path: "A.md", content: "third file" }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "sync" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state) => { + state + .assertFileCount(3) + .assertContent("B.md", "first file") + .assertContent("C.md", "second file") + .assertContent("A.md", "third file"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/19-move-chain-three-files.test.ts b/frontend/deterministic-tests/src/tests/19-move-chain-three-files.test.ts new file mode 100644 index 00000000..a6c6851b --- /dev/null +++ b/frontend/deterministic-tests/src/tests/19-move-chain-three-files.test.ts @@ -0,0 +1,41 @@ +import type { TestDefinition } from "../test-definition"; + +export const moveChainThreeFilesTest: TestDefinition = { + description: + "Three files have their contents rotated (A gets C's content, B gets A's, C gets B's) " + + "while offline. After reconnecting, both clients should converge with the rotated contents.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + + { type: "create", client: 0, path: "A.md", content: "was A" }, + { type: "create", client: 0, path: "B.md", content: "was B" }, + { type: "create", client: 0, path: "C.md", content: "was C" }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + + { type: "delete", client: 0, path: "A.md" }, + { type: "delete", client: 0, path: "B.md" }, + { type: "delete", client: 0, path: "C.md" }, + + { type: "create", client: 0, path: "A.md", content: "was C" }, + { type: "create", client: 0, path: "B.md", content: "was A" }, + { type: "create", client: 0, path: "C.md", content: "was B" }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state) => { + state + .assertFileCount(3) + .assertContent("A.md", "was C") + .assertContent("B.md", "was A") + .assertContent("C.md", "was B"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/2-binary-pending-create-not-displaced.test.ts b/frontend/deterministic-tests/src/tests/2-binary-pending-create-not-displaced.test.ts index 5ad89cbe..0616136b 100644 --- a/frontend/deterministic-tests/src/tests/2-binary-pending-create-not-displaced.test.ts +++ b/frontend/deterministic-tests/src/tests/2-binary-pending-create-not-displaced.test.ts @@ -1,8 +1,6 @@ import type { TestDefinition } from "../test-definition"; -import type { AssertableState } from "../utils/assertable-state"; export const binaryPendingCreateNotDisplacedTest: TestDefinition = { - name: "Both offline binary creates at same path survive sync", description: "Two clients each create a binary file at the same path while offline. " + "After syncing, both files should exist on both clients at separate paths.", @@ -25,17 +23,6 @@ export const binaryPendingCreateNotDisplacedTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyBothFilesExist } + { type: "assert-consistent", verify: (s) => s.assertFileCount(2).assertFileExists("data.bin").assertFileExists("data (1).bin").assertAnyFileContains("binary data from client 0", "binary data from client 1") } ] }; - -function verifyBothFilesExist(state: AssertableState): void { - state - .assertFileCount(2) - .assertFileExists("data.bin") - .assertFileExists("data (1).bin") - .assertAnyFileContains( - "binary data from client 0", - "binary data from client 1" - ); -} diff --git a/frontend/deterministic-tests/src/tests/3-coalesce-update-remote-update-data-loss.test.ts b/frontend/deterministic-tests/src/tests/3-coalesce-update-remote-update-data-loss.test.ts index d66a2cf3..33fb8107 100644 --- a/frontend/deterministic-tests/src/tests/3-coalesce-update-remote-update-data-loss.test.ts +++ b/frontend/deterministic-tests/src/tests/3-coalesce-update-remote-update-data-loss.test.ts @@ -1,7 +1,6 @@ import type { TestDefinition } from "../test-definition"; export const coalesceUpdateRemoteUpdateDataLossTest: TestDefinition = { - name: "Local and remote edits to the same file are both preserved", description: "Client 0 edits a file while client 1 is offline. Client 1 reconnects " + "and immediately edits the same file. Both edits should be preserved.", diff --git a/frontend/deterministic-tests/src/tests/4-coalesced-remote-update-watermark-loss.test.ts b/frontend/deterministic-tests/src/tests/4-coalesced-remote-update-watermark-loss.test.ts index 2d8fd4b6..15fe3e82 100644 --- a/frontend/deterministic-tests/src/tests/4-coalesced-remote-update-watermark-loss.test.ts +++ b/frontend/deterministic-tests/src/tests/4-coalesced-remote-update-watermark-loss.test.ts @@ -1,29 +1,24 @@ import type { TestDefinition } from "../test-definition"; -import type { AssertableState } from "../utils/assertable-state"; export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = { - name: "Coalesced Remote Updates Lose Earlier vaultUpdateIds", description: - "When multiple remote-update events for the same document coalesce, " + - "only the last vaultUpdateId is recorded. Earlier IDs create " + - "permanent watermark gaps that cause unnecessary server replays " + - "on every reconnect.", + "Client 0 sends three rapid updates. After syncing, both clients " + + "disconnect and reconnect twice. Content should remain correct " + + "after each reconnect.", clients: 2, steps: [ - // Setup: both clients have doc.md { type: "create", client: 0, path: "doc.md", content: "original" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "barrier" }, - // Client 0 sends three rapid updates { 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: "sync", client: 0 }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyContent }, + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("doc.md", "final update") }, { type: "disable-sync", client: 0 }, { type: "disable-sync", client: 1 }, @@ -31,18 +26,13 @@ export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyContent }, + { type: "assert-consistent", verify: (s) => 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: verifyContent } + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("doc.md", "final update") } ] }; - - -function verifyContent(state: AssertableState): void { - state.assertFileCount(1).assertContent("doc.md", "final update"); -} diff --git a/frontend/deterministic-tests/src/tests/5-concurrent-delete-during-remote-update.test.ts b/frontend/deterministic-tests/src/tests/5-concurrent-delete-during-remote-update.test.ts index 1a4014ac..3108ecfe 100644 --- a/frontend/deterministic-tests/src/tests/5-concurrent-delete-during-remote-update.test.ts +++ b/frontend/deterministic-tests/src/tests/5-concurrent-delete-during-remote-update.test.ts @@ -1,8 +1,6 @@ -import { AssertableState } from "src/utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const concurrentDeleteDuringRemoteUpdateTest: TestDefinition = { - name: "Delete and remote update of same file do not crash", description: "One client updates a file while the other deletes it at the same " + "time. Both clients should converge without errors.", diff --git a/frontend/deterministic-tests/src/tests/6-concurrent-edit-exact-same-position.test.ts b/frontend/deterministic-tests/src/tests/6-concurrent-edit-exact-same-position.test.ts index 93cc6fc3..08778488 100644 --- a/frontend/deterministic-tests/src/tests/6-concurrent-edit-exact-same-position.test.ts +++ b/frontend/deterministic-tests/src/tests/6-concurrent-edit-exact-same-position.test.ts @@ -1,7 +1,6 @@ import type { TestDefinition } from "../test-definition"; export const concurrentEditExactSamePositionTest: TestDefinition = { - name: "Concurrent edits to the exact same word are both preserved", description: "Both clients replace the same word in a file with different text " + "while offline. After syncing, the merged result should contain " + @@ -17,12 +16,6 @@ export const concurrentEditExactSamePositionTest: TestDefinition = { { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "barrier" }, - { - type: "assert-content", - client: 1, - path: "doc.md", - content: "the quick brown fox" - }, { type: "disable-sync", client: 0 }, { type: "disable-sync", client: 1 }, diff --git a/frontend/deterministic-tests/src/tests/7-concurrent-rename-and-create-at-target.test.ts b/frontend/deterministic-tests/src/tests/7-concurrent-rename-and-create-at-target.test.ts index 7c08b392..3e71ed7d 100644 --- a/frontend/deterministic-tests/src/tests/7-concurrent-rename-and-create-at-target.test.ts +++ b/frontend/deterministic-tests/src/tests/7-concurrent-rename-and-create-at-target.test.ts @@ -1,7 +1,6 @@ import type { TestDefinition } from "../test-definition"; export const concurrentRenameAndCreateAtTargetTest: TestDefinition = { - name: "Rename to path where another client creates a file", description: "One client renames X to Y while another creates a new file at Y, " + "both offline. After syncing, Y should contain merged content from " + diff --git a/frontend/deterministic-tests/src/tests/8-concurrent-rename-and-create-at-target.test.ts b/frontend/deterministic-tests/src/tests/8-concurrent-rename-and-create-at-target.test.ts index 4cd7c1d9..9f0b0318 100644 --- a/frontend/deterministic-tests/src/tests/8-concurrent-rename-and-create-at-target.test.ts +++ b/frontend/deterministic-tests/src/tests/8-concurrent-rename-and-create-at-target.test.ts @@ -1,7 +1,6 @@ import type { TestDefinition } from "../test-definition"; export const concurrentRenameAndCreateAtTargetTest: TestDefinition = { - name: "Rename to path where another client creates a file", description: "One client renames X to Y while another creates a new file at Y, " + "both offline. After syncing, Y should contain merged content from " + diff --git a/frontend/deterministic-tests/src/tests/9-concurrent-rename-same-target.test.ts b/frontend/deterministic-tests/src/tests/9-concurrent-rename-same-target.test.ts index e0419a47..230c7a1d 100644 --- a/frontend/deterministic-tests/src/tests/9-concurrent-rename-same-target.test.ts +++ b/frontend/deterministic-tests/src/tests/9-concurrent-rename-same-target.test.ts @@ -1,7 +1,6 @@ import type { TestDefinition } from "../test-definition"; export const concurrentRenameSameTargetTest: TestDefinition = { - name: "Two clients rename different files to the same target path", description: "One client renames A to C while the other renames B to C, both offline. " + "After syncing, both file contents should be preserved via path deconfliction.", diff --git a/frontend/deterministic-tests/src/tests/binary-to-text-transition.test.ts b/frontend/deterministic-tests/src/tests/binary-to-text-transition.test.ts new file mode 100644 index 00000000..d6e9d43f --- /dev/null +++ b/frontend/deterministic-tests/src/tests/binary-to-text-transition.test.ts @@ -0,0 +1,47 @@ +import type { TestDefinition } from "../test-definition"; + +export const binaryToTextTransitionTest: TestDefinition = { + description: + "A .bin file is created and synced. Both clients edit it offline, " + + "then it is renamed to .md. Both clients edit different sections " + + "offline again. The second merge should preserve both edits.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "data.bin", content: "original content" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + { type: "assert-consistent", verify: (s) => s.assertContent("data.bin", "original content") }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + + { type: "update", client: 0, path: "data.bin", content: "version A from client 0" }, + { type: "update", client: 1, path: "data.bin", content: "version B from client 1" }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContainsAny("data.bin", "version A from client 0", "version B from client 1") }, + + { type: "disable-sync", client: 1 }, + { type: "rename", client: 0, oldPath: "data.bin", newPath: "data.md" }, + { type: "sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + { type: "assert-consistent", verify: (s) => s.assertFileExists("data.md") }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + + { type: "update", client: 0, path: "data.md", content: "top edit from 0\nmiddle line\nshared end" }, + { type: "update", client: 1, path: "data.md", content: "shared start\nmiddle line\nbottom edit from 1" }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("data.md", "top edit from 0", "bottom edit from 1") }, + ], +}; diff --git a/frontend/deterministic-tests/src/tests/concurrent-rename-first-wins.test.ts b/frontend/deterministic-tests/src/tests/concurrent-rename-first-wins.test.ts new file mode 100644 index 00000000..1dddcf7a --- /dev/null +++ b/frontend/deterministic-tests/src/tests/concurrent-rename-first-wins.test.ts @@ -0,0 +1,36 @@ +import type { TestDefinition } from "../test-definition"; + +export const concurrentRenameFirstWinsTest: TestDefinition = { + description: + "Both clients start online with the same file. Both go offline, " + + "rename the file to different paths, and edit it. When they reconnect, " + + "the first rename to reach the server wins the path and both content " + + "edits are merged.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "line 1\nline 2\nline 3" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + { type: "assert-consistent", verify: (s) => s.assertContent("A.md", "line 1\nline 2\nline 3") }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + + { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, + { type: "update", client: 0, path: "B.md", content: "edit from 0\nline 2\nline 3" }, + + { type: "rename", client: 1, oldPath: "A.md", newPath: "C.md" }, + { type: "update", client: 1, path: "C.md", content: "line 1\nline 2\nedit from 1" }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "assert-consistent", verify: (s) => { + s.assertFileNotExists("A.md"); + s.assertFileCount(1); + s.assertAnyFileContains("edit from 0", "edit from 1"); + } }, + ], +}; diff --git a/frontend/deterministic-tests/src/tests/create-merge-delete.test.ts b/frontend/deterministic-tests/src/tests/create-merge-delete.test.ts deleted file mode 100644 index ef29a279..00000000 --- a/frontend/deterministic-tests/src/tests/create-merge-delete.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyMergedContent(state: ClientState): void { - assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); - assert(state.files.has("A.md"), "Expected A.md to exist"); - const content = state.files.get("A.md") ?? ""; - assert( - content.includes("from-zero") && content.includes("from-one"), - `Expected A.md to contain both "from-zero" and "from-one", got: "${content}"` - ); -} - -function verifyEmpty(state: ClientState): void { - assert( - state.files.size === 0, - `Expected 0 files after deletion, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); -} - -export const createMergeDeleteTest: TestDefinition = { - name: "Concurrent Create, Merge, Then Delete", - description: - "Two clients simultaneously create A.md with different content. " + - "The server merges them and both converge. Then Client 0 deletes A.md. " + - "Both clients should converge on an empty state.", - clients: 2, - steps: [ - // Both clients create A.md offline with different content - { type: "create", client: 0, path: "A.md", content: "from-zero" }, - { type: "create", client: 1, path: "A.md", content: "from-one" }, - - // Enable sync — both creates race to the server - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Phase 1: verify merge happened correctly - { type: "assert-consistent", verify: verifyMergedContent }, - - // Phase 2: Client 0 deletes the merged file - { type: "delete", client: 0, path: "A.md" }, - { type: "sync" }, - { type: "barrier" }, - - // Both clients should have no files - { type: "assert-not-exists", client: 0, path: "A.md" }, - { type: "assert-not-exists", client: 1, path: "A.md" }, - { type: "assert-consistent", verify: verifyEmpty } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/create-merge-preserves-renamed-update.test.ts b/frontend/deterministic-tests/src/tests/create-merge-preserves-renamed-update.test.ts deleted file mode 100644 index ef70c6bd..00000000 --- a/frontend/deterministic-tests/src/tests/create-merge-preserves-renamed-update.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG FIX: When a create-merge returns an existing documentId, the stale - * tracked record at a different path must NOT have its file deleted if the - * file contains unsynchronized local modifications. - * - * Scenario (simplified from E2E log_4 failure): - * 1. Both clients create "doc.md" → server merges → both have docX - * 2. Client 1 goes offline, renames "doc.md" → "moved.md", updates it - * 3. Client 1 also creates a new file at the OLD path "doc.md" - * 4. Client 1 comes back online - * 5. The update at "doc.md" sends new content to the server (overwriting docX) - * 6. The create for "moved.md" may merge on the server - * 7. The content appended in step 2 must still be present somewhere - * - * Previously, ensureUniqueDocumentId would delete the renamed file even - * if it had unsynchronized local modifications, silently losing data. - */ -function verifyAllContentPreserved(state: ClientState): void { - const allContent = [...state.files.values()].join("\n"); - assert( - allContent.includes("extra-update"), - `Expected "extra-update" to be preserved somewhere in the files, but got:\n${[...state.files.entries()].map(([k, v]) => ` ${k}: "${v}"`).join("\n")}` - ); -} - -export const createMergePreservesRenamedUpdateTest: TestDefinition = { - name: "Create-Merge Preserves Renamed File With Local Updates", - description: - "When a create request merges with an existing document, " + - "a renamed copy of that document with unsynchronized updates " + - "must not be deleted.", - clients: 2, - steps: [ - // Setup: both clients create at the same path → server merges - { type: "create", client: 0, path: "doc.md", content: "alpha" }, - { type: "create", client: 1, path: "doc.md", content: "beta" }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Client 1 goes offline and makes local changes - { type: "disable-sync", client: 1 }, - - // Rename the merged doc to a new path and update it - { - type: "rename", - client: 1, - oldPath: "doc.md", - newPath: "moved.md" - }, - { - type: "update", - client: 1, - path: "moved.md", - content: "alpha beta extra-update" - }, - - // Create a new file at the original path - { - type: "create", - client: 1, - path: "doc.md", - content: "new-content" - }, - - // Come back online — the reconciliation will detect: - // - "doc.md" in VFS (tracked) but with different content → update - // - "moved.md" not in VFS → create - // The create for "moved.md" may merge with the server's doc, - // triggering ensureUniqueDocumentId - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Verify: "extra-update" must still exist in some file - { type: "assert-consistent", verify: verifyAllContentPreserved } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/create-rename-create-same-path.test.ts b/frontend/deterministic-tests/src/tests/create-rename-create-same-path.test.ts deleted file mode 100644 index 7f82c7ab..00000000 --- a/frontend/deterministic-tests/src/tests/create-rename-create-same-path.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyThreeFiles(state: ClientState): void { - const files = Array.from(state.files.keys()).sort(); - assert( - state.files.size === 3, - `Expected 3 files, got ${state.files.size}: ${files.join(", ")}` - ); - assert( - state.files.has("B.md"), - `Expected B.md (first file renamed), got: ${files.join(", ")}` - ); - assert( - state.files.has("C.md"), - `Expected C.md (second file renamed), got: ${files.join(", ")}` - ); - assert( - state.files.has("A.md"), - `Expected A.md (third file still at original path), got: ${files.join(", ")}` - ); - - const bContent = state.files.get("B.md") ?? ""; - const cContent = state.files.get("C.md") ?? ""; - const aContent = state.files.get("A.md") ?? ""; - assert( - bContent === "first file", - `Expected B.md to contain "first file", got: "${bContent}"` - ); - assert( - cContent === "second file", - `Expected C.md to contain "second file", got: "${cContent}"` - ); - assert( - aContent === "third file", - `Expected A.md to contain "third file", got: "${aContent}"` - ); -} - -/** - * BUG: Tests the queue key migration for pending creates. When a file - * is created at path A, then renamed to B (freeing path A), then a new - * file is created at A, the event coalescing must migrate the first - * create's key from "path:A" to "path:B" so the second create doesn't - * coalesce with the first. - * - * Without key migration (lines 54-68 in sync-event-queue.ts), the - * second create at "path:A" would find the first create's state and - * coalesce with it, losing the second file. - */ -export const createRenameCreateSamePathTest: TestDefinition = { - name: "Create-Rename-Create at Same Path (Three Files)", - description: - "Client creates A.md, renames to B.md, creates new A.md, renames " + - "to C.md, creates yet another A.md. All three files should exist " + - "as separate documents. Tests queue key migration when pending " + - "creates are renamed before sync.", - clients: 2, - steps: [ - // Create first file at A.md, rename to B.md - { type: "create", client: 0, path: "A.md", content: "first file" }, - { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, - - // Create second file at A.md (now free), rename to C.md - { type: "create", client: 0, path: "A.md", content: "second file" }, - { type: "rename", client: 0, oldPath: "A.md", newPath: "C.md" }, - - // Create third file at A.md - { type: "create", client: 0, path: "A.md", content: "third file" }, - - // Enable sync - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // All three files should exist on both clients - { type: "assert-consistent", verify: verifyThreeFiles } - ] -}; 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 4d0bf2a6..5bec2bcb 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 @@ -1,46 +1,16 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * Regression guard for the create+rename race from e2e log_4.log. - * - * In the e2e test, timing jitter caused the HTTP response to arrive - * between the create and rename being coalesced by the sync queue, - * orphaning the document. This is documented in CLAUDE.md as a known - * limitation of concurrent creates at the same path. - * - * The deterministic test framework serializes steps, so the event - * coalescing correctly handles the create+rename sequence here. - * This test serves as a regression guard — if the coalescing logic - * changes, this test will catch regressions. - */ -function verifyBothClientsHaveContent(state: ClientState): void { - assert( - state.files.size === 1, - `Expected exactly 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - const [content] = Array.from(state.files.values()); - assert( - content === "the-content", - `Expected file to have "the-content", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const createRenameResponseSkipsFileTest: TestDefinition = { - name: "Create Then Immediate Rename — File Not Lost", description: - "Client creates a file online then immediately renames it. " + - "The create response arrives at the original path. " + - "The other client must receive the file content.", + "Client 0 creates a file online then immediately renames it. " + + "Client 1 must receive the file content at the renamed path.", clients: 2, steps: [ - // Both clients online { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Client 0 creates doc.md while online (HTTP request fires immediately) { type: "create", client: 0, @@ -48,7 +18,6 @@ export const createRenameResponseSkipsFileTest: TestDefinition = { content: "the-content" }, - // Immediately rename — the create request is already in-flight { type: "rename", client: 0, @@ -56,12 +25,10 @@ export const createRenameResponseSkipsFileTest: TestDefinition = { newPath: "renamed.md" }, - // Let everything sync { type: "sync" }, { type: "sync" }, { type: "barrier" }, - // Both clients must have the content (at whatever path) - { type: "assert-consistent", verify: verifyBothClientsHaveContent } + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertAnyFileContains("the-content") } ] }; diff --git a/frontend/deterministic-tests/src/tests/create-while-server-paused.test.ts b/frontend/deterministic-tests/src/tests/create-while-server-paused.test.ts deleted file mode 100644 index 25badba4..00000000 --- a/frontend/deterministic-tests/src/tests/create-while-server-paused.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { TestDefinition } from "../test-definition"; - -export const createWhileServerPausedTest: TestDefinition = { - name: "Create While Server Paused Then Resume", - description: - "Server is paused. Client 0 creates a file (request will stall). " + - "Then server resumes. File should sync to Client 1.", - clients: 2, - steps: [ - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Pause server first, then create - { type: "pause-server" }, - { type: "create", client: 0, path: "paused-create.md", content: "created during pause" }, - { type: "resume-server" }, - - { type: "sync" }, - { type: "barrier" }, - - { type: "assert-exists", client: 0, path: "paused-create.md" }, - { type: "assert-exists", client: 1, path: "paused-create.md" }, - { - type: "assert-content", - client: 1, - path: "paused-create.md", - content: "created during pause" - }, - { type: "assert-consistent" } - ] -}; 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 new file mode 100644 index 00000000..204e9896 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/delete-by-other-client-then-recreate.test.ts @@ -0,0 +1,24 @@ +import type { TestDefinition } from "../test-definition"; + +export const deleteByOtherClientThenRecreateTest: TestDefinition = { + description: + "Client 1 deletes a file and the delete propagates. Then client 0 " + + "creates a new file at the same path. Both clients must have the file.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "original" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "delete", client: 1, path: "A.md" }, + { type: "barrier" }, + + { type: "assert-consistent", verify: (s) => s.assertFileNotExists("A.md") }, + + { type: "create", client: 0, path: "A.md", content: "recreated by client 0" }, + { type: "barrier" }, + + { type: "assert-consistent", verify: (s) => s.assertContent("A.md", "recreated by client 0") }, + ], +}; diff --git a/frontend/deterministic-tests/src/tests/delete-during-pending-create.test.ts b/frontend/deterministic-tests/src/tests/delete-during-pending-create.test.ts index 712215c7..f6236060 100644 --- a/frontend/deterministic-tests/src/tests/delete-during-pending-create.test.ts +++ b/frontend/deterministic-tests/src/tests/delete-during-pending-create.test.ts @@ -1,34 +1,9 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * EDGE CASE: File deleted locally while a create request is in-flight. - * - * The create request succeeds on the server, but by the time - * applyServerResponse runs, the document has been removed from pathIndex - * (deleted locally). The code at sync-actions.ts line 256-283 handles this: - * it confirms the create (so the server has a documentId), then immediately - * marks it as deleted-locally so the delete can be sent to the server. - * - * This test verifies that: - * 1. The file is properly deleted on both clients - * 2. No orphaned documents exist on the server - * 3. No duplicate documentIds in the VFS - */ -function verifyNoFiles(state: ClientState): void { - assert( - state.files.size === 0, - `Expected 0 files, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); -} +import type { TestDefinition } from "../test-definition"; export const deleteDuringPendingCreateTest: TestDefinition = { - name: "Delete During Pending Create (Server Paused)", description: - "Client creates a file, server is paused so the create request stalls. " + - "Client then deletes the file while the create is in-flight. When the " + - "server resumes, the create succeeds but the file should still end up " + - "deleted on both clients.", + "Client 0 creates a file while the server is paused, then deletes it before the server resumes. " + + "After resume, the file should end up deleted on both clients.", clients: 2, steps: [ { type: "enable-sync", client: 0 }, @@ -36,10 +11,8 @@ export const deleteDuringPendingCreateTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Pause server so the create request stalls { type: "pause-server" }, - // Client 0 creates a file (HTTP request will stall) { type: "create", client: 0, @@ -47,19 +20,12 @@ export const deleteDuringPendingCreateTest: TestDefinition = { content: "this will be deleted" }, - // Wait a bit to ensure the create is queued - - // Client 0 deletes the file while create is pending { type: "delete", client: 0, path: "ephemeral.md" }, - // Resume server — the create request completes, then delete follows { type: "resume-server" }, { type: "sync" }, { type: "barrier" }, - // File should be gone on both clients - { type: "assert-not-exists", client: 0, path: "ephemeral.md" }, - { type: "assert-not-exists", client: 1, path: "ephemeral.md" }, - { type: "assert-consistent", verify: verifyNoFiles } + { type: "assert-consistent", verify: (s) => s.assertFileCount(0).assertFileNotExists("ephemeral.md") } ] }; 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 080f0810..c95c6aa4 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 @@ -1,48 +1,21 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyConvergence(state: ClientState): void { - const files = Array.from(state.files.keys()).sort(); - - // A.md should exist — the recreate creates a new document - assert( - state.files.has("A.md"), - `Expected A.md to exist. Files: ${files.join(", ")}` - ); - - const content = state.files.get("A.md") ?? ""; - - // The recreated content must be present. Client 1's update targeted - // the old (deleted) document, so it may also appear if the server - // merged both — but at minimum the recreated content must survive. - assert( - content.includes("recreated"), - `Expected A.md to contain "recreated" from client 0's recreate, got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const deleteRecreateConcurrentUpdateTest: TestDefinition = { - name: "Delete + Recreate with Concurrent Remote Update", description: - "Client 0 deletes A.md and recreates it with new content while offline. " + - "Client 1 (online) updates A.md with different content. When Client 0 " + - "reconnects, the system must reconcile the delete-recreate with the " + - "concurrent update. Both clients must converge.", + "Client 0 deletes and recreates A.md with new content while offline. Client 1 updates A.md concurrently. " + + "After client 0 reconnects, both clients must converge with client 0's recreated content preserved.", clients: 2, steps: [ - // Setup { type: "create", client: 0, path: "A.md", content: "original" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Client 0 goes offline, deletes and recreates { type: "disable-sync", client: 0 }, { type: "delete", client: 0, path: "A.md" }, { type: "create", client: 0, path: "A.md", content: "recreated by client 0" }, - // Client 1 updates the same file concurrently { type: "update", client: 1, @@ -51,12 +24,10 @@ export const deleteRecreateConcurrentUpdateTest: TestDefinition = { }, { type: "sync", client: 1 }, - // Client 0 reconnects { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - // Both clients must converge - { type: "assert-consistent", verify: verifyConvergence } + { type: "assert-consistent", verify: (s) => s.assertFileExists("A.md").assertContains("A.md", "recreated") } ] }; diff --git a/frontend/deterministic-tests/src/tests/delete-recreate-different-content.test.ts b/frontend/deterministic-tests/src/tests/delete-recreate-different-content.test.ts index 87e8075a..fd483419 100644 --- a/frontend/deterministic-tests/src/tests/delete-recreate-different-content.test.ts +++ b/frontend/deterministic-tests/src/tests/delete-recreate-different-content.test.ts @@ -1,55 +1,11 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * EDGE CASE: Delete and immediately recreate at the same path with - * different content, while the other client is editing. - * - * This exercises the coalescing path: delete + create = create. - * But the tricky part is that the ORIGINAL document at this path - * was tracked (had a documentId). The delete marks it as deleted-locally. - * The subsequent create makes a NEW pending document at the same path. - * - * Meanwhile, Client 1 has been editing the same file. When both sync: - * - Client 0's delete should go through first - * - Client 0's create creates a NEW document on the server - * - Client 1's edit to the OLD document may conflict - * - * The coalescing turns delete+create into just "create". But the executor - * for "create" at sync-actions.ts line 247 checks the VFS: if a tracked - * doc exists at the path, it treats the create as an update instead. - * Since the delete was coalesced away, the tracked doc STILL exists - * in the VFS at the time of execution → the "create" is treated as an - * update to the existing document, not a new document. - * - * This might be correct (updates the existing doc with new content) or - * might be a bug (should create a new documentId). The test verifies - * convergence either way. - */ -function verifyFinalState(state: ClientState): void { - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - assert(state.files.has("A.md"), "Expected A.md to exist"); - const content = state.files.get("A.md") ?? ""; - // Both client contents should be merged (empty-parent 3-way merge) - assert( - content.includes("brand new content") && - content.includes("edit from client 1"), - `Expected merged content with both edits, got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const deleteRecreateDifferentContentTest: TestDefinition = { - name: "Delete + Recreate Same Path While Other Client Edits", description: - "Client 0 deletes and recreates A.md with new content while " + - "Client 1 edits A.md. The coalesced delete+create should produce " + - "correct behavior and both clients should converge.", + "Client 0 deletes and recreates A.md with new content offline while client 1 edits A.md offline. " + + "Both clients should converge with content from both sides merged.", clients: 2, steps: [ - // Setup: create A.md { type: "create", client: 0, @@ -61,11 +17,9 @@ export const deleteRecreateDifferentContentTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Both go offline { type: "disable-sync", client: 0 }, { type: "disable-sync", client: 1 }, - // Client 0: delete and recreate with new content { type: "delete", client: 0, path: "A.md" }, { type: "create", @@ -74,7 +28,6 @@ export const deleteRecreateDifferentContentTest: TestDefinition = { content: "brand new content" }, - // Client 1: edit the same file { type: "update", client: 1, @@ -82,13 +35,12 @@ export const deleteRecreateDifferentContentTest: TestDefinition = { content: "edit from client 1" }, - // Reconnect both { type: "enable-sync", client: 0 }, { type: "sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyFinalState } + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("A.md", "brand new content", "edit from client 1") } ] }; diff --git a/frontend/deterministic-tests/src/tests/delete-recreate-same-path.test.ts b/frontend/deterministic-tests/src/tests/delete-recreate-same-path.test.ts index e9e6116c..10b00f70 100644 --- a/frontend/deterministic-tests/src/tests/delete-recreate-same-path.test.ts +++ b/frontend/deterministic-tests/src/tests/delete-recreate-same-path.test.ts @@ -1,21 +1,18 @@ import type { TestDefinition } from "../test-definition"; export const deleteRecreateSamePathTest: TestDefinition = { - name: "Delete Then Recreate at Same Path", description: "Client 0 creates A.md, syncs. Then deletes A.md and creates a new A.md " + "with different content. Both clients should converge on the new content.", clients: 2, steps: [ - // Setup: create and sync A.md { type: "create", client: 0, path: "A.md", content: "version 1" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-content", client: 1, path: "A.md", content: "version 1" }, + { type: "assert-consistent", verify: (s) => s.assertContent("A.md", "version 1") }, - // Client 0 deletes then recreates A.md with new content { type: "disable-sync", client: 0 }, { type: "delete", client: 0, path: "A.md" }, { type: "create", client: 0, path: "A.md", content: "version 2" }, @@ -23,21 +20,6 @@ export const deleteRecreateSamePathTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Both clients should have the new content - { type: "assert-exists", client: 0, path: "A.md" }, - { type: "assert-exists", client: 1, path: "A.md" }, - { - type: "assert-content", - client: 0, - path: "A.md", - content: "version 2" - }, - { - type: "assert-content", - client: 1, - path: "A.md", - content: "version 2" - }, - { type: "assert-consistent" } + { type: "assert-consistent", verify: (s) => s.assertContent("A.md", "version 2") } ] }; diff --git a/frontend/deterministic-tests/src/tests/delete-rename-conflict.test.ts b/frontend/deterministic-tests/src/tests/delete-rename-conflict.test.ts index aae562bf..4cbeed25 100644 --- a/frontend/deterministic-tests/src/tests/delete-rename-conflict.test.ts +++ b/frontend/deterministic-tests/src/tests/delete-rename-conflict.test.ts @@ -1,71 +1,34 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyConflictResolution(state: ClientState): void { - const files = Array.from(state.files.keys()); - - // B.md must exist (unaffected by the conflict) - assert( - state.files.has("B.md"), - `Expected B.md to exist, got: ${files.join(", ")}` - ); - assert( - state.files.get("B.md") === "content-b", - `Expected B.md to have "content-b", got: "${state.files.get("B.md")}"` - ); - - // A.md should not exist (either deleted or renamed away) - assert( - !state.files.has("A.md"), - `A.md should not exist after conflict resolution, got: ${files.join(", ")}` - ); - - // If C.md exists (rename won over delete), it should have content-a - if (state.files.has("C.md")) { - assert( - state.files.get("C.md") === "content-a", - `If C.md exists, it should have "content-a", got: "${state.files.get("C.md")}"` - ); - } -} +import type { TestDefinition } from "../test-definition"; export const deleteRenameConflictTest: TestDefinition = { - name: "Delete vs Rename Conflict", description: - "Client 0 deletes A.md while Client 1 (offline) renames A.md to C.md. " + - "When Client 1 reconnects, the system must reconcile the conflicting " + - "operations. Both clients should converge to the same state.", + "Client 0 deletes A.md while client 1 renames A.md to C.md offline. " + + "After client 1 reconnects, both clients should converge to the same state.", clients: 2, steps: [ - // Setup: create A.md and B.md, sync to both clients { type: "create", client: 0, path: "A.md", content: "content-a" }, { type: "create", client: 0, path: "B.md", content: "content-b" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-exists", client: 1, path: "A.md" }, - { type: "assert-exists", client: 1, path: "B.md" }, + { type: "assert-consistent", verify: (s) => s.assertFileExists("A.md").assertFileExists("B.md") }, - // Client 1 goes offline { type: "disable-sync", client: 1 }, - // Client 0 deletes A.md and syncs { type: "delete", client: 0, path: "A.md" }, { type: "sync", client: 0 }, - // Client 1 (offline) renames A.md to C.md { type: "rename", client: 1, oldPath: "A.md", newPath: "C.md" }, - // Client 1 reconnects { type: "enable-sync", client: 1 }, { type: "sync", client: 1 }, { type: "barrier" }, - // Both clients must converge — the key invariant is consistency. - // B.md should still exist on both (unaffected by the conflict). - { type: "assert-exists", client: 0, path: "B.md" }, - { type: "assert-exists", client: 1, path: "B.md" }, - { type: "assert-consistent", verify: verifyConflictResolution } + { type: "assert-consistent", verify: (s) => { + s.assertContent("B.md", "content-b"); + s.assertFileNotExists("A.md"); + s.ifFileExists("C.md", (s) => s.assertContent("C.md", "content-a")); + } }, ] }; diff --git a/frontend/deterministic-tests/src/tests/double-offline-cycle.test.ts b/frontend/deterministic-tests/src/tests/double-offline-cycle.test.ts index 1b146a0e..1034ce27 100644 --- a/frontend/deterministic-tests/src/tests/double-offline-cycle.test.ts +++ b/frontend/deterministic-tests/src/tests/double-offline-cycle.test.ts @@ -1,42 +1,11 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; +import type { TestDefinition } from "../test-definition"; -function verifyAllEdits(state: ClientState): void { - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}` - ); - assert( - state.files.has("doc.md"), - `Expected doc.md to exist` - ); - const content = state.files.get("doc.md") ?? ""; - assert( - content === "third edit", - `Expected doc.md to contain "third edit", got: "${content}"` - ); -} - -/** - * Tests two consecutive offline→online cycles. Client 0 goes offline, - * edits, comes online (first cycle). Then goes offline again, edits - * more, comes online (second cycle). All edits should propagate to - * Client 1. - * - * This exercises the runningReconciliation lifecycle: it must be - * cleared after the first cycle so the second reconnect triggers a - * fresh filesystem scan. - */ export const doubleOfflineCycleTest: TestDefinition = { - name: "Double Offline Cycle", description: - "Client 0 goes offline, edits, comes online, syncs. Then goes " + - "offline again, edits more, comes online again. Both offline edits " + - "must propagate to Client 1. Tests that runningReconciliation is " + - "properly cleared between cycles.", + "Client 0 goes through three offline-edit-reconnect cycles. " + + "Each offline edit must propagate to client 1 after reconnection.", clients: 2, steps: [ - // Setup: create and sync { type: "create", client: 0, @@ -47,14 +16,8 @@ export const doubleOfflineCycleTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - { - type: "assert-content", - client: 1, - path: "doc.md", - content: "initial" - }, + { type: "assert-consistent", verify: (s) => s.assertContent("doc.md", "initial") }, - // First offline cycle: edit { type: "disable-sync", client: 0 }, { type: "update", @@ -63,18 +26,11 @@ export const doubleOfflineCycleTest: TestDefinition = { content: "first edit" }, - // Come online, sync first edit { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - { - type: "assert-content", - client: 1, - path: "doc.md", - content: "first edit" - }, + { type: "assert-consistent", verify: (s) => s.assertContent("doc.md", "first edit") }, - // Second offline cycle: edit again { type: "disable-sync", client: 0 }, { type: "update", @@ -83,18 +39,11 @@ export const doubleOfflineCycleTest: TestDefinition = { content: "second edit" }, - // Come online, sync second edit { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - { - type: "assert-content", - client: 1, - path: "doc.md", - content: "second edit" - }, + { type: "assert-consistent", verify: (s) => s.assertContent("doc.md", "second edit") }, - // Third offline cycle: edit once more { type: "disable-sync", client: 0 }, { type: "update", @@ -103,10 +52,9 @@ export const doubleOfflineCycleTest: TestDefinition = { content: "third edit" }, - // Come online, sync third edit { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyAllEdits } + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("doc.md", "third edit") } ] }; diff --git a/frontend/deterministic-tests/src/tests/failed-vfs-move-falls-back.test.ts b/frontend/deterministic-tests/src/tests/failed-vfs-move-falls-back.test.ts index ed54b90d..f9ae2a3f 100644 --- a/frontend/deterministic-tests/src/tests/failed-vfs-move-falls-back.test.ts +++ b/frontend/deterministic-tests/src/tests/failed-vfs-move-falls-back.test.ts @@ -1,34 +1,11 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * Tests rename-overwrite behavior: when file A is renamed to file B's - * path (overwriting B), both clients should converge on a single file - * at the target path with A's content. - */ -function verifyOneFile(state: ClientState): void { - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}: ${[...state.files.keys()].join(", ")}` - ); - assert( - state.files.has("B.md"), - `Expected B.md to exist, got: ${[...state.files.keys()].join(", ")}` - ); - assert( - state.files.get("B.md") === "content A", - `Expected B.md to have A's content, got: "${state.files.get("B.md")}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const failedVfsMoveFallsBackTest: TestDefinition = { - name: "Rename Overwrite — A.md Renamed to Occupied B.md", description: "File A is renamed to B's path (overwriting B). Both clients " + "should converge on a single file at B.md with A's content.", clients: 2, steps: [ - // Setup: create two files { type: "create", client: 0, path: "A.md", content: "content A" }, { type: "create", client: 0, path: "B.md", content: "content B" }, { type: "enable-sync", client: 0 }, @@ -36,12 +13,10 @@ export const failedVfsMoveFallsBackTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Client 0 renames A.md to B.md (overwrite) { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, { type: "sync" }, { type: "barrier" }, - // Both clients should have only B.md - { type: "assert-consistent", verify: verifyOneFile } + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("B.md", "content A") } ] }; 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 7d5e524a..ce12df0c 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 @@ -1,55 +1,24 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyNoDuplicates(state: ClientState): void { - assert( - state.files.size === 1, - `Expected exactly 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.has("doc.md"), - `Expected doc.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - const content = state.files.get("doc.md") ?? ""; - assert( - content === "important data", - `Expected doc.md content to be "important data", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const idempotencyAfterServerPauseTest: TestDefinition = { - name: "Idempotency Key Prevents Duplicates After Server Pause", description: - "Client 0 creates a file. The server is paused mid-response (SIGSTOP), " + - "so the client's HTTP request stalls. When the server resumes, the " + - "idempotency key should prevent duplicate documents from being created. " + - "Both clients must converge to a single copy of the file.", + "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.", clients: 2, steps: [ - // Both clients online { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Client 0 creates a file, then immediately pause the server so the - // response is stalled (the server may or may not have committed the - // create — either way the idempotency key protects us). { type: "create", client: 0, path: "doc.md", content: "important data" }, { type: "pause-server" }, - // Wait with server frozen — client's in-flight create request is stuck. - - // Resume the server. The stalled request completes (or the client - // retries with the same idempotency key). { type: "resume-server" }, - // Sync and converge { type: "sync" }, { type: "barrier" }, - // There must be exactly one doc.md with the correct content — no - // duplicates like "doc (1).md". - { type: "assert-consistent", verify: verifyNoDuplicates } + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("doc.md", "important data") } ] }; diff --git a/frontend/deterministic-tests/src/tests/interleaved-operations.test.ts b/frontend/deterministic-tests/src/tests/interleaved-operations.test.ts deleted file mode 100644 index 09fa5276..00000000 --- a/frontend/deterministic-tests/src/tests/interleaved-operations.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { TestDefinition } from "../test-definition"; - -export const interleavedOperationsTest: TestDefinition = { - name: "Interleaved Create-Update-Delete Across Clients", - description: - "Client 0 creates files A, B, C. Client 1 syncs. Then Client 0 deletes A, " + - "Client 1 updates B, Client 0 renames C to D — all interleaved. " + - "Both should converge to the same final state.", - clients: 2, - steps: [ - // Setup: create 3 files - { type: "create", client: 0, path: "A.md", content: "aaa" }, - { type: "create", client: 0, path: "B.md", content: "bbb" }, - { type: "create", client: 0, path: "C.md", content: "ccc" }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Interleaved operations (both clients online) - { type: "delete", client: 0, path: "A.md" }, - { type: "update", client: 1, path: "B.md", content: "bbb-updated" }, - { type: "rename", client: 0, oldPath: "C.md", newPath: "D.md" }, - - { type: "sync" }, - { type: "barrier" }, - - // A.md deleted, B.md updated, C.md renamed to D.md - { type: "assert-not-exists", client: 0, path: "A.md" }, - { type: "assert-not-exists", client: 1, path: "A.md" }, - { type: "assert-exists", client: 0, path: "B.md" }, - { type: "assert-exists", client: 1, path: "B.md" }, - { type: "assert-not-exists", client: 0, path: "C.md" }, - { type: "assert-not-exists", client: 1, path: "C.md" }, - { type: "assert-exists", client: 0, path: "D.md" }, - { type: "assert-exists", client: 1, path: "D.md" }, - { type: "assert-consistent" } - ] -}; 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 438af856..ef8404fb 100644 --- a/frontend/deterministic-tests/src/tests/interrupted-delete-retry.test.ts +++ b/frontend/deterministic-tests/src/tests/interrupted-delete-retry.test.ts @@ -1,48 +1,25 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG FIX TEST: Interrupted deletes must be retried after reconnect. - * - * Scenario: - * 1. Client 0 creates a file, syncs to both clients. - * 2. Client 0 deletes the file. - * 3. Server is paused BEFORE the delete HTTP request completes. - * The doc transitions to deleted-locally but the server never receives the delete. - * 4. Server resumes. Client reconnects and runs reconciliation. - * 5. The interrupted delete should be retried and succeed. - * 6. Both clients should converge on 0 files. - */ -function verifyNoFiles(state: ClientState): void { - assert(state.files.size === 0, `Expected 0 files, got ${state.files.size}: ${[...state.files.keys()].join(", ")}`); -} +import type { TestDefinition } from "../test-definition"; export const interruptedDeleteRetryTest: TestDefinition = { - name: "Interrupted Delete Is Retried After Reconnect", description: - "A delete that was interrupted by a server pause/disconnect " + - "should be retried when the connection is restored.", + "Client 0 deletes a file, then the server is paused. " + + "After the server resumes, both clients should have zero files.", clients: 2, steps: [ - // Setup: create file, sync both { type: "create", client: 0, path: "doc.md", content: "to be deleted" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Client 0 deletes the file { type: "delete", client: 0, path: "doc.md" }, - // Pause server to interrupt the delete request { type: "pause-server" }, - // Resume server - the interrupted delete should be retried { type: "resume-server" }, { type: "sync" }, { type: "barrier" }, - // Both clients should have 0 files - { type: "assert-consistent", verify: verifyNoFiles }, + { type: "assert-consistent", verify: (s) => s.assertFileCount(0) }, ], }; diff --git a/frontend/deterministic-tests/src/tests/key-migration-event-drop.test.ts b/frontend/deterministic-tests/src/tests/key-migration-event-drop.test.ts index d85ddfbc..9d9a870d 100644 --- a/frontend/deterministic-tests/src/tests/key-migration-event-drop.test.ts +++ b/frontend/deterministic-tests/src/tests/key-migration-event-drop.test.ts @@ -1,45 +1,9 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG: Queue key migration can drop events when the new key already has events. - * - * In sync-event-queue.ts line 94-98, migrateKey() silently drops events - * from the old key if the new key (documentId) already has queued events. - * The comment says "Keep the existing state at the new key (it's more - * recent)" — but the old key's state may contain unsynced local changes. - * - * Scenario: - * 1. Client creates file A.md (pending, key = "path:A.md") - * 2. Server assigns documentId via resolveIdempotencyKeys - * 3. BEFORE the key migration, a local-update event for A.md arrives - * and gets queued under "path:A.md" (because the doc is still pending - * at that point in the resolveKey lookup) - * 4. Meanwhile, a remote-update broadcast arrives for the same documentId - * and gets queued under the documentId key - * 5. migrateKey runs: old key has "update", new key has "remote-update" - * 6. The old key's "update" is DROPPED — the local edit is lost - * - * This test simulates a similar scenario: Client 0 creates a file and - * immediately updates it. While the create is being resolved, the update - * should not be lost. - */ -function verifyUpdatedContent(state: ClientState): void { - assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); - assert(state.files.has("A.md"), "Expected A.md to exist"); - const content = state.files.get("A.md") ?? ""; - assert( - content === "updated content", - `Expected "updated content", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const keyMigrationEventDropTest: TestDefinition = { - name: "Key Migration Does Not Drop Local Updates", description: - "Client creates a file and immediately updates it before the create " + - "is acknowledged. The queue key migrates from path-based to documentId. " + - "The local update should not be lost during key migration.", + "Client 0 creates a file and immediately updates it while the server is paused. " + + "After resume, both clients should have the updated content.", clients: 2, steps: [ { type: "enable-sync", client: 0 }, @@ -47,10 +11,8 @@ export const keyMigrationEventDropTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Pause server so create request stalls { type: "pause-server" }, - // Client 0 creates file, then immediately updates it { type: "create", client: 0, @@ -64,12 +26,10 @@ export const keyMigrationEventDropTest: TestDefinition = { content: "updated content" }, - // Resume server — create completes, update should follow { type: "resume-server" }, { type: "sync" }, { type: "barrier" }, - // The updated content should be on both clients, not the initial - { type: "assert-consistent", verify: verifyUpdatedContent } + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("A.md", "updated content") } ] }; diff --git a/frontend/deterministic-tests/src/tests/large-file-count.test.ts b/frontend/deterministic-tests/src/tests/large-file-count.test.ts deleted file mode 100644 index a295a10a..00000000 --- a/frontend/deterministic-tests/src/tests/large-file-count.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { ClientState, TestDefinition, TestStep } from "../test-definition"; -import { assert } from "../utils/assert"; - -const FILE_COUNT = 20; - -function buildSteps(): TestStep[] { - const steps: TestStep[] = []; - - // Create N files offline on client 0 - for (let i = 0; i < FILE_COUNT; i++) { - steps.push({ - type: "create", - client: 0, - path: `file-${String(i).padStart(3, "0")}.md`, - content: `content-${i}` - }); - } - - // Enable sync and converge - steps.push({ type: "enable-sync", client: 0 }); - steps.push({ type: "enable-sync", client: 1 }); - steps.push({ type: "sync" }); - steps.push({ type: "barrier" }); - - // Verify all files - steps.push({ - type: "assert-consistent", - verify: (state: ClientState) => { - assert( - state.files.size === FILE_COUNT, - `Expected ${FILE_COUNT} files, got ${state.files.size}` - ); - for (let i = 0; i < FILE_COUNT; i++) { - const path = `file-${String(i).padStart(3, "0")}.md`; - assert(state.files.has(path), `Missing file: ${path}`); - assert( - state.files.get(path) === `content-${i}`, - `Wrong content for ${path}` - ); - } - } - }); - - return steps; -} - -export const largeFileCountTest: TestDefinition = { - name: "Large File Count Sync", - description: - `Client 0 creates ${FILE_COUNT} files offline. All should sync ` + - "to Client 1 with correct content.", - clients: 2, - steps: buildSteps() -}; 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 4ab69ba8..94d82baa 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 @@ -1,53 +1,16 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG: Local edit lost when create returns MergingUpdate. - * - * Scenario: - * 1. Client 1 creates doc.md and syncs it to the server - * 2. Client 0 (offline) creates doc.md with different content - * 3. Server is paused, client 0 goes online — create request stalls - * 4. Client 0 updates the file locally while the create is in-flight - * 5. Server resumes → create returns MergingUpdate with merged content - * 6. applyServerResponse reads currentDisk (the local update) and calls - * write(path, currentDisk, responseBytes). The 3-way merge sees - * parent == ours (currentDisk == currentDisk) → "no local changes" → - * overwrites with server content. The local update is permanently lost. - * - * Expected: the local edit made during the in-flight create must survive. - */ -function verifyLocalEditPreserved(state: ClientState): void { - assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); - assert(state.files.has("doc.md"), "Expected doc.md to exist"); - const content = state.files.get("doc.md") ?? ""; - assert( - content.includes("from-client-1"), - `Expected "from-client-1" in content, got: "${content}"` - ); - // The critical assertion: the local edit made while the create was - // in-flight must survive the MergingUpdate 3-way merge. - assert( - content.includes("local-edit-during-create"), - `Expected "local-edit-during-create" in content (lost during merge), got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const localEditLostDuringCreateMergeTest: TestDefinition = { - name: "Local Edit Lost During Create-Merge Response", description: - "When a create returns a MergingUpdate and the file was locally " + - "edited between the request and response, the local edit must " + - "not be lost by the 3-way merge.", + "Client 1 creates doc.md. Client 0 creates the same file offline, then connects with the server paused. " + + "Client 0 edits the file while the create is stalled. After resume, both clients' content must be merged.", clients: 2, steps: [ - // Client 1 creates doc.md while client 0 is offline { type: "enable-sync", client: 1 }, { type: "sync", client: 1 }, { type: "create", client: 1, path: "doc.md", content: "from-client-1" }, { type: "sync", client: 1 }, - // Client 0 creates the same file offline (doesn't know about client 1's version) { type: "create", client: 0, @@ -55,13 +18,10 @@ export const localEditLostDuringCreateMergeTest: TestDefinition = { content: "from-client-0" }, - // Pause server so client 0's create stalls mid-flight { type: "pause-server" }, - // Bring client 0 online — its create request will stall { type: "enable-sync", client: 0 }, - // Client 0 updates the file WHILE the create is in-flight { type: "update", client: 0, @@ -69,16 +29,12 @@ export const localEditLostDuringCreateMergeTest: TestDefinition = { content: "local-edit-during-create" }, - // Resume server — create completes with MergingUpdate { type: "resume-server" }, - // Give time for: create response → 3-way merge → follow-up - // update (detects local edit) → propagation to client 1 { type: "sync" }, { type: "sync" }, { type: "barrier" }, - // The local edit must be preserved - { type: "assert-consistent", verify: verifyLocalEditPreserved } + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("doc.md", "from-client-1", "local-edit-during-create") } ] }; diff --git a/frontend/deterministic-tests/src/tests/mc-cross-create-rename-same-target.test.ts b/frontend/deterministic-tests/src/tests/mc-cross-create-rename-same-target.test.ts index ef9b65c1..ce991df3 100644 --- a/frontend/deterministic-tests/src/tests/mc-cross-create-rename-same-target.test.ts +++ b/frontend/deterministic-tests/src/tests/mc-cross-create-rename-same-target.test.ts @@ -1,109 +1,45 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * Edge case: Both clients create files at DIFFERENT paths, then both rename - * their respective files to the SAME target path. - * - * Timeline: - * 1. Client 0 creates X.md, Client 1 creates Y.md (both offline). - * 2. Both enable sync, converge (X.md and Y.md exist on both). - * 3. Client 1 goes offline. - * 4. Client 0 renames X.md -> Z.md, syncs. - * 5. Client 1 (offline) renames Y.md -> Z.md. - * 6. Client 1 reconnects. - * - * The tricky part: Both renames target Z.md. Client 0's rename completes first - * on the server. When Client 1 reconnects and tries to rename Y.md -> Z.md, - * the server already has a document at Z.md (formerly X.md). The system must - * use path deconfliction (e.g., Z (1).md) to preserve both documents' content. - * - * This differs from the existing concurrent-rename-same-target test because - * the files START at different paths (not A.md/B.md created by the same client) - * and the creates themselves are concurrent, exercising the interaction between - * concurrent create-merge and rename-deconfliction. - */ - -function verifyBothContentsPreserved(state: ClientState): void { - const allContent = Array.from(state.files.values()).join("\n"); - assert( - allContent.includes("content-x"), - `Expected "content-x" to be preserved somewhere. ` + - `Files: ${JSON.stringify(Object.fromEntries(state.files))}` - ); - assert( - allContent.includes("content-y"), - `Expected "content-y" to be preserved somewhere. ` + - `Files: ${JSON.stringify(Object.fromEntries(state.files))}` - ); - - // Neither X.md nor Y.md should exist (both were renamed away) - assert( - !state.files.has("X.md"), - `Expected X.md to not exist (was renamed). ` + - `Files: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - !state.files.has("Y.md"), - `Expected Y.md to not exist (was renamed). ` + - `Files: ${Array.from(state.files.keys()).join(", ")}` - ); - - // At least one file should be at Z.md - assert( - state.files.has("Z.md"), - `Expected Z.md to exist. ` + - `Files: ${Array.from(state.files.keys()).join(", ")}` - ); - - // There must be exactly 2 files (both contents preserved, possibly deconflicted) - assert( - state.files.size === 2, - `Expected exactly 2 files, got ${state.files.size}: ` + - Array.from(state.files.keys()).join(", ") - ); -} +import type { TestDefinition } from "../test-definition"; export const mcCrossCreateRenameSameTargetTest: TestDefinition = { - name: "MC: Cross-Create then Rename to Same Target", description: "Client 0 creates X.md, Client 1 creates Y.md. Both sync. Client 0 renames " + "X.md -> Z.md. Client 1 (offline) renames Y.md -> Z.md. Both must converge " + "with both contents preserved via path deconfliction.", clients: 2, steps: [ - // Phase 1: Both create files offline at different paths { type: "create", client: 0, path: "X.md", content: "content-x" }, { type: "create", client: 1, path: "Y.md", content: "content-y" }, - // Both enable sync — creates race to server { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Verify both files exist on both clients - { type: "assert-exists", client: 0, path: "X.md" }, - { type: "assert-exists", client: 0, path: "Y.md" }, - { type: "assert-exists", client: 1, path: "X.md" }, - { type: "assert-exists", client: 1, path: "Y.md" }, + { + type: "assert-consistent", + verify: (s) => s.assertFileExists("X.md").assertFileExists("Y.md") + }, - // Phase 2: Client 1 goes offline { type: "disable-sync", client: 1 }, - // Phase 3: Client 0 renames X.md -> Z.md and syncs { type: "rename", client: 0, oldPath: "X.md", newPath: "Z.md" }, { type: "sync", client: 0 }, - // Phase 4: Client 1 (offline) renames Y.md -> Z.md { type: "rename", client: 1, oldPath: "Y.md", newPath: "Z.md" }, - // Phase 5: Client 1 reconnects { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Both contents must be preserved, both clients consistent - { type: "assert-consistent", verify: verifyBothContentsPreserved } + { + type: "assert-consistent", + verify: (s) => { + s.assertFileCount(2) + .assertFileNotExists("X.md") + .assertFileNotExists("Y.md") + .assertFileExists("Z.md") + .assertAnyFileContains("content-x", "content-y"); + } + } ] }; 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 e5f8f362..98504f03 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 @@ -1,98 +1,37 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * Edge case: Client 0 creates a file, syncs. Client 1 receives it. Then Client - * 0 deletes the file and syncs. Meanwhile Client 1 goes offline and renames it. - * - * Timeline: - * 1. Client 0 creates A.md, both sync. - * 2. Client 1 goes offline. - * 3. Client 0 deletes A.md, syncs (server marks document as deleted). - * 4. Client 1 (offline) renames A.md -> B.md. - * 5. Client 1 reconnects. - * - * The tricky part: Client 1's rename targets a document that was deleted on the - * server between Client 1's disconnect and reconnect. The offline rename is a - * sync-update with oldPath=A.md, relativePath=B.md. On reconnect, the offline - * reconciliation detects B.md as a local file with a documentId pointing to a - * deleted server document. The system must decide: honor the rename (creating a - * new document at B.md) or propagate the delete. - * - * This test verifies that both clients converge regardless of which resolution - * strategy the system uses, and that no data is silently lost without the other - * client also seeing the same result. - * - * We also add a second file C.md that remains untouched to verify unrelated - * documents are not affected by the conflict resolution. - */ - -function verifyState(state: ClientState): void { - // C.md must always survive (unrelated to the conflict) - assert( - state.files.has("C.md"), - `Expected C.md to exist (untouched). ` + - `Files: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.get("C.md") === "unrelated", - `Expected C.md content to be "unrelated", got: "${state.files.get("C.md")}"` - ); - - // A.md should NOT exist (it was either renamed or deleted) - assert( - !state.files.has("A.md"), - `Expected A.md to NOT exist. ` + - `Files: ${Array.from(state.files.keys()).join(", ")}` - ); - - // Either B.md exists (rename won) or no extra files exist (delete won). - // The key invariant is convergence, which assert-consistent already checks. - // But let's also verify that the content is correct if B.md exists. - if (state.files.has("B.md")) { - const content = state.files.get("B.md") ?? ""; - assert( - content === "original", - `If B.md exists (rename won), it should have the original content. Got: "${content}"` - ); - } -} +import type { TestDefinition } from "../test-definition"; export const mcDeleteThenOfflineRenameTest: TestDefinition = { - name: "MC: Delete Synced Then Offline Rename", description: "Client 0 creates A.md, both sync. Client 1 goes offline. Client 0 deletes " + "A.md and syncs. Client 1 (offline) renames A.md to B.md. Client 1 reconnects. " + "Both must converge. C.md (unrelated) must be unaffected.", clients: 2, steps: [ - // Phase 1: Client 0 creates A.md and C.md, both sync { type: "create", client: 0, path: "A.md", content: "original" }, { type: "create", client: 0, path: "C.md", content: "unrelated" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-content", client: 1, path: "A.md", content: "original" }, - { type: "assert-content", client: 1, path: "C.md", content: "unrelated" }, - // Phase 2: Client 1 goes offline { type: "disable-sync", client: 1 }, - // Phase 3: Client 0 deletes A.md and syncs { type: "delete", client: 0, path: "A.md" }, { type: "sync", client: 0 }, - { type: "assert-not-exists", client: 0, path: "A.md" }, - // Phase 4: Client 1 (offline) renames A.md -> B.md { type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" }, - // Phase 5: Client 1 reconnects { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Both must converge — key assertions - { type: "assert-consistent", verify: verifyState } + { + type: "assert-consistent", + verify: (s) => { + s.assertContent("C.md", "unrelated") + .assertFileNotExists("A.md"); + s.ifFileExists("B.md", (s) => s.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 4ebe131b..26a095d5 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 @@ -1,43 +1,6 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyState(state: ClientState): void { - const files = Array.from(state.files.keys()); - - // file-1.md, file-3.md, file-5.md must survive (unaffected by conflict) - for (const path of ["file-1.md", "file-3.md", "file-5.md"]) { - assert( - state.files.has(path), - `Expected ${path} to exist. Files: ${files.join(", ")}` - ); - } - - // file-2.md was deleted on server by Client 1, and renamed to - // renamed.md by Client 0 offline. The delete should win. - assert( - !state.files.has("file-2.md"), - `Expected file-2.md to be deleted. Files: ${files.join(", ")}` - ); - - // file-4.md was also deleted by Client 1. - assert( - !state.files.has("file-4.md"), - `Expected file-4.md to be deleted. Files: ${files.join(", ")}` - ); - - // renamed.md: Client 0's offline rename of deleted file-2.md. - // The delete is authoritative, so renamed.md may or may not exist - // depending on conflict resolution. If it exists, verify its content. - if (state.files.has("renamed.md")) { - assert( - state.files.get("renamed.md") === "content-2", - `If renamed.md exists, it should have "content-2", got: "${state.files.get("renamed.md")}"` - ); - } -} +import type { TestDefinition } from "../test-definition"; export const mcMultiDeleteOfflineRenameTest: TestDefinition = { - name: "MC: Multi-File Delete + Offline Rename", description: "Client 0 creates 5 files. Client 1 deletes 2 while Client 0 (offline) " + "renames one of the deleted files. Both must converge.", @@ -53,23 +16,28 @@ export const mcMultiDeleteOfflineRenameTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Client 0 goes offline { type: "disable-sync", client: 0 }, - // Client 1 deletes file-2 and file-4 { type: "delete", client: 1, path: "file-2.md" }, { type: "delete", client: 1, path: "file-4.md" }, { type: "sync", client: 1 }, - // Client 0 (offline) renames file-2 { type: "rename", client: 0, oldPath: "file-2.md", newPath: "renamed.md" }, - // Client 0 reconnects { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - // Both must converge - { type: "assert-consistent", verify: verifyState } + { + type: "assert-consistent", + verify: (s) => { + s.assertFileExists("file-1.md") + .assertFileExists("file-3.md") + .assertFileExists("file-5.md") + .assertFileNotExists("file-2.md") + .assertFileNotExists("file-4.md"); + s.ifFileExists("renamed.md", (s) => s.assertContent("renamed.md", "content-2")); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/mc-three-client-rename-offline-update.test.ts b/frontend/deterministic-tests/src/tests/mc-three-client-rename-offline-update.test.ts index 23dbb02d..8144bbb5 100644 --- a/frontend/deterministic-tests/src/tests/mc-three-client-rename-offline-update.test.ts +++ b/frontend/deterministic-tests/src/tests/mc-three-client-rename-offline-update.test.ts @@ -1,39 +1,11 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyState(state: ClientState): void { - // A.md should not exist (it was renamed to B.md by Client 1) - assert( - !state.files.has("A.md"), - `A.md should not exist after rename. Files: ${Array.from(state.files.keys()).join(", ")}` - ); - - // Exactly 1 file should exist (B.md with merged content) - assert( - state.files.size === 1, - `Expected exactly 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - - // B.md must exist with Client 2's updated content merged in - assert( - state.files.has("B.md"), - `Expected B.md to exist. Files: ${Array.from(state.files.keys()).join(", ")}` - ); - const content = state.files.get("B.md") ?? ""; - assert( - content.includes("updated-by-client-2"), - `Expected B.md to contain "updated-by-client-2", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const mcThreeClientRenameOfflineUpdateTest: TestDefinition = { - name: "MC: Three-Client Rename + Offline Update", description: "Client 0 creates A.md. Client 1 renames to B.md. Client 2 (offline) " + "updates A.md. All three converge with updated content at B.md.", clients: 3, steps: [ - // Phase 1: Client 0 creates A.md, everyone syncs { type: "create", client: 0, path: "A.md", content: "original" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, @@ -41,26 +13,18 @@ export const mcThreeClientRenameOfflineUpdateTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Phase 2: Client 2 goes offline { type: "disable-sync", client: 2 }, - // Phase 3: Client 1 renames A.md -> B.md, clients 0 and 1 sync { type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" }, { type: "sync", client: 1 }, { type: "sync", client: 0 }, - // Don't use barrier here — Client 2 is offline and can't converge - { type: "assert-not-exists", client: 0, path: "A.md" }, - { type: "assert-exists", client: 0, path: "B.md" }, - // Phase 4: Client 2 updates its local A.md while offline { type: "update", client: 2, path: "A.md", content: "updated-by-client-2" }, - // Phase 5: Client 2 reconnects { type: "enable-sync", client: 2 }, { type: "sync" }, { type: "barrier" }, - // All three must converge - { type: "assert-consistent", verify: verifyState } + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertFileNotExists("A.md").assertContains("B.md", "updated-by-client-2") } ] }; diff --git a/frontend/deterministic-tests/src/tests/migrate-key-preserves-existing.test.ts b/frontend/deterministic-tests/src/tests/migrate-key-preserves-existing.test.ts index ba9a50ae..a4f6d3d3 100644 --- a/frontend/deterministic-tests/src/tests/migrate-key-preserves-existing.test.ts +++ b/frontend/deterministic-tests/src/tests/migrate-key-preserves-existing.test.ts @@ -1,35 +1,9 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG FIX: migrateKey must not overwrite existing state at the new key. - * - * Scenario: - * 1. Client 0 creates file A.md, then immediately updates it - * 2. Server is paused so the create stalls (idempotency key unresolved) - * 3. Client 1 is online and also creates at A.md (different content) - * 4. Server resumes — both creates merge - * 5. Client 0's update should not be lost during key migration - * - * The test verifies that after convergence, the file exists with - * content from both clients' edits. - */ -function verifyContent(state: ClientState): void { - assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); - assert(state.files.has("A.md"), "Expected A.md to exist"); - const content = state.files.get("A.md") ?? ""; - // Client 0's update should be present - assert( - content.includes("updated by client 0"), - `Expected content to include "updated by client 0", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const migrateKeyPreservesExistingTest: TestDefinition = { - name: "Key Migration Preserves Existing Queue State", description: - "When migrateKey is called and the new key already has queued " + - "events, the existing events must not be silently dropped.", + "Client 0 creates a file and immediately updates it while the server is paused. " + + "After resume, the update must not be lost.", clients: 2, steps: [ { type: "enable-sync", client: 0 }, @@ -37,10 +11,8 @@ export const migrateKeyPreservesExistingTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Pause server so create stalls { type: "pause-server" }, - // Client 0 creates and immediately updates { type: "create", client: 0, path: "A.md", content: "initial" }, { type: "update", @@ -49,11 +21,10 @@ export const migrateKeyPreservesExistingTest: TestDefinition = { content: "updated by client 0" }, - // Resume server { type: "resume-server" }, { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyContent } + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("A.md", "updated by client 0") } ] }; diff --git a/frontend/deterministic-tests/src/tests/move-and-concurrent-remote-update.test.ts b/frontend/deterministic-tests/src/tests/move-and-concurrent-remote-update.test.ts index 6430b796..f590f5b4 100644 --- a/frontend/deterministic-tests/src/tests/move-and-concurrent-remote-update.test.ts +++ b/frontend/deterministic-tests/src/tests/move-and-concurrent-remote-update.test.ts @@ -1,53 +1,11 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; +import type { TestDefinition } from "../test-definition"; -function verifyBothContentAndPath(state: ClientState): void { - // The file should be at B.md (Client 0 renamed it) - // AND should contain Client 1's updated content (merged with original) - const files = Array.from(state.files.keys()); - assert( - state.files.has("B.md"), - `Expected B.md to exist, got: ${files.join(", ")}` - ); - assert( - !state.files.has("A.md"), - `A.md should not exist after rename, got: ${files.join(", ")}` - ); - assert( - state.files.size === 1, - `Expected exactly 1 file, got ${state.files.size}: ${files.join(", ")}` - ); - - const content = state.files.get("B.md") ?? ""; - // Client 1 updated the content to include "updated by client 1" - // The 3-way merge should preserve this update at the renamed path - assert( - content.includes("updated by client 1"), - `Expected B.md to contain "updated by client 1" from the remote update, got: "${content}"` - ); -} - -/** - * BUG: Coalescing table says `move + remote-update = move`, which drops - * the remote update content. The local client only sends the rename - * to the server. If the server has no concurrent version to merge with, - * the remote client's update is lost on this client until a forced - * re-sync (runFinalConsistencyCheck). - * - * This test verifies that when Client 0 renames A.md → B.md while - * Client 1 simultaneously updates A.md, BOTH the rename and the - * content update are reflected on both clients. - */ export const moveAndConcurrentRemoteUpdateTest: TestDefinition = { - name: "Move and Concurrent Remote Update", description: - "Client 0 renames A.md to B.md while Client 1 updates A.md content. " + - "The coalescing table merges move + remote-update into just 'move', " + - "potentially dropping the remote content update. Both clients should " + - "converge to B.md with Client 1's updated content.", + "Client 0 renames A.md to B.md offline while client 1 updates A.md. " + + "After client 0 reconnects, both should have B.md with client 1's updated content.", clients: 2, steps: [ - // Setup: both clients share A.md { type: "create", client: 0, @@ -58,18 +16,10 @@ export const moveAndConcurrentRemoteUpdateTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - { - type: "assert-content", - client: 1, - path: "A.md", - content: "original content" - }, - // Client 0 goes offline and renames A.md → B.md { type: "disable-sync", client: 0 }, { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, - // Client 1 updates A.md while Client 0 is offline { type: "update", client: 1, @@ -78,12 +28,10 @@ export const moveAndConcurrentRemoteUpdateTest: TestDefinition = { }, { type: "sync", client: 1 }, - // Client 0 comes online — will receive remote-update for A.md - // The move event (A→B) and remote-update should both apply { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyBothContentAndPath } + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertFileNotExists("A.md").assertContains("B.md", "updated by client 1") } ] }; diff --git a/frontend/deterministic-tests/src/tests/move-chain-three-files.test.ts b/frontend/deterministic-tests/src/tests/move-chain-three-files.test.ts deleted file mode 100644 index b4be03d9..00000000 --- a/frontend/deterministic-tests/src/tests/move-chain-three-files.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * EDGE CASE: Three-file circular rotation while offline. - * - * Files A, B, C get rotated: A→B, B→C, C→A. Since the DeterministicAgent - * works on an in-memory filesystem, we can simulate this by: - * 1. Delete all three files - * 2. Recreate them with rotated content - * - * On reconnect, the reconciliation algorithm must detect that: - * - A.md has C's old content (move from C→A) - * - B.md has A's old content (move from A→B) - * - C.md has B's old content (move from B→C) - * - * Since each file has unique content, the hash-based move detection should - * work. But this creates THREE simultaneous move detections, which is a - * stress test of the algorithm: each match removes from missingTracked, - * and the order of processing matters. - */ -function verifyFinalState(state: ClientState): void { - assert( - state.files.size === 3, - `Expected 3 files, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.get("A.md") === "was C", - `Expected A.md = "was C", got: "${state.files.get("A.md")}"` - ); - assert( - state.files.get("B.md") === "was A", - `Expected B.md = "was A", got: "${state.files.get("B.md")}"` - ); - assert( - state.files.get("C.md") === "was B", - `Expected C.md = "was B", got: "${state.files.get("C.md")}"` - ); -} - -export const moveChainThreeFilesTest: TestDefinition = { - name: "Three-File Circular Rotation Offline", - description: - "Three files are rotated (A→B, B→C, C→A) while offline by " + - "deleting all and recreating with swapped content. The reconciliation " + - "should detect the moves via hash matching and sync correctly.", - clients: 2, - steps: [ - // Setup: create three files with unique content - { type: "create", client: 0, path: "A.md", content: "was A" }, - { type: "create", client: 0, path: "B.md", content: "was B" }, - { type: "create", client: 0, path: "C.md", content: "was C" }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Client 0 goes offline - { type: "disable-sync", client: 0 }, - - // Delete all three - { type: "delete", client: 0, path: "A.md" }, - { type: "delete", client: 0, path: "B.md" }, - { type: "delete", client: 0, path: "C.md" }, - - // Recreate with rotated content: C→A, A→B, B→C - { type: "create", client: 0, path: "A.md", content: "was C" }, - { type: "create", client: 0, path: "B.md", content: "was A" }, - { type: "create", client: 0, path: "C.md", content: "was B" }, - - // Reconnect - { type: "enable-sync", client: 0 }, - { type: "sync" }, - { type: "barrier" }, - - { type: "assert-consistent", verify: verifyFinalState } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/move-identical-content-ambiguity.test.ts b/frontend/deterministic-tests/src/tests/move-identical-content-ambiguity.test.ts deleted file mode 100644 index 39b1c61d..00000000 --- a/frontend/deterministic-tests/src/tests/move-identical-content-ambiguity.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG: Move detection fails when two files have identical content. - * - * reconcileWithDisk() detects moves by matching content hashes of new files - * against missing tracked docs. If there are TWO missing tracked docs with - * the same hash, neither will match (matches.length !== 1), and the move - * is treated as a "new file + delete" instead of a rename. - * - * Scenario: - * 1. Client 0 creates two files with identical content: A.md and B.md - * 2. Both sync to Client 1 - * 3. Client 1 goes offline - * 4. Client 1 deletes A.md and renames B.md to C.md (same content) - * 5. Client 1 reconnects - * - * Expected: A.md deleted on server, B.md renamed to C.md (preserving documentId) - * Bug: reconcileWithDisk sees B.md missing + C.md new, but content hash - * matches BOTH A.md and B.md (since they had identical content). So the - * move from B→C is not detected. Instead, B.md is treated as a delete - * and C.md as a new create, losing B.md's documentId. - * - * The test verifies convergence still works (the system recovers via - * server-side merge), but documents may get new documentIds unnecessarily. - */ -function verifyFinalState(state: ClientState): void { - // A.md should not exist (deleted) - assert(!state.files.has("A.md"), "A.md should not exist"); - - // B.md should not exist (renamed to C.md) - assert(!state.files.has("B.md"), "B.md should not exist"); - - // C.md should exist with the shared content - assert(state.files.has("C.md"), "C.md should exist"); - const content = state.files.get("C.md") ?? ""; - assert( - content === "identical content", - `Expected C.md to contain "identical content", got: "${content}"` - ); - - // Only C.md should exist - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); -} - -export const moveIdenticalContentAmbiguityTest: TestDefinition = { - name: "Move Detection Ambiguity With Identical Content", - description: - "Two files with identical content exist. One is deleted and the other " + - "renamed while offline. On reconnect, the move detection algorithm sees " + - "two matching hashes and cannot determine which missing doc was moved. " + - "The system should still converge correctly.", - clients: 2, - steps: [ - // Setup: create two files with identical content - { - type: "create", - client: 0, - path: "A.md", - content: "identical content" - }, - { - type: "create", - client: 0, - path: "B.md", - content: "identical content" - }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Verify both clients have both files - { - type: "assert-content", - client: 1, - path: "A.md", - content: "identical content" - }, - { - type: "assert-content", - client: 1, - path: "B.md", - content: "identical content" - }, - - // Client 1 goes offline, deletes A.md and renames B.md → C.md - { type: "disable-sync", client: 1 }, - { type: "delete", client: 1, path: "A.md" }, - { type: "rename", client: 1, oldPath: "B.md", newPath: "C.md" }, - - // Client 1 reconnects - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Both clients should converge - { type: "assert-consistent", verify: verifyFinalState } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/move-preserves-remote-update.test.ts b/frontend/deterministic-tests/src/tests/move-preserves-remote-update.test.ts index b5c225b5..59bedbbe 100644 --- a/frontend/deterministic-tests/src/tests/move-preserves-remote-update.test.ts +++ b/frontend/deterministic-tests/src/tests/move-preserves-remote-update.test.ts @@ -1,59 +1,37 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG FIX: Local rename must not drop a concurrent remote content update. - * - * Scenario: - * 1. Both clients have doc.md = "line 1\nline 2" - * 2. Client 0 renames doc.md to renamed.md - * 3. Client 1 edits doc.md content - * 4. Both sync - * 5. The file should exist (at some path) with both the rename and content update applied - */ -function verifyContentPreserved(state: ClientState): void { - assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); - // The file should be at the renamed path - assert( - state.files.has("renamed.md") || state.files.has("doc.md"), - `Expected file at renamed.md or doc.md, got: ${Array.from(state.files.keys()).join(", ")}` - ); - // Content from client 1's edit should be present - const [content] = [...state.files.values()]; - assert( - content.includes("client 1 edit"), - `Expected merged content to include "client 1 edit", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const movePreservesRemoteUpdateTest: TestDefinition = { - name: "Local Move Preserves Remote Content Update", description: - "When a user renames a file and another client edits it concurrently, " + - "the content update should not be lost.", + "Client 0 renames a file offline while client 1 edits it offline. " + + "After both reconnect, the renamed file should contain client 1's edit.", clients: 2, steps: [ - // Setup { type: "create", client: 0, path: "doc.md", content: "line 1\nline 2" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Both go offline { type: "disable-sync", client: 0 }, { type: "disable-sync", client: 1 }, - // Client 0 renames, client 1 edits content { type: "rename", client: 0, oldPath: "doc.md", newPath: "renamed.md" }, { type: "update", client: 1, path: "doc.md", content: "line 1\nclient 1 edit\nline 2" }, - // Both come online { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyContentPreserved }, + { + type: "assert-consistent", + verify: (s) => { + s.assertFileCount(1); + const content = Array.from(s.files.values())[0]; + if (!content.includes("client 1 edit")) { + throw new Error(`Expected merged content to include "client 1 edit", got: "${content}"`); + } + } + }, ], }; diff --git a/frontend/deterministic-tests/src/tests/move-remote-update-reverts-rename.test.ts b/frontend/deterministic-tests/src/tests/move-remote-update-reverts-rename.test.ts index 6bbbca29..95fcfe26 100644 --- a/frontend/deterministic-tests/src/tests/move-remote-update-reverts-rename.test.ts +++ b/frontend/deterministic-tests/src/tests/move-remote-update-reverts-rename.test.ts @@ -1,71 +1,35 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG: remote-update + local-move = remote-update loses the rename. - * - * In sync-events.ts coalesceFromRemoteUpdate (line 271-272): - * case "local-move": - * return current; // remote-update absorbs the local-move - * - * When a remote-update broadcast arrives and then the user renames the - * file, the coalescing discards the move info. The executor only sees - * "remote-update" and calls executeSyncUpdateFull(force=true). - * - * In the force path (no local content changes), the server responds - * with the old path. The client moves the file BACK to the old path, - * reverting the user's rename. - * - * If there ARE content changes, the update sends doc.relativePath (the - * new path) to the server, which may preserve the rename. But the - * behavior is inconsistent. - * - * This test verifies that when a remote-update and a local-rename race, - * the rename is preserved (or at least both clients converge). - */ -function verifyState(state: ClientState): void { - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - // The file should exist at the renamed path or original — either is OK - // as long as both clients converge. But ideally the rename survives. - const content = Array.from(state.files.values())[0]; - assert( - content === "updated by client 1", - `Expected "updated by client 1", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const moveRemoteUpdateRevertsRenameTest: TestDefinition = { - name: "Remote Update + Local Move Coalescing May Revert Rename", description: - "When a remote-update broadcast arrives and the user renames the " + - "file, the coalescing (remote-update + local-move = remote-update) " + - "discards the rename info. The force path may revert the rename " + - "by moving the file back to the server's path.", + "Client 1 updates a file while client 0 is offline. Client 0 reconnects and renames the file. " + + "Both clients should converge with client 1's updated content.", clients: 2, steps: [ - // Setup: both clients have doc.md { type: "create", client: 0, path: "doc.md", content: "original" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Client 1 updates the file content (broadcasts to client 0) { type: "disable-sync", client: 0 }, { type: "update", client: 1, path: "doc.md", content: "updated by client 1" }, { type: "sync", client: 1 }, - // Client 0 comes online and renames the file while the remote-update - // is arriving on the WebSocket { type: "enable-sync", client: 0 }, { type: "rename", client: 0, oldPath: "doc.md", newPath: "renamed.md" }, { type: "sync" }, { type: "barrier" }, - // Both should converge - { type: "assert-consistent", verify: verifyState } + { + type: "assert-consistent", + verify: (s) => { + s.assertFileCount(1); + const content = Array.from(s.files.values())[0]; + if (content !== "updated by client 1") { + throw new Error(`Expected "updated by client 1", got: "${content}"`); + } + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/move-then-delete-stale-path.test.ts b/frontend/deterministic-tests/src/tests/move-then-delete-stale-path.test.ts index c207d0a9..77814669 100644 --- a/frontend/deterministic-tests/src/tests/move-then-delete-stale-path.test.ts +++ b/frontend/deterministic-tests/src/tests/move-then-delete-stale-path.test.ts @@ -1,43 +1,11 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; +import type { TestDefinition } from "../test-definition"; -function verifyDeleted(state: ClientState): void { - const files = Array.from(state.files.keys()); - assert( - state.files.size === 0, - `Expected 0 files after move+delete, got ${state.files.size}: ${files.join(", ")}` - ); -} - -/** - * Tests the stale-path bug in the delete executor. - * - * When a file is renamed (A→B) and then deleted, the event coalescing - * produces `move(A→B) + delete = delete(path: A)`. The VFS.move in - * syncLocallyUpdatedFile has already moved the doc to B. The executor's - * delete action looks up the doc: getByPath("A") returns undefined - * (doc moved to B), so it falls back to getByDocumentId. It finds the - * doc at B. Then it calls deleteLocally(). - * - * Before the fix: deleteLocally(action.path) used "A" — the stale - * path from when the event was enqueued. The pathIndex lookup at "A" - * fails (doc is at "B"), so the delete is silently dropped. The doc - * stays tracked at B, and the file is gone from disk but VFS thinks - * it still exists. - * - * After the fix: deleteLocally(doc.relativePath) uses "B" — the - * current VFS path. The delete succeeds. - */ export const moveThenDeleteStalePathTest: TestDefinition = { - name: "Move Then Delete (Stale Path Fix)", description: - "Client 0 creates A.md, syncs. Then renames A.md to B.md and " + - "immediately deletes B.md. The coalesced delete action has the " + - "old path 'A', but the doc is at 'B' in VFS. The delete executor " + - "must use the current VFS path, not the stale action path.", + "Client 0 renames A.md to B.md and immediately deletes B.md. " + + "Both clients should end up with zero files.", clients: 2, steps: [ - // Setup: create and sync { type: "create", client: 0, @@ -48,25 +16,13 @@ export const moveThenDeleteStalePathTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - { - type: "assert-content", - client: 1, - path: "A.md", - content: "content to delete" - }, - // Rename A→B then delete B (with sync enabled so VFS.move fires) { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, { type: "delete", client: 0, path: "B.md" }, { type: "sync" }, { type: "barrier" }, - // Both clients should have 0 files - { type: "assert-not-exists", client: 0, path: "A.md" }, - { type: "assert-not-exists", client: 0, path: "B.md" }, - { type: "assert-not-exists", client: 1, path: "A.md" }, - { type: "assert-not-exists", client: 1, path: "B.md" }, - { type: "assert-consistent", verify: verifyDeleted } + { type: "assert-consistent", verify: (s) => s.assertFileCount(0).assertFileNotExists("A.md").assertFileNotExists("B.md") } ] }; 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 827e7f77..66efd778 100644 --- a/frontend/deterministic-tests/src/tests/multi-file-operations.test.ts +++ b/frontend/deterministic-tests/src/tests/multi-file-operations.test.ts @@ -1,53 +1,11 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyState(state: ClientState): void { - const files = Array.from(state.files.keys()); - - // B.md must exist with updated content from client 1 - assert( - state.files.has("B.md"), - `Expected B.md to exist, got: ${files.join(", ")}` - ); - const bContent = state.files.get("B.md") ?? ""; - assert( - bContent.includes("updated"), - `Expected B.md to contain "updated", got: "${bContent}"` - ); - - // C.md must exist (created independently, unaffected) - assert( - state.files.has("C.md"), - `Expected C.md to exist, got: ${files.join(", ")}` - ); - - // A.md should not exist (deleted by client 0 or renamed by client 1) - assert( - !state.files.has("A.md"), - `A.md should not exist, got: ${files.join(", ")}` - ); - - // D.md: Client 1 renamed the server-deleted A.md to D.md offline. - // The system may keep D.md (rename wins) or drop it (delete wins). - // If D.md exists, it should have the original content. - if (state.files.has("D.md")) { - assert( - state.files.get("D.md") === "content-a", - `If D.md exists, it should have "content-a", got: "${state.files.get("D.md")}"` - ); - } -} +import type { TestDefinition } from "../test-definition"; export const multiFileOperationsTest: TestDefinition = { - name: "Multi-File Operations", description: - "Client 0 creates A.md, B.md, C.md. Both clients sync. Client 1 goes offline. " + - "Client 0 deletes A.md. Client 1 (offline) updates B.md and renames A.md to D.md. " + - "When Client 1 reconnects, the system must reconcile: A.md deleted on server, " + - "renamed on client 1; B.md updated on client 1. Both must converge.", + "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.", clients: 2, steps: [ - // Setup: create three files and sync { type: "create", client: 0, path: "A.md", content: "content-a" }, { type: "create", client: 0, path: "B.md", content: "content-b" }, { type: "create", client: 0, path: "C.md", content: "content-c" }, @@ -56,23 +14,26 @@ export const multiFileOperationsTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Client 1 goes offline { type: "disable-sync", client: 1 }, - // Client 0 deletes A.md and syncs { type: "delete", client: 0, path: "A.md" }, { type: "sync", client: 0 }, - // Client 1 (offline) updates B.md and renames A.md to D.md { type: "update", client: 1, path: "B.md", content: "updated by client 1" }, { type: "rename", client: 1, oldPath: "A.md", newPath: "D.md" }, - // Client 1 reconnects { type: "enable-sync", client: 1 }, { type: "sync", client: 1 }, { type: "barrier" }, - // Verify convergence: B.md and C.md must exist. B.md must have update. - { type: "assert-consistent", verify: verifyState } + { + type: "assert-consistent", + verify: (s) => { + s.assertContains("B.md", "updated") + .assertFileExists("C.md") + .assertFileNotExists("A.md"); + s.ifFileExists("D.md", (s) => s.assertContent("D.md", "content-a")); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/multiple-updates-coalesce.test.ts b/frontend/deterministic-tests/src/tests/multiple-updates-coalesce.test.ts deleted file mode 100644 index ba4de977..00000000 --- a/frontend/deterministic-tests/src/tests/multiple-updates-coalesce.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { TestDefinition } from "../test-definition"; - -export const multipleUpdatesCoalesceTest: TestDefinition = { - name: "Multiple Rapid Updates Converge to Final Version", - description: - "Client 0 rapidly updates a file multiple times while online. " + - "Both clients must converge to the final content.", - clients: 2, - steps: [ - // Setup: create file and sync - { type: "create", client: 0, path: "rapid.md", content: "v0" }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - { type: "assert-content", client: 1, path: "rapid.md", content: "v0" }, - - // Client 0 rapidly updates (sync is enabled, so events are enqueued) - { type: "update", client: 0, path: "rapid.md", content: "v1" }, - { type: "update", client: 0, path: "rapid.md", content: "v2" }, - { type: "update", client: 0, path: "rapid.md", content: "v3" }, - { type: "update", client: 0, path: "rapid.md", content: "v4-final" }, - - // Sync and converge - { type: "sync" }, - { type: "barrier" }, - - // Both should have the final version - { - type: "assert-content", - client: 0, - path: "rapid.md", - content: "v4-final" - }, - { - type: "assert-content", - client: 1, - path: "rapid.md", - content: "v4-final" - }, - { type: "assert-consistent" } - ] -}; 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 3e5dc3ca..56ecc00d 100644 --- a/frontend/deterministic-tests/src/tests/offline-concurrent-renames.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-concurrent-renames.test.ts @@ -1,41 +1,6 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyConvergence(state: ClientState): void { - const files = Array.from(state.files.keys()).sort(); - - // The original file A.md should not exist (both clients renamed it away) - assert( - !state.files.has("A.md"), - `A.md should not exist after both renames. Files: ${files.join(", ")}` - ); - - // Both clients renamed the same document. The server picks one rename - // as the winner. Exactly one file should exist (the document at its - // final path) since there was only one document to begin with. - assert( - state.files.size === 1, - `Expected exactly 1 file (same document renamed), got ${state.files.size}: ${files.join(", ")}` - ); - - // The rename target should be B.md or C.md - const hasB = state.files.has("B.md"); - const hasC = state.files.has("C.md"); - assert( - hasB || hasC, - `Expected B.md or C.md to exist. Files: ${files.join(", ")}` - ); - - // The content must be preserved regardless of which rename won - const [content] = Array.from(state.files.values()); - assert( - content === "shared-content", - `Expected content "shared-content", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const offlineConcurrentRenamesTest: TestDefinition = { - name: "Offline Concurrent Renames of Same File", description: "Client 0 creates A.md and syncs to both clients. Both clients go offline. " + "Client 0 renames A.md to B.md. Client 1 renames A.md to C.md. " + @@ -43,24 +8,19 @@ export const offlineConcurrentRenamesTest: TestDefinition = { "agree on the final state and the content must not be lost.", clients: 2, steps: [ - // Setup: create A.md and sync to both clients { type: "create", client: 0, path: "A.md", content: "shared-content" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, { - type: "assert-content", - client: 1, - path: "A.md", - content: "shared-content" + type: "assert-consistent", + verify: (s) => s.assertContent("A.md", "shared-content") }, - // Both clients go offline { type: "disable-sync", client: 0 }, { type: "disable-sync", client: 1 }, - // Client 0 renames A.md -> B.md { type: "rename", client: 0, @@ -68,7 +28,6 @@ export const offlineConcurrentRenamesTest: TestDefinition = { newPath: "B.md" }, - // Client 1 renames A.md -> C.md { type: "rename", client: 1, @@ -76,17 +35,24 @@ export const offlineConcurrentRenamesTest: TestDefinition = { newPath: "C.md" }, - // Both reconnect { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // A.md must be gone from both - { type: "assert-not-exists", client: 0, path: "A.md" }, - { type: "assert-not-exists", client: 1, path: "A.md" }, - - // Both must converge to the same state with content preserved - { type: "assert-consistent", verify: verifyConvergence } + { + type: "assert-consistent", + verify: (s) => { + s.assertFileNotExists("A.md") + .assertFileCount(1) + .assertAnyFileContains("shared-content"); + s.ifFileExists("B.md", (s) => + s.assertContent("B.md", "shared-content") + ); + s.ifFileExists("C.md", (s) => + s.assertContent("C.md", "shared-content") + ); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/offline-create-rename-create.test.ts b/frontend/deterministic-tests/src/tests/offline-create-rename-create.test.ts deleted file mode 100644 index 28a25cce..00000000 --- a/frontend/deterministic-tests/src/tests/offline-create-rename-create.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyBothFilesExist(state: ClientState): void { - const files = Array.from(state.files.keys()).sort(); - - // B.md should exist with the original content (renamed from A.md) - assert( - state.files.has("B.md"), - `B.md should exist (renamed from A.md). Files: ${files.join(", ")}` - ); - const bContent = state.files.get("B.md") ?? ""; - assert( - bContent === "first-content", - `B.md should have "first-content" (original file), got: "${bContent}"` - ); - - // A.md should exist with the new content (recreated after rename) - assert( - state.files.has("A.md"), - `A.md should exist (recreated after rename). Files: ${files.join(", ")}` - ); - const aContent = state.files.get("A.md") ?? ""; - assert( - aContent === "second-content", - `A.md should have "second-content" (new file), got: "${aContent}"` - ); - - // Exactly 2 files - assert( - state.files.size === 2, - `Expected 2 files, got ${state.files.size}: ${files.join(", ")}` - ); -} - -export const offlineCreateRenameCreateTest: TestDefinition = { - name: "Offline Create, Rename, Recreate Same Path", - description: - "Client 0 goes offline. Creates file A with content X, renames A to B, " + - "then creates a new file A with content Y. When Client 0 reconnects, " + - "Client 1 should see both A.md (content Y) and B.md (content X) -- " + - "the rename and the new create are independent documents.", - clients: 2, - steps: [ - // Client 1 starts syncing immediately to receive updates - { type: "enable-sync", client: 1 }, - - // Client 0 is offline and performs create -> rename -> create - { type: "create", client: 0, path: "A.md", content: "first-content" }, - { - type: "rename", - client: 0, - oldPath: "A.md", - newPath: "B.md" - }, - { type: "create", client: 0, path: "A.md", content: "second-content" }, - - // Client 0 enables sync -- offline reconciliation should detect - // B.md and A.md as two separate new files - { type: "enable-sync", client: 0 }, - { type: "sync" }, - { type: "barrier" }, - - // Both files should exist on both clients - { type: "assert-exists", client: 0, path: "A.md" }, - { type: "assert-exists", client: 0, path: "B.md" }, - { type: "assert-exists", client: 1, path: "A.md" }, - { type: "assert-exists", client: 1, path: "B.md" }, - { type: "assert-consistent", verify: verifyBothFilesExist } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/offline-create-same-path-binary-conflict.test.ts b/frontend/deterministic-tests/src/tests/offline-create-same-path-binary-conflict.test.ts index b43f1287..ca777563 100644 --- a/frontend/deterministic-tests/src/tests/offline-create-same-path-binary-conflict.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-create-same-path-binary-conflict.test.ts @@ -1,52 +1,11 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * EDGE CASE: Two clients create at the same path while offline — mergeable text files. - * - * When a remote-update arrives for a path where a local pending create - * exists, the code at sync-actions.ts line 1161 skips the remote download - * ONLY for mergeable file types. For mergeable files, the idempotency - * key resolution will handle the merge correctly. - * - * This test verifies that when both clients create at the same path with - * different text content while offline, the server merges correctly and - * both clients converge. - * - * The interesting edge case is: Client 0 creates and syncs first, then - * Client 1 creates at the same path. The server's smart create should - * merge the content (3-way merge with empty parent), and both clients - * should see both pieces of content. - */ -function verifyMergedContent(state: ClientState): void { - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.has("notes.md"), - `Expected notes.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - const content = state.files.get("notes.md") ?? ""; - assert( - content.includes("alpha wrote this line"), - `Expected content to include "alpha wrote this line", got: "${content}"` - ); - assert( - content.includes("beta wrote this different line"), - `Expected content to include "beta wrote this different line", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const offlineCreateSamePathMergeableTest: TestDefinition = { - name: "Offline Create Same Path — Mergeable Text", description: - "Both clients create a file at the same path while offline with " + - "different text content. When both sync, the server should 3-way " + - "merge the content and both clients should converge to the merged result.", + "Both clients create a file at the same path while offline with different text content. " + + "After both sync, both clients must converge to a merged result containing both contributions.", clients: 2, steps: [ - // Both clients create at same path while offline { type: "create", client: 0, @@ -60,14 +19,23 @@ export const offlineCreateSamePathMergeableTest: TestDefinition = { content: "beta wrote this different line" }, - // Enable sync — Client 0 syncs first, then Client 1's create - // triggers a smart merge on the server { type: "enable-sync", client: 0 }, { type: "sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyMergedContent } + { + type: "assert-consistent", + verify: (s) => + s + .assertFileCount(1) + .assertFileExists("notes.md") + .assertContains( + "notes.md", + "alpha wrote this line", + "beta wrote this different line" + ) + } ] }; 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 f4a25896..bf144048 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 @@ -1,46 +1,11 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyConvergence(state: ClientState): void { - const files = Array.from(state.files.keys()).sort(); - - // A.md should not exist (it was renamed/deleted) - assert( - !state.files.has("A.md"), - `A.md should not exist. Files: ${files.join(", ")}` - ); - - // B.md should still exist unaffected - assert( - state.files.has("B.md"), - `B.md should exist (untouched). Files: ${files.join(", ")}` - ); - assert( - state.files.get("B.md") === "content-b", - `B.md should have "content-b", got: "${state.files.get("B.md")}"` - ); - - // Clients must converge. If delete wins, A_renamed.md shouldn't exist. - // If rename wins, A_renamed.md should exist with content-a. - // Either way, both clients must agree. - if (state.files.has("A_renamed.md")) { - assert( - state.files.get("A_renamed.md") === "content-a", - `If A_renamed.md exists, it should have "content-a", got: "${state.files.get("A_renamed.md")}"` - ); - } -} +import type { TestDefinition } from "../test-definition"; export const offlineDeleteRemoteRenameTest: TestDefinition = { - name: "Offline Delete + Concurrent Remote Rename", description: - "Client 0 goes offline and deletes A.md locally. Meanwhile Client 1 " + - "renames A.md to A_renamed.md and syncs. When Client 0 reconnects, " + - "the offline reconciliation discovers A.md is missing locally but the " + - "server has it renamed. The system must converge consistently.", + "Client 0 deletes A.md offline while client 1 renames it to A_renamed.md. " + + "After client 0 reconnects, both clients must converge.", clients: 2, steps: [ - // Setup { type: "create", client: 0, path: "A.md", content: "content-a" }, { type: "create", client: 0, path: "B.md", content: "content-b" }, { type: "enable-sync", client: 0 }, @@ -48,11 +13,9 @@ export const offlineDeleteRemoteRenameTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Client 0 goes offline and deletes A.md { type: "disable-sync", client: 0 }, { type: "delete", client: 0, path: "A.md" }, - // Client 1 renames A.md -> A_renamed.md { type: "rename", client: 1, @@ -61,12 +24,19 @@ export const offlineDeleteRemoteRenameTest: TestDefinition = { }, { type: "sync", client: 1 }, - // Client 0 reconnects { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - // Both clients must converge - { type: "assert-consistent", verify: verifyConvergence } + { + type: "assert-consistent", + verify: (s) => { + s.assertFileNotExists("A.md") + .assertContent("B.md", "content-b"); + s.ifFileExists("A_renamed.md", (s) => + s.assertContent("A_renamed.md", "content-a") + ); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/offline-delete-vs-remote-update.test.ts b/frontend/deterministic-tests/src/tests/offline-delete-vs-remote-update.test.ts index d1d7dcf8..d86e3066 100644 --- a/frontend/deterministic-tests/src/tests/offline-delete-vs-remote-update.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-delete-vs-remote-update.test.ts @@ -1,48 +1,10 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; +import type { TestDefinition } from "../test-definition"; -function verifyConsistentState(state: ClientState): void { - // After Client 0 deletes and Client 1 updates the same file, - // both clients must agree. The delete intent should win (user - // explicitly deleted the file) and both clients should converge - // to having no files OR the file re-created. - // - // The coalescing path is: local-update enqueued for Client 1's - // remote broadcast → local-delete arrives → coalesces. - // - // Key assertion: both clients must be consistent, regardless - // of which intent wins. - const files = Array.from(state.files.keys()); - // File should NOT exist (delete wins in current implementation) - assert( - state.files.size === 0, - `Expected 0 files after delete-wins resolution, got ${state.files.size}: ${files.join(", ")}` - ); -} - -/** - * Tests the coalescing path: `remote-update + local-delete → delete`. - * - * When Client 0 comes online after deleting A.md, it receives a - * remote-update broadcast for A.md from Client 1's edit. The - * coalescing must produce a `delete` action (not `remote-delete` - * with isDeleted=false) so the executor properly marks the doc as - * deleted-locally and sends DELETE to the server. - * - * Before the fix: the coalescing produced `remote-delete` with the - * remote-update version (isDeleted=false). The executor treated this - * as a tracked doc update, downloaded the remote content, and - * silently resurrected the file — overriding the user's delete. - */ export const offlineDeleteVsRemoteUpdateTest: TestDefinition = { - name: "Offline Delete vs Remote Update", description: - "Client 0 deletes A.md while Client 1 updates A.md. Tests the " + - "coalescing of remote-update + local-delete and whether both " + - "clients converge to a consistent state.", + "Client 0 deletes A.md offline while client 1 updates it. Both clients must converge.", clients: 2, steps: [ - // Setup: both clients share A.md { type: "create", client: 0, @@ -54,17 +16,13 @@ export const offlineDeleteVsRemoteUpdateTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, { - type: "assert-content", - client: 1, - path: "A.md", - content: "original content" + type: "assert-consistent", + verify: (s) => s.assertContent("A.md", "original content") }, - // Client 0 goes offline and deletes A.md { type: "disable-sync", client: 0 }, { type: "delete", client: 0, path: "A.md" }, - // Client 1 updates A.md while Client 0 is offline { type: "update", client: 1, @@ -73,12 +31,13 @@ export const offlineDeleteVsRemoteUpdateTest: TestDefinition = { }, { type: "sync", client: 1 }, - // Client 0 comes online — receives remote-update for A.md - // but has already deleted it locally { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyConsistentState } + { + type: "assert-consistent", + verify: (s) => s.assertFileCount(0) + } ] }; diff --git a/frontend/deterministic-tests/src/tests/offline-edit-remote-rename.test.ts b/frontend/deterministic-tests/src/tests/offline-edit-remote-rename.test.ts index 16bcdfce..fc4383e4 100644 --- a/frontend/deterministic-tests/src/tests/offline-edit-remote-rename.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-edit-remote-rename.test.ts @@ -1,56 +1,21 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyEditPreservedAtNewPath(state: ClientState): void { - const files = Array.from(state.files.keys()).sort(); - - // A.md should not exist (it was renamed to B.md) - assert( - !state.files.has("A.md"), - `A.md should not exist after rename. Files: ${files.join(", ")}` - ); - - // B.md should exist with Client 0's edit merged in - assert( - state.files.has("B.md"), - `Expected B.md to exist. Files: ${files.join(", ")}` - ); - - const content = state.files.get("B.md") ?? ""; - assert( - content.includes("edited by client 0"), - `Expected B.md to contain Client 0's edit "edited by client 0", got: "${content}"` - ); - - assert( - state.files.size === 1, - `Expected exactly 1 file, got ${state.files.size}: ${files.join(", ")}` - ); -} +import type { TestDefinition } from "../test-definition"; export const offlineEditRemoteRenameTest: TestDefinition = { - name: "Offline Edit + Remote Rename", description: - "Client 0 goes offline and edits A.md. Meanwhile Client 1 renames " + - "A.md to B.md. When Client 0 reconnects, its edit should be applied " + - "to B.md (the renamed path). The edit must not be lost and A.md must " + - "not exist.", + "Client 0 edits A.md offline while client 1 renames A.md to B.md. " + + "After client 0 reconnects, the edit must appear in B.md and A.md must not exist.", clients: 2, steps: [ - // Setup: create and sync { type: "create", client: 0, path: "A.md", content: "original" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, { - type: "assert-content", - client: 1, - path: "A.md", - content: "original" + type: "assert-consistent", + verify: (s) => s.assertContent("A.md", "original") }, - // Client 0 goes offline and edits { type: "disable-sync", client: 0 }, { type: "update", @@ -59,7 +24,6 @@ export const offlineEditRemoteRenameTest: TestDefinition = { content: "edited by client 0" }, - // Client 1 renames A.md -> B.md while Client 0 is offline { type: "rename", client: 1, @@ -68,13 +32,17 @@ export const offlineEditRemoteRenameTest: TestDefinition = { }, { type: "sync", client: 1 }, - // Client 0 reconnects — edit must be preserved at new path { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-not-exists", client: 0, path: "A.md" }, - { type: "assert-not-exists", client: 1, path: "A.md" }, - { type: "assert-consistent", verify: verifyEditPreservedAtNewPath } + { + type: "assert-consistent", + verify: (s) => + s + .assertFileNotExists("A.md") + .assertFileCount(1) + .assertContains("B.md", "edited by client 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 cf8b36e8..77d50099 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 @@ -1,49 +1,10 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * EDGE CASE: File moved AND edited to have the same hash as another file. - * - * reconcileWithDisk detects moves by matching content hashes. But if a - * file is moved AND edited such that its new content matches a different - * missing file's hash, the move detection assigns it to the WRONG document. - * - * Scenario: - * 1. Two files exist: A.md ("content A") and B.md ("content B") - * 2. Client goes offline - * 3. A.md is deleted, B.md is renamed to C.md and edited to "content A" - * 4. On reconnect, reconcileWithDisk sees: - * - Missing: A.md (hash="content A"), B.md (hash="content B") - * - New: C.md (hash="content A") - * - C.md's hash matches A.md's hash → wrong move detection! - * - B.md is treated as deleted instead of renamed - * - * The system should still converge correctly despite the false match. - */ -function verifyFinalState(state: ClientState): void { - assert(!state.files.has("A.md"), "A.md should not exist"); - assert(!state.files.has("B.md"), "B.md should not exist"); - assert(state.files.has("C.md"), "C.md should exist"); - const content = state.files.get("C.md") ?? ""; - assert( - content === "content A", - `Expected C.md to contain "content A", got: "${content}"` - ); - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); -} +import type { TestDefinition } from "../test-definition"; export const offlineEditThenMoveSameContentTest: TestDefinition = { - name: "Offline Move + Edit Creates False Hash Match", description: - "A file is renamed and edited to have the same content as a deleted " + - "file. Move detection may match against the wrong document. The " + - "system should still converge.", + "A file is renamed and edited to match a deleted file's content. Both clients must converge despite the ambiguity.", clients: 2, steps: [ - // Setup: create two files with different content { type: "create", client: 0, @@ -61,16 +22,12 @@ export const offlineEditThenMoveSameContentTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Client 0 goes offline { type: "disable-sync", client: 0 }, - // Delete A.md { type: "delete", client: 0, path: "A.md" }, - // Rename B.md → C.md { type: "rename", client: 0, oldPath: "B.md", newPath: "C.md" }, - // Edit C.md to have the same content as the now-deleted A.md { type: "update", client: 0, @@ -78,11 +35,18 @@ export const offlineEditThenMoveSameContentTest: TestDefinition = { content: "content A" }, - // Reconnect { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyFinalState } + { + type: "assert-consistent", + verify: (s) => + s + .assertFileNotExists("A.md") + .assertFileNotExists("B.md") + .assertContent("C.md", "content A") + .assertFileCount(1) + } ] }; diff --git a/frontend/deterministic-tests/src/tests/offline-mixed-operations.test.ts b/frontend/deterministic-tests/src/tests/offline-mixed-operations.test.ts index ca6a3c91..68453a0e 100644 --- a/frontend/deterministic-tests/src/tests/offline-mixed-operations.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-mixed-operations.test.ts @@ -1,57 +1,12 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyFinalState(state: ClientState): void { - const files = Array.from(state.files.keys()).sort(); - - // file1.md was deleted -- must not exist - assert( - !state.files.has("file1.md"), - `file1.md should have been deleted but exists. Files: ${files.join(", ")}` - ); - - // file2.md was renamed to moved.md - assert( - !state.files.has("file2.md"), - `file2.md should have been renamed but still exists. Files: ${files.join(", ")}` - ); - assert( - state.files.has("moved.md"), - `moved.md should exist after rename. Files: ${files.join(", ")}` - ); - const movedContent = state.files.get("moved.md") ?? ""; - assert( - movedContent === "content-2", - `moved.md should have original content "content-2", got: "${movedContent}"` - ); - - // file3.md was updated - assert( - state.files.has("file3.md"), - `file3.md should exist. Files: ${files.join(", ")}` - ); - const file3Content = state.files.get("file3.md") ?? ""; - assert( - file3Content === "updated-content-3", - `file3.md should have "updated-content-3", got: "${file3Content}"` - ); - - // Exactly 2 files should remain - assert( - state.files.size === 2, - `Expected 2 files, got ${state.files.size}: ${files.join(", ")}` - ); -} +import type { TestDefinition } from "../test-definition"; export const offlineMixedOperationsTest: TestDefinition = { - name: "Offline Mixed Operations (Delete + Rename + Edit)", description: "Client 0 creates 3 files, syncs to both clients. Client 0 goes offline, " + "deletes file 1, renames file 2 to a new name, and edits file 3. " + "When Client 0 reconnects, all three operations should propagate to Client 1.", clients: 2, steps: [ - // Setup: Client 0 creates 3 files and syncs { type: "create", client: 0, path: "file1.md", content: "content-1" }, { type: "create", client: 0, path: "file2.md", content: "content-2" }, { type: "create", client: 0, path: "file3.md", content: "content-3" }, @@ -60,30 +15,17 @@ export const offlineMixedOperationsTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Verify initial sync { - type: "assert-content", - client: 1, - path: "file1.md", - content: "content-1" - }, - { - type: "assert-content", - client: 1, - path: "file2.md", - content: "content-2" - }, - { - type: "assert-content", - client: 1, - path: "file3.md", - content: "content-3" + type: "assert-consistent", + verify: (s) => + s + .assertContent("file1.md", "content-1") + .assertContent("file2.md", "content-2") + .assertContent("file3.md", "content-3") }, - // Client 0 goes offline { type: "disable-sync", client: 0 }, - // Client 0 performs three different offline operations { type: "delete", client: 0, path: "file1.md" }, { type: "rename", @@ -98,16 +40,19 @@ export const offlineMixedOperationsTest: TestDefinition = { content: "updated-content-3" }, - // Client 0 reconnects { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - // All operations should have propagated - { type: "assert-not-exists", client: 1, path: "file1.md" }, - { type: "assert-not-exists", client: 1, path: "file2.md" }, - { type: "assert-exists", client: 1, path: "moved.md" }, - { type: "assert-exists", client: 1, path: "file3.md" }, - { type: "assert-consistent", verify: verifyFinalState } + { + type: "assert-consistent", + verify: (s) => + s + .assertFileNotExists("file1.md") + .assertFileNotExists("file2.md") + .assertContent("moved.md", "content-2") + .assertContent("file3.md", "updated-content-3") + .assertFileCount(2) + } ] }; diff --git a/frontend/deterministic-tests/src/tests/offline-move-then-remote-delete.test.ts b/frontend/deterministic-tests/src/tests/offline-move-then-remote-delete.test.ts index 2276d53a..d1522528 100644 --- a/frontend/deterministic-tests/src/tests/offline-move-then-remote-delete.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-move-then-remote-delete.test.ts @@ -1,44 +1,11 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG: Move + remote-delete coalescing uses stale source path. - * - * Found by: multi-client convergence agent (#10) - * - * When a local move and a remote-delete are coalesced for the same document: - * move(A→B) + remote-delete = delete(path: A) - * (sync-events.ts line 210-211) - * - * But the VFS has already moved the document from A to B (syncer.ts - * line 152 runs vfs.move() immediately on the local-move event). - * When the executor tries to find the document at path A (line 302 - * in syncer.ts), it returns undefined because D1 is now at path B. - * The delete is silently skipped. - * - * The system should recover via runFinalConsistencyCheck() or the next - * reconciliation cycle, which will detect that B.md exists on disk - * but the server says D1 is deleted. - * - * This test verifies that both clients converge — the file should end - * up deleted on both clients. - */ -function verifyNoFiles(state: ClientState): void { - assert( - state.files.size === 0, - `Expected 0 files, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); -} +import type { TestDefinition } from "../test-definition"; export const offlineMoveThenRemoteDeleteTest: TestDefinition = { - name: "Offline Move + Remote Delete Convergence", description: - "Client 0 renames A→B offline while Client 1 deletes A. " + - "The move+delete coalescing may use a stale path. " + - "Both clients should converge to having no files.", + "Client 0 renames A.md to B.md offline while client 1 deletes A.md. " + + "Both clients must converge to having no files.", clients: 2, steps: [ - // Setup: both have A.md { type: "create", client: 0, @@ -50,24 +17,23 @@ export const offlineMoveThenRemoteDeleteTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Client 0 goes offline, renames A→B { type: "disable-sync", client: 0 }, { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, - // Client 1 deletes A.md (broadcasts to server) { type: "delete", client: 1, path: "A.md" }, { type: "sync", client: 1 }, - // Client 0 reconnects — receives remote-delete while move is pending { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - // Both should converge to no files - { type: "assert-not-exists", client: 0, path: "A.md" }, - { type: "assert-not-exists", client: 1, path: "A.md" }, - { type: "assert-not-exists", client: 0, path: "B.md" }, - { type: "assert-not-exists", client: 1, path: "B.md" }, - { type: "assert-consistent", verify: verifyNoFiles } + { + type: "assert-consistent", + verify: (s) => + s + .assertFileNotExists("A.md") + .assertFileNotExists("B.md") + .assertFileCount(0) + } ] }; diff --git a/frontend/deterministic-tests/src/tests/offline-multiple-edits.test.ts b/frontend/deterministic-tests/src/tests/offline-multiple-edits.test.ts index 39aa7ba1..e242223a 100644 --- a/frontend/deterministic-tests/src/tests/offline-multiple-edits.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-multiple-edits.test.ts @@ -1,72 +1,38 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyOnlyLatestVersion(state: ClientState): void { - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.has("doc.md"), - `Expected doc.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - const content = state.files.get("doc.md") ?? ""; - assert( - content === "edit-5-final", - `Expected doc.md to have "edit-5-final" (latest edit), got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const offlineMultipleEditsTest: TestDefinition = { - name: "Offline Multiple Edits Converge to Latest", description: "Client 0 creates a file and syncs. Client 0 goes offline, edits the file " + "5 times with different content. When Client 0 reconnects, both clients " + "must converge to the final version.", clients: 2, steps: [ - // Setup: create file and sync to both clients { type: "create", client: 0, path: "doc.md", content: "original" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, { - type: "assert-content", - client: 1, - path: "doc.md", - content: "original" + type: "assert-consistent", + verify: (s) => s.assertContent("doc.md", "original") }, - // Client 0 goes offline { type: "disable-sync", client: 0 }, - // Client 0 makes 5 sequential edits while offline { type: "update", client: 0, path: "doc.md", content: "edit-1" }, { type: "update", client: 0, path: "doc.md", content: "edit-2" }, { type: "update", client: 0, path: "doc.md", content: "edit-3" }, { type: "update", client: 0, path: "doc.md", content: "edit-4" }, { type: "update", client: 0, path: "doc.md", content: "edit-5-final" }, - // Client 0 reconnects -- offline reconciliation should detect the - // changed hash and sync the current on-disk content (edit-5-final) { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - // Both clients should have the final version { - type: "assert-content", - client: 0, - path: "doc.md", - content: "edit-5-final" - }, - { - type: "assert-content", - client: 1, - path: "doc.md", - content: "edit-5-final" - }, - { type: "assert-consistent", verify: verifyOnlyLatestVersion } + type: "assert-consistent", + verify: (s) => + s.assertFileCount(1).assertContent("doc.md", "edit-5-final") + } ] }; diff --git a/frontend/deterministic-tests/src/tests/offline-rename-and-edit.test.ts b/frontend/deterministic-tests/src/tests/offline-rename-and-edit.test.ts index 4d2cb9d4..c446d459 100644 --- a/frontend/deterministic-tests/src/tests/offline-rename-and-edit.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-rename-and-edit.test.ts @@ -1,60 +1,37 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyContent(state: ClientState): void { - // The file should be at B.md with the exact edited content - assert( - state.files.has("B.md"), - `Expected B.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - const content = state.files.get("B.md") ?? ""; - assert( - content === "edited after rename", - `Expected B.md to be "edited after rename", got: "${content}"` - ); - - // A.md should not exist (renamed away) - assert( - !state.files.has("A.md"), - `A.md should not exist after rename, got: ${Array.from(state.files.keys()).join(", ")}` - ); - - // Only B.md should exist - assert( - state.files.size === 1, - `Expected exactly 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); -} +import type { TestDefinition } from "../test-definition"; export const offlineRenameAndEditTest: TestDefinition = { - name: "Offline Rename and Edit", description: "Client 0 creates A.md and syncs. Client 0 goes offline, renames A.md " + "to B.md, then edits B.md. When Client 0 reconnects, the rename and edit " + "should both propagate to Client 1.", clients: 2, steps: [ - // Setup: create and sync { type: "create", client: 0, path: "A.md", content: "original" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-content", client: 1, path: "A.md", content: "original" }, + { + type: "assert-consistent", + verify: (s) => s.assertContent("A.md", "original") + }, - // Client 0 goes offline, renames and edits { type: "disable-sync", client: 0 }, { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, { type: "update", client: 0, path: "B.md", content: "edited after rename" }, - // Client 0 reconnects { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - // A.md should be gone, B.md should have edited content - { type: "assert-not-exists", client: 0, path: "A.md" }, - { type: "assert-not-exists", client: 1, path: "A.md" }, - { type: "assert-consistent", verify: verifyContent } + { + type: "assert-consistent", + verify: (s) => + s + .assertFileNotExists("A.md") + .assertFileCount(1) + .assertContent("B.md", "edited after rename") + } ] }; 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 4814118f..24f4ff2a 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 @@ -1,49 +1,22 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyResult(state: ClientState): void { - const files = Array.from(state.files.keys()).sort(); - - // Y.md should exist — the renamed original document with - // Client 1's updated content merged in. - assert( - state.files.has("Y.md"), - `Expected Y.md to exist. Files: ${files.join(", ")}` - ); - const content = state.files.get("Y.md") ?? ""; - assert( - content.includes("updated-by-client-1"), - `Expected Y.md to contain "updated-by-client-1", got: "${content}"` - ); - - assert( - state.files.size === 1, - `Expected exactly 1 file, got ${state.files.size}: ${files.join(", ")}` - ); -} +import type { TestDefinition } from "../test-definition"; export const offlineRenameRemoteCreateOldPathTest: TestDefinition = { - name: "Offline Rename + Remote Create at Old Path", description: "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.", clients: 2, steps: [ - // Setup: create X.md and sync { type: "create", client: 0, path: "X.md", content: "original" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, { - type: "assert-content", - client: 1, - path: "X.md", - content: "original" + type: "assert-consistent", + verify: (s) => s.assertContent("X.md", "original") }, - // Client 0 goes offline and renames { type: "disable-sync", client: 0 }, { type: "rename", @@ -52,7 +25,6 @@ export const offlineRenameRemoteCreateOldPathTest: TestDefinition = { newPath: "Y.md" }, - // Client 1 updates the same document at X.md { type: "update", client: 1, @@ -61,12 +33,16 @@ export const offlineRenameRemoteCreateOldPathTest: TestDefinition = { }, { type: "sync", client: 1 }, - // Client 0 reconnects — must detect move AND merge with update { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - // Both clients should converge: Y.md with Client 1's content - { type: "assert-consistent", verify: verifyResult } + { + type: "assert-consistent", + verify: (s) => + s + .assertFileCount(1) + .assertContains("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 9d4e6c44..47a88328 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 @@ -1,48 +1,6 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; +import type { TestDefinition } from "../test-definition"; -function verifyFinalState(state: ClientState): void { - const files = Array.from(state.files.keys()); - // Client 0 updated both files, then deleted B.md. - // Client 1 updated B.md while Client 0 was offline. - // - // After reconnect: - // - A.md should have Client 0's update - // - B.md: Client 0 deleted it (local intent), Client 1 updated it - // (remote update). The coalescing path determines which wins. - // Current behavior: delete wins (local-delete + remote-update - // coalesces differently depending on ordering). - assert( - state.files.has("A.md"), - `Expected A.md to exist, got: ${files.join(", ")}` - ); - const aContent = state.files.get("A.md") ?? ""; - assert( - aContent === "A updated by client 0", - `Expected A.md to have Client 0's update, got: "${aContent}"` - ); - - // B.md should be gone (Client 0 deleted it) - assert( - !state.files.has("B.md"), - `Expected B.md to be deleted, got: ${files.join(", ")}` - ); -} - -/** - * Tests a complex offline scenario: Client 0 goes offline, updates - * two files, then deletes one of them. Meanwhile Client 1 updates - * the file that Client 0 will delete. When Client 0 comes online, - * the reconciliation must handle: - * 1. A.md: local update (straightforward) - * 2. B.md: deleted locally + updated remotely (conflict) - * - * This exercises the offline reconciliation ordering: - * updates are enqueued before deletes, and coalescing with - * remote updates received during reconnect. - */ export const offlineUpdateBothThenDeleteOneTest: TestDefinition = { - name: "Offline Update Both Files Then Delete One", description: "Client 0 goes offline, updates A.md and B.md, then deletes B.md. " + "Client 1 updates B.md while Client 0 is offline. When Client 0 " + @@ -50,7 +8,6 @@ export const offlineUpdateBothThenDeleteOneTest: TestDefinition = { "consistently resolved (delete wins).", clients: 2, steps: [ - // Setup: create two files { type: "create", client: 0, @@ -68,22 +25,15 @@ export const offlineUpdateBothThenDeleteOneTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, { - type: "assert-content", - client: 1, - path: "A.md", - content: "A original" - }, - { - type: "assert-content", - client: 1, - path: "B.md", - content: "B original" + type: "assert-consistent", + verify: (s) => + s + .assertContent("A.md", "A original") + .assertContent("B.md", "B original") }, - // Client 0 goes offline { type: "disable-sync", client: 0 }, - // Client 0 updates both files { type: "update", client: 0, @@ -97,10 +47,8 @@ export const offlineUpdateBothThenDeleteOneTest: TestDefinition = { content: "B updated by client 0" }, - // Client 0 deletes B.md { type: "delete", client: 0, path: "B.md" }, - // Meanwhile Client 1 updates B.md { type: "update", client: 1, @@ -109,11 +57,16 @@ export const offlineUpdateBothThenDeleteOneTest: TestDefinition = { }, { type: "sync", client: 1 }, - // Client 0 comes online { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyFinalState } + { + type: "assert-consistent", + verify: (s) => + s + .assertContent("A.md", "A updated by client 0") + .assertFileNotExists("B.md") + } ] }; diff --git a/frontend/deterministic-tests/src/tests/online-create-rename-concurrent-create-orphan.test.ts b/frontend/deterministic-tests/src/tests/online-create-rename-concurrent-create-orphan.test.ts new file mode 100644 index 00000000..3449e676 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/online-create-rename-concurrent-create-orphan.test.ts @@ -0,0 +1,30 @@ +import type { TestDefinition } from "../test-definition"; + +export const onlineCreateRenameConcurrentCreateOrphanTest: TestDefinition = { + description: + "Client 0 creates a binary file and renames it while offline, then reconnects and immediately deletes it. " + + "Both clients must converge to zero files.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + + { type: "create", client: 0, path: "data.bin", content: "BINARY:offline-content" }, + { type: "rename", client: 0, oldPath: "data.bin", newPath: "moved.bin" }, + + { type: "enable-sync", client: 0 }, + { type: "delete", client: 0, path: "moved.bin" }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state) => { + state.assertFileCount(0); + } + } + ] +}; 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 new file mode 100644 index 00000000..b575aa58 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/online-delete-recreate-rapid-cycle.test.ts @@ -0,0 +1,34 @@ +import type { TestDefinition } from "../test-definition"; + +export const onlineDeleteRecreateRapidCycleTest: TestDefinition = { + description: + "A file is deleted and recreated multiple times by alternating clients while both are online. " + + "Both clients must converge after each cycle.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "round 0" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "delete", client: 1, path: "A.md" }, + { type: "barrier" }, + { type: "create", client: 0, path: "A.md", content: "round 1" }, + { type: "barrier" }, + + { type: "delete", client: 0, path: "A.md" }, + { type: "barrier" }, + { type: "create", client: 1, path: "A.md", content: "round 2" }, + { type: "barrier" }, + + { type: "delete", client: 1, path: "A.md" }, + { type: "barrier" }, + { type: "create", client: 0, path: "A.md", content: "round 3" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s) => s.assertContent("A.md", "round 3"), + }, + ], +}; diff --git a/frontend/deterministic-tests/src/tests/online-edit-vs-delete-convergence.test.ts b/frontend/deterministic-tests/src/tests/online-edit-vs-delete-convergence.test.ts new file mode 100644 index 00000000..16ed7236 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/online-edit-vs-delete-convergence.test.ts @@ -0,0 +1,27 @@ +import type { TestDefinition } from "../test-definition"; + +export const onlineEditVsDeleteConvergenceTest: TestDefinition = { + description: + "Both clients are online. Client 0 edits a file while client 1 " + + "deletes it. The clients must converge to the same state.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "original" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "update", client: 0, path: "A.md", content: "edited by client 0" }, + { type: "delete", client: 1, path: "A.md" }, + + { type: "barrier" }, + { + type: "assert-consistent", + verify: (state) => { + state.ifFileExists("A.md", (s) => + s.assertContainsAny("A.md", "edited by client 0") + ); + } + }, + ], +}; diff --git a/frontend/deterministic-tests/src/tests/overlapping-edits-same-section.test.ts b/frontend/deterministic-tests/src/tests/overlapping-edits-same-section.test.ts index 6a22d200..eeb705de 100644 --- a/frontend/deterministic-tests/src/tests/overlapping-edits-same-section.test.ts +++ b/frontend/deterministic-tests/src/tests/overlapping-edits-same-section.test.ts @@ -1,48 +1,11 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyMergedEdits(state: ClientState): void { - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}` - ); - assert( - state.files.has("doc.md"), - `Expected doc.md to exist` - ); - const content = state.files.get("doc.md") ?? ""; - - // Both edits should be present in the merged result. - // Client 0 added "alpha addition" and Client 1 added "beta addition". - // The shared heading and footer should be preserved. - assert( - content.includes("# Title"), - `Expected "# Title" to be preserved, got: "${content}"` - ); - assert( - content.includes("alpha addition"), - `Expected Client 0's edit "alpha addition" to be present, got: "${content}"` - ); - assert( - content.includes("beta addition"), - `Expected Client 1's edit "beta addition" to be present, got: "${content}"` - ); - assert( - content.includes("footer"), - `Expected "footer" to be preserved, got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const overlappingEditsSameSectionTest: TestDefinition = { - name: "Overlapping Edits in Same Section", description: - "Both clients edit the same document by adding content to different " + - "parts of the same section. Client 0 adds a line after the heading, " + - "Client 1 adds a line before the footer. The 3-way merge should " + - "preserve both edits without data loss.", + "Both clients go offline and edit different parts of the same document. " + + "After both reconnect, both edits must be preserved without data loss.", clients: 2, steps: [ - // Setup: create a multi-line document { type: "create", client: 0, @@ -54,11 +17,9 @@ export const overlappingEditsSameSectionTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Both clients go offline and edit the same document { type: "disable-sync", client: 0 }, { type: "disable-sync", client: 1 }, - // Client 0: add line after heading { type: "update", client: 0, @@ -66,7 +27,6 @@ export const overlappingEditsSameSectionTest: TestDefinition = { content: "# Title\nalpha addition\n\nfooter" }, - // Client 1: add line before footer { type: "update", client: 1, @@ -74,13 +34,16 @@ export const overlappingEditsSameSectionTest: TestDefinition = { content: "# Title\n\nbeta addition\nfooter" }, - // Both reconnect { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Both edits should be merged - { type: "assert-consistent", verify: verifyMergedEdits } + { + type: "assert-consistent", + verify: (s) => + s.assertFileCount(1) + .assertContains("doc.md", "# Title", "alpha addition", "beta addition", "footer"), + } ] }; 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 5cd558df..181f256c 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 @@ -1,79 +1,32 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG: Queue reset discards local events embedded in remote action types. - * - * In sync-event-queue.ts reset() (line 172-179): - * for (const [key, state] of this.documentStates.entries()) { - * if (state.action === "remote-update" || state.action === "remote-delete") { - * this.documentStates.delete(key); - * } - * } - * - * This removes all actions with type "remote-update" or "remote-delete". - * But coalescing can embed local events INTO remote actions: - * - * remote-update + local-update = remote-update (line 262-264) - * remote-delete + local-update = remote-delete (line 295-297) - * remote-delete + local-move = remote-delete (line 301-303) - * - * When the queue resets (WebSocket disconnect), these coalesced actions - * are removed — silently discarding the local-update/move intent. - * - * The local edit IS recovered on the next reconnect via - * scheduleSyncForOfflineChanges() (which scans the filesystem and - * detects hash mismatches). But there is a narrow window where the - * edit could be lost if metadata was partially updated. - * - * This test verifies that local edits survive a disconnect that happens - * while the edit is coalesced with a remote event. - */ -function verifyEditSurvived(state: ClientState): void { - assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); - assert(state.files.has("doc.md"), "Expected doc.md to exist"); - const content = state.files.get("doc.md")!; - // Both edits should survive — the filesystem scan on reconnect must recover the local edit - assert( - content.includes("from client 0") && content.includes("from client 1"), - `Expected merged content with both edits, got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const queueResetLosesCoalescedLocalEditTest: TestDefinition = { - name: "Queue Reset Preserves Coalesced Local Edits", description: - "When a local-update is coalesced into a remote-update action " + - "and then the WebSocket disconnects, the queue reset removes " + - "the remote-update — potentially losing the local edit. " + - "The filesystem scan on reconnect should recover it.", + "Client 1 edits a shared file, then client 0 also edits it and immediately disconnects. " + + "After client 0 reconnects, both edits must be preserved.", clients: 2, steps: [ - // Setup: both clients have doc.md { type: "create", client: 0, path: "doc.md", content: "original" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Client 1 edits — this will broadcast a remote-update to client 0 { type: "update", client: 1, path: "doc.md", content: "from client 1" }, { type: "sync", client: 1 }, - // Client 0 edits (local-update) — may coalesce with the pending - // remote-update in the queue as: remote-update + local-update = remote-update { type: "update", client: 0, path: "doc.md", content: "from client 0" }, - // Immediately disconnect client 0 — queue.reset() removes remote events { type: "disable-sync", client: 0 }, - // Reconnect — scheduleSyncForOfflineChanges should detect the - // local edit via hash mismatch and re-queue it { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - // Both must converge with the local edit preserved - { type: "assert-consistent", verify: verifyEditSurvived } + { + type: "assert-consistent", + verify: (s) => + s.assertFileCount(1).assertContains("doc.md", "from client 0", "from client 1"), + } ] }; diff --git a/frontend/deterministic-tests/src/tests/rapid-create-update-delete-cycle.test.ts b/frontend/deterministic-tests/src/tests/rapid-create-update-delete-cycle.test.ts index 62fc7e41..cc011dc0 100644 --- a/frontend/deterministic-tests/src/tests/rapid-create-update-delete-cycle.test.ts +++ b/frontend/deterministic-tests/src/tests/rapid-create-update-delete-cycle.test.ts @@ -1,42 +1,9 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * EDGE CASE: Rapid create-update-delete cycle tests coalescing correctness. - * - * When events arrive faster than the queue can process them, coalescing - * determines the final action. This tests the full cycle: - * - * create + update = create (content read at sync time) - * create + delete = noop - * - * So a create-update-delete sequence should coalesce to noop and never - * reach the server at all. - * - * But then a new create follows: - * noop + create = create - * - * The final file should be synced correctly. - */ -function verifyFinalState(state: ClientState): void { - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - assert(state.files.has("cycle.md"), "Expected cycle.md to exist"); - const content = state.files.get("cycle.md") ?? ""; - assert( - content === "final creation", - `Expected "final creation", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const rapidCreateUpdateDeleteCycleTest: TestDefinition = { - name: "Rapid Create-Update-Delete-Create Cycle", description: - "Client 0 rapidly creates, updates, deletes, then re-creates a file. " + - "The event coalescing should correctly reduce this to a single create " + - "of the final content. Client 1 should see only the final file.", + "Client 0 rapidly creates, updates, deletes, then re-creates a file while the server is paused. " + + "After the server resumes, client 1 must see only the final file.", clients: 2, steps: [ { type: "enable-sync", client: 0 }, @@ -44,10 +11,8 @@ export const rapidCreateUpdateDeleteCycleTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Pause server so all operations coalesce before being processed { type: "pause-server" }, - // Rapid cycle: create → update → delete { type: "create", client: 0, @@ -62,7 +27,6 @@ export const rapidCreateUpdateDeleteCycleTest: TestDefinition = { }, { type: "delete", client: 0, path: "cycle.md" }, - // Re-create with final content { type: "create", client: 0, @@ -70,11 +34,13 @@ export const rapidCreateUpdateDeleteCycleTest: TestDefinition = { content: "final creation" }, - // Resume server { type: "resume-server" }, { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyFinalState } + { + type: "assert-consistent", + verify: (s) => s.assertFileCount(1).assertContent("cycle.md", "final creation"), + } ] }; diff --git a/frontend/deterministic-tests/src/tests/rapid-edit-delete-online-convergence.test.ts b/frontend/deterministic-tests/src/tests/rapid-edit-delete-online-convergence.test.ts new file mode 100644 index 00000000..042942b3 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rapid-edit-delete-online-convergence.test.ts @@ -0,0 +1,44 @@ +import type { TestDefinition } from "../test-definition"; + +export const rapidEditDeleteOnlineConvergenceTest: TestDefinition = { + description: + "Client 0 rapidly edits multiple files while client 1 deletes some of them, all while both are online. " + + "Both clients must converge to a consistent state.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "content A" }, + { type: "create", client: 0, path: "B.md", content: "content B" }, + { type: "create", client: 0, path: "C.md", content: "content C" }, + { type: "create", client: 0, path: "D.md", content: "content D" }, + { type: "create", client: 0, path: "E.md", content: "content E" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "update", client: 0, path: "A.md", content: "A edit 1" }, + { type: "update", client: 0, path: "B.md", content: "B edit 1" }, + { type: "update", client: 0, path: "C.md", content: "C edit 1" }, + { type: "delete", client: 1, path: "A.md" }, + { type: "delete", client: 1, path: "C.md" }, + { type: "delete", client: 1, path: "E.md" }, + { type: "update", client: 0, path: "A.md", content: "A edit 2" }, + { type: "update", client: 0, path: "B.md", content: "B edit 2" }, + { type: "update", client: 0, path: "C.md", content: "C edit 2" }, + + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s) => { + for (const [path, content] of s.files) { + for (const clientFiles of s.clientFiles) { + if (clientFiles.has(path) && clientFiles.get(path) !== content) { + throw new Error( + `Content mismatch for ${path}: "${clientFiles.get(path)}" vs "${content}"` + ); + } + } + } + }, + }, + ], +}; diff --git a/frontend/deterministic-tests/src/tests/rapid-sync-toggle.test.ts b/frontend/deterministic-tests/src/tests/rapid-sync-toggle.test.ts deleted file mode 100644 index 6bfb3447..00000000 --- a/frontend/deterministic-tests/src/tests/rapid-sync-toggle.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { TestDefinition } from "../test-definition"; - -export const rapidSyncToggleTest: TestDefinition = { - name: "Rapid Sync Toggle", - description: - "Client 0 creates a file, then toggles sync off and on multiple times. " + - "The file should eventually sync to Client 1 without deadlocks or data loss.", - clients: 2, - steps: [ - { type: "enable-sync", client: 1 }, - - // Create a file while offline - { type: "create", client: 0, path: "stable.md", content: "must survive toggles" }, - - // Toggle sync on client 0 multiple times - { type: "enable-sync", client: 0 }, - { type: "disable-sync", client: 0 }, - { type: "enable-sync", client: 0 }, - { type: "disable-sync", client: 0 }, - - // Final enable — this one must succeed - { type: "enable-sync", client: 0 }, - { type: "sync" }, - { type: "barrier" }, - - { type: "assert-exists", client: 0, path: "stable.md" }, - { type: "assert-exists", client: 1, path: "stable.md" }, - { - type: "assert-content", - client: 1, - path: "stable.md", - content: "must survive toggles" - }, - { type: "assert-consistent" } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/rapid-updates-after-merge.test.ts b/frontend/deterministic-tests/src/tests/rapid-updates-after-merge.test.ts index e0d49bfd..bf0ed488 100644 --- a/frontend/deterministic-tests/src/tests/rapid-updates-after-merge.test.ts +++ b/frontend/deterministic-tests/src/tests/rapid-updates-after-merge.test.ts @@ -1,37 +1,11 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyFinalState(state: ClientState): void { - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}` - ); - assert( - state.files.has("doc.md"), - `Expected doc.md to exist` - ); - const content = state.files.get("doc.md") ?? ""; - - // After the merge and three rapid updates, "update 3" should be present. - // Earlier updates may be coalesced, but the final state must include the - // last update's content. - assert( - content.includes("update 3"), - `Expected final content to include "update 3", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const rapidUpdatesAfterMergeTest: TestDefinition = { - name: "Rapid Sequential Updates After Concurrent Merge", description: - "Both clients create the same file (triggering a merge). After merge " + - "completes, Client 0 rapidly sends three updates in succession. Each " + - "update must correctly use the content cache to compute diffs against " + - "the right parent version. Tests that the cache stores server content " + - "(not local content) after MergingUpdate.", + "Both clients create the same file offline, triggering a merge on sync. " + + "Client 0 then rapidly sends three updates. Both clients must converge to the final update.", clients: 2, steps: [ - // Both create at same path (triggers merge) { type: "create", client: 0, path: "doc.md", content: "from client 0" }, { type: "create", client: 1, path: "doc.md", content: "from client 1" }, @@ -40,7 +14,6 @@ export const rapidUpdatesAfterMergeTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // After merge, Client 0 sends rapid sequential updates { type: "update", client: 0, @@ -65,10 +38,11 @@ export const rapidUpdatesAfterMergeTest: TestDefinition = { }, { type: "sync", client: 0 }, - // Wait for propagation { type: "barrier" }, - // Both clients must converge with update 3 - { type: "assert-consistent", verify: verifyFinalState } + { + type: "assert-consistent", + verify: (s) => s.assertFileCount(1).assertContains("doc.md", "update 3"), + } ] }; diff --git a/frontend/deterministic-tests/src/tests/recently-deleted-cleared-on-reconnect.test.ts b/frontend/deterministic-tests/src/tests/recently-deleted-cleared-on-reconnect.test.ts index 245db72e..d8d0cf21 100644 --- a/frontend/deterministic-tests/src/tests/recently-deleted-cleared-on-reconnect.test.ts +++ b/frontend/deterministic-tests/src/tests/recently-deleted-cleared-on-reconnect.test.ts @@ -1,65 +1,38 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG FIX: recentlyDeletedIds must be cleared on reconnect. - * - * Scenario: - * 1. Client 0 creates and syncs doc.md - * 2. Client 0 deletes doc.md (adds to recentlyDeletedIds) - * 3. Client 0 goes offline - * 4. Client 1 creates a NEW doc.md (different documentId) - * 5. Client 0 comes online - * 6. Client 0 should receive the new doc.md from client 1 - * (recentlyDeletedIds should have been cleared on reconnect so - * the new documentId is not blocked) - */ -function verifyFileExists(state: ClientState): void { - assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); - assert(state.files.has("doc.md"), "Expected doc.md to exist"); - const content = state.files.get("doc.md") ?? ""; - assert( - content === "new content from client 1", - `Expected "new content from client 1", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const recentlyDeletedClearedOnReconnectTest: TestDefinition = { - name: "Recently Deleted IDs Cleared On Reconnect", description: "After a client deletes a document and reconnects, it should " + "accept new documents from other clients even if they happen to " + "arrive at the same path as the deleted document.", clients: 2, steps: [ - // Setup: both online { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Client 0 creates and syncs a file { type: "create", client: 0, path: "doc.md", content: "original" }, { type: "sync" }, { type: "barrier" }, - // Client 0 deletes the file { type: "delete", client: 0, path: "doc.md" }, { type: "sync" }, { type: "barrier" }, - // Client 0 goes offline { type: "disable-sync", client: 0 }, - // Client 1 creates a new file at the same path { type: "create", client: 1, path: "doc.md", content: "new content from client 1" }, { type: "sync", client: 1 }, - // Client 0 comes back online - should receive the new file { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyFileExists }, + { + type: "assert-consistent", + verify: (s) => + s.assertFileCount(1).assertContent("doc.md", "new content from client 1"), + }, ], }; diff --git a/frontend/deterministic-tests/src/tests/rename-chain-then-delete.test.ts b/frontend/deterministic-tests/src/tests/rename-chain-then-delete.test.ts index 3d89e693..27787e4f 100644 --- a/frontend/deterministic-tests/src/tests/rename-chain-then-delete.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-chain-then-delete.test.ts @@ -1,40 +1,23 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyAllDeleted(state: ClientState): void { - const files = Array.from(state.files.keys()); - assert( - state.files.size === 0, - `Expected no files (document was deleted after rename chain), got ${state.files.size}: ${files.join(", ")}` - ); -} +import type { TestDefinition } from "../test-definition"; export const renameChainThenDeleteTest: TestDefinition = { - name: "Rename Chain Then Delete (Offline Catchup)", description: - "Client 0 creates X.md and syncs. Client 1 goes offline. Client 0 " + - "renames X.md -> Y.md -> Z.md, then deletes Z.md. Client 1 reconnects " + - "with X.md still on disk. The offline reconciliation must detect that " + - "the document was deleted (despite the rename chain) and remove X.md.", + "Client 0 renames X.md to Y.md to Z.md, then deletes Z.md while client 1 is offline. " + + "After client 1 reconnects, both clients must have no files.", clients: 2, steps: [ - // Setup: create and sync { type: "create", client: 0, path: "X.md", content: "chain-content" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, { - type: "assert-content", - client: 1, - path: "X.md", - content: "chain-content" + type: "assert-consistent", + verify: (s) => s.assertContent("X.md", "chain-content"), }, - // Client 1 goes offline { type: "disable-sync", client: 1 }, - // Client 0: rename chain X -> Y -> Z, then delete Z { type: "rename", client: 0, @@ -52,12 +35,10 @@ export const renameChainThenDeleteTest: TestDefinition = { { type: "delete", client: 0, path: "Z.md" }, { type: "sync", client: 0 }, - // Client 1 reconnects — should detect X.md's document is deleted { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Both clients must agree: no files - { type: "assert-consistent", verify: verifyAllDeleted } + { type: "assert-consistent", verify: (s) => s.assertFileCount(0) } ] }; diff --git a/frontend/deterministic-tests/src/tests/rename-chain.test.ts b/frontend/deterministic-tests/src/tests/rename-chain.test.ts index 75b33535..8cc3bde3 100644 --- a/frontend/deterministic-tests/src/tests/rename-chain.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-chain.test.ts @@ -1,7 +1,6 @@ import type { TestDefinition } from "../test-definition"; export const renameChainTest: TestDefinition = { - name: "Rename Chain", description: "Client 0 (offline) creates A.md, renames to B.md, then renames to C.md. " + "When sync is enabled, only C.md should exist. Client 1 should receive C.md " + @@ -10,27 +9,20 @@ export const renameChainTest: TestDefinition = { steps: [ { type: "enable-sync", client: 1 }, - // Client 0 creates and renames while offline { type: "create", client: 0, path: "A.md", content: "important content" }, { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, { type: "rename", client: 0, oldPath: "B.md", newPath: "C.md" }, - // Enable sync — reconciliation discovers C.md as a new file { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - // Only C.md should exist on both clients - { type: "assert-not-exists", client: 0, path: "A.md" }, - { type: "assert-not-exists", client: 0, path: "B.md" }, - { type: "assert-exists", client: 0, path: "C.md" }, - { type: "assert-content", client: 0, path: "C.md", content: "important content" }, - - { type: "assert-not-exists", client: 1, path: "A.md" }, - { type: "assert-not-exists", client: 1, path: "B.md" }, - { type: "assert-exists", client: 1, path: "C.md" }, - { type: "assert-content", client: 1, path: "C.md", content: "important content" }, - - { type: "assert-consistent" } + { + type: "assert-consistent", + verify: (s) => + s.assertFileNotExists("A.md") + .assertFileNotExists("B.md") + .assertContent("C.md", "important content"), + } ] }; diff --git a/frontend/deterministic-tests/src/tests/rename-circular.test.ts b/frontend/deterministic-tests/src/tests/rename-circular.test.ts index 6b1c9069..233b5c86 100644 --- a/frontend/deterministic-tests/src/tests/rename-circular.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-circular.test.ts @@ -1,60 +1,10 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyCircularRotation(state: ClientState): void { - // Temp file must not survive the rotation - assert( - !state.files.has("temp-a.md"), - `temp-a.md should not exist after rotation, got: ${Array.from(state.files.keys()).join(", ")}` - ); - - // Exactly 3 files should exist - assert( - state.files.size === 3, - `Expected exactly 3 files after rotation, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - - assert( - state.files.has("A.md"), - `Expected A.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.has("B.md"), - `Expected B.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.has("C.md"), - `Expected C.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - - // After circular rename A->B, B->C, C->A: - // A.md should have C's original content - // B.md should have A's original content - // C.md should have B's original content - assert( - state.files.get("A.md") === "content-c", - `Expected A.md to have "content-c" after rotation, got: "${state.files.get("A.md")}"` - ); - assert( - state.files.get("B.md") === "content-a", - `Expected B.md to have "content-a" after rotation, got: "${state.files.get("B.md")}"` - ); - assert( - state.files.get("C.md") === "content-b", - `Expected C.md to have "content-b" after rotation, got: "${state.files.get("C.md")}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const renameCircularTest: TestDefinition = { - name: "Circular Rename Chain (3-Way Swap)", description: - "Client 0 has A.md, B.md, C.md synced. Goes offline and performs a " + - "circular rename: A->B, B->C, C->A. This requires temp files to avoid " + - "overwriting. When Client 0 reconnects, all three files should have " + - "rotated content on both clients.", + "Client 0 creates three files, syncs, then goes offline and performs a circular rename via a temp file (A->temp, C->A, B->C, temp->B). After reconnecting, both clients should have rotated content with no temp file remaining.", clients: 2, steps: [ - // Setup: create three files and sync to both clients { type: "create", client: 0, path: "A.md", content: "content-a" }, { type: "create", client: 0, path: "B.md", content: "content-b" }, { type: "create", client: 0, path: "C.md", content: "content-c" }, @@ -62,32 +12,32 @@ export const renameCircularTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-content", client: 1, path: "A.md", content: "content-a" }, - { type: "assert-content", client: 1, path: "B.md", content: "content-b" }, - { type: "assert-content", client: 1, path: "C.md", content: "content-c" }, + { + type: "assert-consistent", + verify: (s) => + s.assertContent("A.md", "content-a") + .assertContent("B.md", "content-b") + .assertContent("C.md", "content-c"), + }, - // Client 0 goes offline and performs the 3-way circular rename - // To avoid overwriting, we use temp files: - // 1. A.md -> temp-a.md (save A's content) - // 2. C.md -> A.md (A now has C's content) - // 3. B.md -> C.md (C now has B's content) - // 4. temp-a.md -> B.md (B now has A's content) { type: "disable-sync", client: 0 }, { type: "rename", client: 0, oldPath: "A.md", newPath: "temp-a.md" }, { type: "rename", client: 0, oldPath: "C.md", newPath: "A.md" }, { type: "rename", client: 0, oldPath: "B.md", newPath: "C.md" }, { type: "rename", client: 0, oldPath: "temp-a.md", newPath: "B.md" }, - // Client 0 reconnects { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - // Temp file should not exist on either client - { type: "assert-not-exists", client: 0, path: "temp-a.md" }, - { type: "assert-not-exists", client: 1, path: "temp-a.md" }, - - // All three files should exist with rotated content - { type: "assert-consistent", verify: verifyCircularRotation } + { + type: "assert-consistent", + verify: (s) => + s.assertFileNotExists("temp-a.md") + .assertFileCount(3) + .assertContent("A.md", "content-c") + .assertContent("B.md", "content-a") + .assertContent("C.md", "content-b"), + } ] }; 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 2b1938a0..c29b1dc5 100644 --- a/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts @@ -1,32 +1,8 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyConflictResolution(state: ClientState): void { - const files = Array.from(state.files.keys()); - - // B.md should exist (client 1 renamed A.md to B.md, and client 0 - // created B.md with same content — the server merges them) - assert( - state.files.has("B.md"), - `Expected B.md to exist, got: ${files.join(", ")}` - ); - assert( - state.files.get("B.md") === "hi", - `Expected B.md to have "hi", got: "${state.files.get("B.md")}"` - ); - - // A.md should not exist (it was renamed to B.md) - assert( - !state.files.has("A.md"), - `A.md should not exist after rename, got: ${files.join(", ")}` - ); -} +import type { TestDefinition } from "../test-definition"; export const renameCreateConflictTest: TestDefinition = { - name: "Rename-Create Conflict", description: - "Client 0 creates file A, Client 1 renames A to B, then Client 0 (without syncing) creates B. " + - "The system must resolve the conflict deterministically.", + "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.", clients: 2, steps: [ { type: "enable-sync", client: 0 }, @@ -34,8 +10,10 @@ export const renameCreateConflictTest: TestDefinition = { { type: "create", client: 0, path: "A.md", content: "hi" }, { type: "sync", client: 0 }, { type: "sync", client: 1 }, - { type: "assert-exists", client: 1, path: "A.md" }, - { type: "assert-content", client: 1, path: "A.md", content: "hi" }, + { + type: "assert-consistent", + verify: (s) => s.assertContent("A.md", "hi"), + }, { type: "disable-sync", client: 0 }, { type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" }, { type: "sync", client: 1 }, @@ -43,6 +21,10 @@ export const renameCreateConflictTest: TestDefinition = { { type: "enable-sync", client: 0 }, { type: "sync", client: 0 }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyConflictResolution } + { + type: "assert-consistent", + verify: (s) => + s.assertFileNotExists("A.md").assertContent("B.md", "hi"), + } ] }; diff --git a/frontend/deterministic-tests/src/tests/rename-pending-create-before-response.test.ts b/frontend/deterministic-tests/src/tests/rename-pending-create-before-response.test.ts index 9d9b9b1d..d38a0392 100644 --- a/frontend/deterministic-tests/src/tests/rename-pending-create-before-response.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-pending-create-before-response.test.ts @@ -1,56 +1,17 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG: Renaming a file while its create request is in-flight orphans the document. - * - * Scenario: - * 1. Client 0 creates `doc.md` (pending create, HTTP request in-flight) - * 2. Server is paused so the create stalls - * 3. Client 0 renames `doc.md` → `renamed.md` before the response - * 4. VFS.move() updates the pending document's path to `renamed.md` - * 5. Server resumes, create response confirms document at `doc.md` - * 6. The sync executor may fail to reconcile because the VFS no longer - * has a document at `doc.md` — it was moved to `renamed.md` - * - * Expected: the file should end up at `renamed.md` on both clients. - * The server document at `doc.md` should be renamed to `renamed.md` - * via a follow-up sync operation. - */ -function verifyFileAtRenamedPath(state: ClientState): void { - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.has("renamed.md"), - `Expected renamed.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - const content = state.files.get("renamed.md") ?? ""; - assert( - content === "original-content", - `Expected "original-content", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const renamePendingCreateBeforeResponseTest: TestDefinition = { - name: "Rename Pending Create Before Server Response", description: - "When a file is renamed while its create request is in-flight, " + - "the document must not become orphaned. Both clients should " + - "converge with the file at the renamed path.", + "Client 0 creates a file while the server is paused, then renames it before the create completes. After the server resumes, both clients should converge with the file at the renamed path.", clients: 2, steps: [ - // Both clients online { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Pause server so the create stalls { type: "pause-server" }, - // Client 0 creates doc.md (request stalls at server) { type: "create", client: 0, @@ -58,9 +19,6 @@ export const renamePendingCreateBeforeResponseTest: TestDefinition = { content: "original-content" }, - // Wait for the create to enter the executor - - // Client 0 renames the file WHILE create is in-flight { type: "rename", client: 0, @@ -68,15 +26,16 @@ export const renamePendingCreateBeforeResponseTest: TestDefinition = { newPath: "renamed.md" }, - // Resume server — create response arrives for "doc.md" { type: "resume-server" }, - // Give time for create response + follow-up rename sync { type: "sync" }, { type: "sync" }, { type: "barrier" }, - // File should be at renamed.md on both clients - { type: "assert-consistent", verify: verifyFileAtRenamedPath } + { + type: "assert-consistent", + verify: (s) => + s.assertFileCount(1).assertContent("renamed.md", "original-content"), + } ] }; diff --git a/frontend/deterministic-tests/src/tests/rename-roundtrip.test.ts b/frontend/deterministic-tests/src/tests/rename-roundtrip.test.ts index 468d2d29..bdf043f4 100644 --- a/frontend/deterministic-tests/src/tests/rename-roundtrip.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-roundtrip.test.ts @@ -1,61 +1,38 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyRoundtrip(state: ClientState): void { - const files = Array.from(state.files.keys()); - assert( - files.includes("A.md"), - `Expected A.md to exist after round-trip rename, got: ${files.join(", ")}` - ); - assert( - !files.includes("B.md"), - `B.md should not exist after round-trip rename, got: ${files.join(", ")}` - ); - assert( - state.files.get("A.md") === "original", - `Expected A.md to have "original" content, got: "${state.files.get("A.md")}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const renameRoundtripTest: TestDefinition = { - name: "Rename Round-Trip (A->B->A)", description: - "Client 0 creates A.md and syncs. Then renames A.md to B.md and syncs. " + - "Then renames B.md back to A.md and syncs. Both clients should end with " + - "A.md at the original path with the original content. B.md should not exist. " + - "Tests that the system correctly handles a rename that returns to the " + - "original path, especially regarding document identity tracking.", + "Client 0 creates A.md, renames it to B.md, then renames it back to A.md. After each step both clients sync. Both should end with only A.md at the original path.", clients: 2, steps: [ - // Setup: create and sync { type: "create", client: 0, path: "A.md", content: "original" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-content", client: 1, path: "A.md", content: "original" }, + { + type: "assert-consistent", + verify: (s) => s.assertContent("A.md", "original"), + }, - // First rename: A.md -> B.md { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, { type: "sync" }, { type: "barrier" }, - // Verify intermediate state: only B.md exists - { type: "assert-not-exists", client: 0, path: "A.md" }, - { type: "assert-not-exists", client: 1, path: "A.md" }, - { type: "assert-exists", client: 0, path: "B.md" }, - { type: "assert-exists", client: 1, path: "B.md" }, - { type: "assert-content", client: 0, path: "B.md", content: "original" }, - { type: "assert-content", client: 1, path: "B.md", content: "original" }, + { + type: "assert-consistent", + verify: (s) => + s.assertFileNotExists("A.md").assertContent("B.md", "original"), + }, - // Second rename: B.md -> A.md (back to original path) { type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" }, { type: "sync" }, { type: "barrier" }, - // Final state: back to A.md with original content - { type: "assert-not-exists", client: 0, path: "B.md" }, - { type: "assert-not-exists", client: 1, path: "B.md" }, - { type: "assert-consistent", verify: verifyRoundtrip } + { + type: "assert-consistent", + verify: (s) => + s.assertFileNotExists("B.md").assertContent("A.md", "original"), + } ] }; diff --git a/frontend/deterministic-tests/src/tests/rename-swap.test.ts b/frontend/deterministic-tests/src/tests/rename-swap.test.ts index feb635a5..1cd9c93c 100644 --- a/frontend/deterministic-tests/src/tests/rename-swap.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-swap.test.ts @@ -1,28 +1,6 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifySwap(state: ClientState): void { - assert( - state.files.has("A.md"), - `Expected A.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.has("B.md"), - `Expected B.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - // After the swap, A.md should have B's original content and vice versa - assert( - state.files.get("A.md") === "content-b", - `Expected A.md to have "content-b" after swap, got: "${state.files.get("A.md")}"` - ); - assert( - state.files.get("B.md") === "content-a", - `Expected B.md to have "content-a" after swap, got: "${state.files.get("B.md")}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const renameSwapTest: TestDefinition = { - name: "Offline Swap via Temp File", description: "Client 0 has A.md and B.md synced. Goes offline and swaps them using " + "a temp file: A.md -> temp.md, B.md -> A.md, temp.md -> B.md. " + @@ -30,32 +8,34 @@ export const renameSwapTest: TestDefinition = { "The temp file should not exist on either client.", clients: 2, steps: [ - // Setup: create both files and sync to both clients { type: "create", client: 0, path: "A.md", content: "content-a" }, { type: "create", client: 0, path: "B.md", content: "content-b" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-content", client: 1, path: "A.md", content: "content-a" }, - { type: "assert-content", client: 1, path: "B.md", content: "content-b" }, + { + type: "assert-consistent", + verify: (s) => + s.assertContent("A.md", "content-a").assertContent("B.md", "content-b"), + }, - // Client 0 goes offline and performs the swap { type: "disable-sync", client: 0 }, { type: "rename", client: 0, oldPath: "A.md", newPath: "temp.md" }, { type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" }, { type: "rename", client: 0, oldPath: "temp.md", newPath: "B.md" }, - // Client 0 reconnects { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - // temp.md should not exist on either client - { type: "assert-not-exists", client: 0, path: "temp.md" }, - { type: "assert-not-exists", client: 1, path: "temp.md" }, - - // Both clients should have the swapped content - { type: "assert-consistent", verify: verifySwap } + { + type: "assert-consistent", + verify: (s) => + s + .assertFileNotExists("temp.md") + .assertContent("A.md", "content-b") + .assertContent("B.md", "content-a"), + } ] }; diff --git a/frontend/deterministic-tests/src/tests/rename-to-existing-path.test.ts b/frontend/deterministic-tests/src/tests/rename-to-existing-path.test.ts index 0cdd8718..b1d09c7f 100644 --- a/frontend/deterministic-tests/src/tests/rename-to-existing-path.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-to-existing-path.test.ts @@ -1,32 +1,11 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyFinalState(state: ClientState): void { - // A.md should not exist (it was renamed) - assert(!state.files.has("A.md"), "A.md should not exist after rename"); - // B.md should exist with the alpha content (from the renamed A.md) - assert(state.files.has("B.md"), "B.md should exist"); - assert( - state.files.get("B.md") === "alpha", - `B.md should have "alpha" content, got: "${state.files.get("B.md")}"` - ); - // The original B.md content ("beta") should be overwritten — only the - // renamed content should survive. Verify no other files contain "beta". - const allContent = Array.from(state.files.values()).join("\n"); - assert( - !allContent.includes("beta"), - `Expected "beta" to be gone after overwrite, but found it in: ${JSON.stringify(Object.fromEntries(state.files))}` - ); -} +import type { TestDefinition } from "../test-definition"; export const renameToExistingPathTest: TestDefinition = { - name: "Rename to Existing Path", description: "Client 0 has A.md and B.md. Client 0 renames A.md to B.md (overwriting B.md). " + "Both clients should converge: A.md gone, B.md has A.md's content.", clients: 2, steps: [ - // Setup: create two files and sync { type: "create", client: 0, path: "A.md", content: "alpha" }, { type: "create", client: 0, path: "B.md", content: "beta" }, { type: "enable-sync", client: 0 }, @@ -34,14 +13,14 @@ export const renameToExistingPathTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Client 0 renames A.md to B.md (overwrites B.md) { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, { type: "sync" }, { type: "barrier" }, - // Both should converge - { type: "assert-not-exists", client: 0, path: "A.md" }, - { type: "assert-not-exists", client: 1, path: "A.md" }, - { type: "assert-consistent", verify: verifyFinalState } + { + type: "assert-consistent", + verify: (s) => + s.assertFileNotExists("A.md").assertContent("B.md", "alpha"), + } ] }; diff --git a/frontend/deterministic-tests/src/tests/rename-to-path-of-unconfirmed-delete.test.ts b/frontend/deterministic-tests/src/tests/rename-to-path-of-unconfirmed-delete.test.ts index 4db2faea..543599bb 100644 --- a/frontend/deterministic-tests/src/tests/rename-to-path-of-unconfirmed-delete.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-to-path-of-unconfirmed-delete.test.ts @@ -1,50 +1,10 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * EDGE CASE: Rename to the path of a document whose delete hasn't been - * confirmed on the server yet. - * - * The VFS move() method (vfs.ts line 494-497) silently removes any existing - * document at the target path from the pathIndex. If the target path holds - * a tracked document that is about to be deleted (but the delete hasn't - * been sent to the server yet), the move will remove it from pathIndex, - * potentially causing a deleted-locally document to lose its path reference. - * - * Scenario: - * 1. Both clients have A.md and B.md - * 2. Client 0 goes offline, deletes A.md, renames B.md → A.md - * 3. On reconnect: - * - The delete of A.md is queued - * - The rename of B.md → A.md needs VFS.move(B.md, A.md) - * - But A.md is still in pathIndex (tracked, not yet deleted) - * - VFS.move removes A.md from pathIndex before the delete is confirmed - * - * Expected: A.md's documentId is deleted on server, B.md's document - * is renamed to A.md, both clients converge. - */ -function verifyFinalState(state: ClientState): void { - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - assert(state.files.has("A.md"), "Expected A.md to exist"); - const content = state.files.get("A.md") ?? ""; - assert( - content === "content B", - `Expected "content B", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const renameToPathOfUnconfirmedDeleteTest: TestDefinition = { - name: "Rename to Path of Unconfirmed Delete", description: - "Client deletes A.md and renames B.md to A.md while offline. " + - "On reconnect, the VFS must handle the path conflict between " + - "the tracked A.md (pending delete) and the rename destination.", + "Client 0 deletes A.md and renames B.md to A.md while offline. After reconnecting, A.md should exist with B's content and B.md should be gone.", clients: 2, steps: [ - // Setup: both clients have A.md and B.md { type: "create", client: 0, @@ -62,21 +22,22 @@ export const renameToPathOfUnconfirmedDeleteTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Client 0 goes offline { type: "disable-sync", client: 0 }, - // Delete A.md, then rename B.md → A.md { type: "delete", client: 0, path: "A.md" }, { type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" }, - // Reconnect { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - // Should converge: A.md exists with B's content, B.md gone - { type: "assert-not-exists", client: 0, path: "B.md" }, - { type: "assert-not-exists", client: 1, path: "B.md" }, - { type: "assert-consistent", verify: verifyFinalState } + { + type: "assert-consistent", + verify: (s) => + s + .assertFileCount(1) + .assertFileNotExists("B.md") + .assertContent("A.md", "content B"), + } ] }; 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 e4f95852..a17f52d4 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 @@ -1,80 +1,30 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG: syncLocallyUpdatedFile does not handle pending doc at target path. - * - * In syncer.ts syncLocallyUpdatedFile (lines 146-195), the if/else chain: - * if (existingAtNew === undefined || existingAtNew.state === "deleted-locally") - * else if (existingAtNew.state === "tracked") - * - * There is NO branch for existingAtNew.state === "pending". When a tracked - * doc is renamed to a path occupied by a pending create: - * - * 1. No branch matches → vfsMoveSucceeded stays false - * 2. Falls back to local-update at oldPath - * 3. File is on disk at newPath (user renamed it) - * 4. Executor reads from oldPath → FileNotFoundError - * 5. Operation is silently dropped - * 6. Tracked doc at oldPath becomes orphaned (VFS entry, no file) - * 7. On next reconciliation, recovers via filesystem scan - * - * This test verifies that the rename eventually converges, even though - * the initial sync attempt fails. The pending doc at the target path - * should be handled properly. - */ -function verifyFinalState(state: ClientState): void { - // After convergence, A.md should exist with B's content (B was - // renamed to A, overwriting the pending A). B.md should not exist. - assert( - state.files.has("A.md"), - `Expected A.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - !state.files.has("B.md"), - `Expected B.md to not exist (was renamed to A.md), got: ${Array.from(state.files.keys()).join(", ")}` - ); - const content = state.files.get("A.md") ?? ""; - assert( - content.includes("tracked B content"), - `Expected A.md to have B's content, got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const renameToPendingPathFallbackTest: TestDefinition = { - name: "Rename Tracked File to Path With Pending Create", description: - "When a tracked document is renamed to a path occupied by a " + - "pending create, the VFS move is skipped (no branch for pending " + - "state). The fallback update fails with FileNotFoundError. " + - "Reconciliation should eventually recover.", + "Client 0 creates B.md and syncs. Goes offline, creates A.md, then renames B.md to A.md (overwriting the unsynced A). After reconnecting, B.md should be gone and A.md should have B's content.", clients: 2, steps: [ - // Setup: B.md tracked and synced on both clients { type: "create", client: 0, path: "B.md", content: "tracked B content" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Client 0 goes offline { type: "disable-sync", client: 0 }, - // Client 0 creates A.md (pending, never synced) { type: "create", client: 0, path: "A.md", content: "pending A content" }, - // Client 0 renames B.md → A.md (overwrites the pending A) - // This triggers the missing-branch bug { type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" }, - // Re-enable sync { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - // Verify B.md is gone and A.md exists with B's content - { type: "assert-not-exists", client: 0, path: "B.md" }, - { type: "assert-not-exists", client: 1, path: "B.md" }, - { type: "assert-consistent", verify: verifyFinalState } + { + type: "assert-consistent", + verify: (s) => + s.assertFileNotExists("B.md").assertContains("A.md", "tracked B content"), + } ] }; diff --git a/frontend/deterministic-tests/src/tests/rename-to-recently-deleted-path.test.ts b/frontend/deterministic-tests/src/tests/rename-to-recently-deleted-path.test.ts index 4cb5588c..754c0c18 100644 --- a/frontend/deterministic-tests/src/tests/rename-to-recently-deleted-path.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-to-recently-deleted-path.test.ts @@ -1,41 +1,10 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyConvergence(state: ClientState): void { - const files = Array.from(state.files.keys()).sort(); - - // A.md should not exist (it was renamed away by Client 1) - assert( - !state.files.has("A.md"), - `A.md should not exist after rename. Files: ${files.join(", ")}` - ); - - // B.md should exist — Client 1 renamed A.md to B.md, reclaiming the - // path that Client 0 had just deleted. Content should be "content-a". - assert( - state.files.has("B.md"), - `Expected B.md to exist (renamed from A.md). Files: ${files.join(", ")}` - ); - assert( - state.files.get("B.md") === "content-a", - `Expected B.md to have "content-a", got: "${state.files.get("B.md")}"` - ); - - assert( - state.files.size === 1, - `Expected exactly 1 file, got ${state.files.size}: ${files.join(", ")}` - ); -} +import type { TestDefinition } from "../test-definition"; export const renameToRecentlyDeletedPathTest: TestDefinition = { - name: "Rename to a Path That Was Recently Deleted", description: - "Client 0 deletes B.md and syncs. Client 1 (offline) renames A.md " + - "to B.md — claiming the path that was just vacated. When Client 1 " + - "reconnects, the rename should succeed at B.md without collision.", + "Client 0 deletes B.md. Client 1 renames A.md to B.md offline. After reconnecting, only B.md should exist with A's content.", clients: 2, steps: [ - // Setup: create both files { type: "create", client: 0, path: "A.md", content: "content-a" }, { type: "create", client: 0, path: "B.md", content: "content-b" }, { type: "enable-sync", client: 0 }, @@ -43,14 +12,11 @@ export const renameToRecentlyDeletedPathTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Client 1 goes offline { type: "disable-sync", client: 1 }, - // Client 0 deletes B.md { type: "delete", client: 0, path: "B.md" }, { type: "sync", client: 0 }, - // Client 1 (offline) renames A.md to B.md { type: "rename", client: 1, @@ -58,12 +24,17 @@ export const renameToRecentlyDeletedPathTest: TestDefinition = { newPath: "B.md" }, - // Client 1 reconnects { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Both clients should converge: only B.md with content-a - { type: "assert-consistent", verify: verifyConvergence } + { + type: "assert-consistent", + verify: (s) => + s + .assertFileCount(1) + .assertFileNotExists("A.md") + .assertContent("B.md", "content-a"), + } ] }; diff --git a/frontend/deterministic-tests/src/tests/rename-update-conflict.test.ts b/frontend/deterministic-tests/src/tests/rename-update-conflict.test.ts index 0fcc7735..099009fb 100644 --- a/frontend/deterministic-tests/src/tests/rename-update-conflict.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-update-conflict.test.ts @@ -1,58 +1,35 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyConvergence(state: ClientState): void { - const files = Array.from(state.files.keys()); - // A.md should not exist (it was renamed to B.md by client 0) - assert( - !files.includes("A.md"), - `Expected A.md to not exist after rename, but found files: ${files.join(", ")}` - ); - // B.md should exist (the rename target) - assert( - files.includes("B.md"), - `Expected B.md to exist after rename, but found files: ${files.join(", ")}` - ); - // B.md should contain client 1's update (merged with the rename) - const content = state.files.get("B.md") ?? ""; - assert( - content.includes("updated"), - `Expected B.md to contain "updated" from client 1's edit, got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const renameUpdateConflictTest: TestDefinition = { - name: "Rename vs Update Conflict", description: - "Client 0 renames A.md to B.md while Client 1 (offline) updates A.md. " + - "When Client 1 reconnects, the update should be applied to B.md (the " + - "renamed file) via 3-way merge. Both clients should converge.", + "Client 0 renames A.md to B.md while client 1 updates A.md offline. After client 1 reconnects, both should converge with the update at B.md.", clients: 2, steps: [ - // Setup: create A.md and sync to both clients { type: "create", client: 0, path: "A.md", content: "original" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-content", client: 1, path: "A.md", content: "original" }, + { + type: "assert-consistent", + verify: (s) => s.assertContent("A.md", "original"), + }, - // Client 1 goes offline { type: "disable-sync", client: 1 }, - // Client 0 renames A.md to B.md and syncs { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, { type: "sync", client: 0 }, - // Client 1 (offline) updates A.md { type: "update", client: 1, path: "A.md", content: "updated by client 1" }, - // Client 1 reconnects — must reconcile rename with update { type: "enable-sync", client: 1 }, { type: "sync", client: 1 }, { type: "barrier" }, - // Verify convergence - { type: "assert-consistent", verify: verifyConvergence } + { + type: "assert-consistent", + verify: (s) => + s.assertFileNotExists("A.md").assertContains("B.md", "updated"), + } ] }; 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 a17546ed..e7b001e2 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 @@ -1,43 +1,12 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG: recentlyDeletedIds cleared on sync reset can allow document resurrection. - * - * Found by: multi-client convergence agent (#10) - * - * When the VFS is reset (syncer.ts line 225-229, on WebSocket disconnect), - * the recentlyDeletedIds set is NOT cleared by syncer.reset() (which only - * calls queue.reset()). The VFS.reset() DOES clear it (line 646), but - * syncer.reset() doesn't call vfs.reset(). - * - * However, there's a related edge case: if sync is toggled off and on - * (which calls pause/resume), the recentlyDeletedIds persists correctly. - * But if the client deletes a document and then loses connection, the - * lastSeenUpdateId watermark may not have advanced past the delete. - * On reconnect, the server replays the delete broadcast, and the client - * should handle it correctly. - * - * This test verifies that after Client 0 deletes a file and Client 1 - * toggles sync off and on, the delete is properly applied and no - * resurrection occurs. - */ -function verifyNoFiles(state: ClientState): void { - assert( - state.files.size === 0, - `Expected 0 files, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); -} +import type { TestDefinition } from "../test-definition"; export const resetClearsRecentlyDeletedResurrectionTest: TestDefinition = { - name: "Sync Reset Does Not Resurrect Deleted Documents", 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.", clients: 2, steps: [ - // Setup { type: "create", client: 0, @@ -49,26 +18,25 @@ export const resetClearsRecentlyDeletedResurrectionTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Client 0 deletes the file { type: "delete", client: 0, path: "ghost.md" }, { type: "sync", client: 0 }, - // Wait for broadcast to propagate { type: "sync" }, { type: "barrier" }, - // Client 1 should NOT have the file - { type: "assert-not-exists", client: 1, path: "ghost.md" }, + { + type: "assert-consistent", + verify: (s) => s.assertFileNotExists("ghost.md"), + }, - // Client 1 toggles sync (simulating disconnect/reconnect) { type: "disable-sync", client: 1 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // File should STILL be gone — no resurrection - { type: "assert-not-exists", client: 0, path: "ghost.md" }, - { type: "assert-not-exists", client: 1, path: "ghost.md" }, - { type: "assert-consistent", verify: verifyNoFiles } + { + type: "assert-consistent", + verify: (s) => s.assertFileCount(0), + } ] }; diff --git a/frontend/deterministic-tests/src/tests/sequential-create-duplicate-content.test.ts b/frontend/deterministic-tests/src/tests/sequential-create-duplicate-content.test.ts index 49581c46..968166a9 100644 --- a/frontend/deterministic-tests/src/tests/sequential-create-duplicate-content.test.ts +++ b/frontend/deterministic-tests/src/tests/sequential-create-duplicate-content.test.ts @@ -1,66 +1,32 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyBothFilesPreserved(state: ClientState): void { - assert( - state.files.size === 2, - `Expected 2 files, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.has("A.md"), - `Expected A.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.has("B.md"), - `Expected B.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - - const contentA = state.files.get("A.md") ?? ""; - const contentB = state.files.get("B.md") ?? ""; - assert( - contentA === "identical content here", - `A.md has wrong content: "${contentA}"` - ); - assert( - contentB === "identical content here", - `B.md has wrong content: "${contentB}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const sequentialCreateDuplicateContentTest: TestDefinition = { - name: "Sequential Creates With Identical Content Preserved", description: - "Client 0 creates A.md and syncs it. Then Client 0 creates B.md with " + - "the exact same content as A.md and syncs again. Both files must be " + - "preserved as separate documents — the duplicate content detection " + - "must not collapse them into one file or delete B.md.", + "Client 0 creates A.md, syncs, then creates B.md with identical content. Both files must remain as separate documents on both clients.", clients: 2, steps: [ - // Create A.md and sync it fully { type: "create", client: 0, path: "A.md", content: "identical content here" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Verify A.md arrived on client 1 { - type: "assert-content", - client: 1, - path: "A.md", - content: "identical content here" + type: "assert-consistent", + verify: (s) => s.assertContent("A.md", "identical content here"), }, - // Now create B.md with identical content on client 0 { type: "create", client: 0, path: "B.md", content: "identical content here" }, { type: "sync" }, { type: "barrier" }, - // Both files must exist on both clients with correct content. - // This catches bugs where duplicate detection (content hash matching - // during offline reconciliation) accidentally treats B.md as a - // "move" of A.md, or where the server merges B.md into A.md's - // document because of identical content at a different path. - { type: "assert-consistent", verify: verifyBothFilesPreserved } + { + type: "assert-consistent", + verify: (s) => + s + .assertFileCount(2) + .assertContent("A.md", "identical content here") + .assertContent("B.md", "identical content here"), + } ] }; 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 46c7107e..fea4adad 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 @@ -1,36 +1,8 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyBothFiles(state: ClientState): void { - assert( - state.files.has("alpha.md"), - `Expected alpha.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.has("beta.md"), - `Expected beta.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - const alphaContent = state.files.get("alpha.md") ?? ""; - const betaContent = state.files.get("beta.md") ?? ""; - assert( - alphaContent.includes("from client 0"), - `Expected alpha.md to contain "from client 0", got: "${alphaContent}"` - ); - assert( - betaContent.includes("from client 1"), - `Expected beta.md to contain "from client 1", got: "${betaContent}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const serverPauseBothClientsCreateTest: TestDefinition = { - name: "Server Pause While Both Clients Create", description: - "Both clients are synced. Client 0 creates alpha.md. The server is immediately " + - "paused (SIGSTOP), stalling in-flight requests and WebSocket broadcasts. " + - "While the server is paused, Client 1 creates beta.md (its request will also stall). " + - "After the server resumes, both files should propagate to both clients. " + - "This tests that the retry logic on both clients correctly recovers stalled " + - "HTTP creates and that WebSocket reconnection delivers the missed broadcasts.", + "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.", clients: 2, steps: [ { type: "enable-sync", client: 0 }, @@ -38,8 +10,6 @@ export const serverPauseBothClientsCreateTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Client 0 creates a file, then immediately pause the server - // so the create response (or broadcast to client 1) may be stalled { type: "create", client: 0, @@ -48,8 +18,6 @@ export const serverPauseBothClientsCreateTest: TestDefinition = { }, { type: "pause-server" }, - // While server is paused, client 1 creates a different file. - // This HTTP request will stall until the server is resumed. { type: "create", client: 1, @@ -57,18 +25,17 @@ export const serverPauseBothClientsCreateTest: TestDefinition = { content: "from client 1" }, - // Resume the server — both stalled requests should complete { type: "resume-server" }, - // Let both clients finish all pending sync work { type: "sync" }, { type: "barrier" }, - // Both files must exist on both clients - { type: "assert-exists", client: 0, path: "alpha.md" }, - { type: "assert-exists", client: 0, path: "beta.md" }, - { type: "assert-exists", client: 1, path: "alpha.md" }, - { type: "assert-exists", client: 1, path: "beta.md" }, - { type: "assert-consistent", verify: verifyBothFiles } + { + type: "assert-consistent", + verify: (s) => + s + .assertContains("alpha.md", "from client 0") + .assertContains("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 51a80898..394a531a 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 @@ -1,57 +1,10 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * EDGE CASE: Both clients edit the same file while server is paused. - * - * When the server is paused (SIGSTOP), both clients' HTTP requests stall. - * When the server resumes, both updates arrive nearly simultaneously. - * The server processes them sequentially (SQLite), so one will be a - * FastForwardUpdate and the other will trigger a 3-way merge. - * - * This test verifies: - * 1. Both edits are preserved in the merged result - * 2. Both clients converge to the same content - * 3. The content cache on both clients is correct after the merge - * (subsequent edits use the right diff base) - * - * After the initial merge converges, Client 0 makes another edit to - * verify the content cache is correct — if the cache has wrong content, - * the diff will be computed incorrectly and the update will fail. - */ -function verifyBothConcurrentEdits(state: ClientState): void { - assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); - assert(state.files.has("shared.md"), "Expected shared.md to exist"); - const content = state.files.get("shared.md") ?? ""; - assert( - content.includes("edited by client 0"), - `Expected content to include client 0's edit, got: "${content}"` - ); - assert( - content.includes("edited by client 1"), - `Expected content to include client 1's edit, got: "${content}"` - ); -} - -function verifyPostMergeEdit(state: ClientState): void { - assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); - assert(state.files.has("shared.md"), "Expected shared.md to exist"); - const content = state.files.get("shared.md") ?? ""; - assert( - content.includes("post-merge edit from client 0"), - `Expected content to include post-merge edit, got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const serverPauseBothEditSameFileTest: TestDefinition = { - name: "Server Pause — Both Clients Edit Same File + Post-Merge Edit", description: - "Both clients edit the same file while the server is paused. " + - "After resume and convergence, Client 0 makes another edit to " + - "verify the content cache is consistent (correct diff base).", + "Both clients edit different sections of the same file while the server is paused. After resuming and converging, client 0 makes another edit to verify further updates still work correctly.", clients: 2, steps: [ - // Setup { type: "create", client: 0, @@ -63,10 +16,8 @@ export const serverPauseBothEditSameFileTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Pause server { type: "pause-server" }, - // Both clients edit different sections { type: "update", client: 0, @@ -82,15 +33,18 @@ export const serverPauseBothEditSameFileTest: TestDefinition = { "line 1: original\nline 2: original\nline 3: edited by client 1" }, - // Resume — both updates hit server nearly simultaneously { type: "resume-server" }, { type: "sync" }, { type: "barrier" }, - // Verify both concurrent edits are preserved in the merge - { type: "assert-consistent", verify: verifyBothConcurrentEdits }, + { + type: "assert-consistent", + verify: (s) => + s + .assertFileCount(1) + .assertContains("shared.md", "edited by client 0", "edited by client 1"), + }, - // Now Client 0 makes another edit (verifies content cache is correct) { type: "update", client: 0, @@ -100,6 +54,10 @@ export const serverPauseBothEditSameFileTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyPostMergeEdit } + { + type: "assert-consistent", + verify: (s) => + s.assertFileCount(1).assertContains("shared.md", "post-merge edit from client 0"), + } ] }; diff --git a/frontend/deterministic-tests/src/tests/server-pause-delete-recreate.test.ts b/frontend/deterministic-tests/src/tests/server-pause-delete-recreate.test.ts new file mode 100644 index 00000000..920259e1 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/server-pause-delete-recreate.test.ts @@ -0,0 +1,32 @@ +import type { TestDefinition } from "../test-definition"; + +export const serverPauseDeleteRecreateTest: TestDefinition = { + description: + "Client 1 deletes a file and syncs. The server is paused, then client 0 creates at the same path. After the server resumes, both clients should have the recreated file.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "original" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "delete", client: 1, path: "A.md" }, + { type: "barrier" }, + + { type: "pause-server" }, + + { type: "create", client: 0, path: "A.md", content: "recreated during contention" }, + + { type: "resume-server" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state) => { + state + .assertFileCount(1) + .assertContent("A.md", "recreated during contention"); + } + }, + ], +}; diff --git a/frontend/deterministic-tests/src/tests/server-pause-rename-edit-resume.test.ts b/frontend/deterministic-tests/src/tests/server-pause-rename-edit-resume.test.ts index f3a550c9..c2d6772e 100644 --- a/frontend/deterministic-tests/src/tests/server-pause-rename-edit-resume.test.ts +++ b/frontend/deterministic-tests/src/tests/server-pause-rename-edit-resume.test.ts @@ -1,45 +1,12 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; +import type { TestDefinition } from "../test-definition"; -function verifyRenamedAndEdited(state: ClientState): void { - const files = Array.from(state.files.keys()); - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}: ${files.join(", ")}` - ); - assert( - !state.files.has("A.md"), - `A.md should not exist after rename` - ); - assert( - state.files.has("B.md"), - `Expected B.md to exist, got: ${files.join(", ")}` - ); - const content = state.files.get("B.md") ?? ""; - assert( - content === "edited after rename during pause", - `Expected B.md content to be "edited after rename during pause", got: "${content}"` - ); -} - -/** - * Tests that a rename + edit while the server is paused both propagate - * correctly after resume. The event coalescing should produce a - * move-and-update action. When the server resumes and processes the - * stalled request, both the path change and content change should - * apply atomically. - * - * This exercises the coalescing path: move + update = move-and-update. - */ export const serverPauseRenameEditResumeTest: TestDefinition = { - name: "Server Pause: Rename + Edit Then Resume", description: "Client 0 creates A.md and syncs. Server is paused. Client 0 " + "renames A.md to B.md and edits B.md. Server resumes. Both the " + "rename and edit should propagate to Client 1.", clients: 2, steps: [ - // Setup: create and sync { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { @@ -51,16 +18,12 @@ export const serverPauseRenameEditResumeTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, { - type: "assert-content", - client: 1, - path: "A.md", - content: "original content" + type: "assert-consistent", + verify: (s) => s.assertContent("A.md", "original content"), }, - // Pause server { type: "pause-server" }, - // Rename and edit while server is paused { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, { type: "update", @@ -69,15 +32,18 @@ export const serverPauseRenameEditResumeTest: TestDefinition = { content: "edited after rename during pause" }, - // Resume server { type: "resume-server" }, { type: "sync" }, { type: "barrier" }, - // Both clients should have B.md with edited content - { type: "assert-not-exists", client: 0, path: "A.md" }, - { type: "assert-not-exists", client: 1, path: "A.md" }, - { type: "assert-consistent", verify: verifyRenamedAndEdited } + { + type: "assert-consistent", + verify: (s) => + s + .assertFileCount(1) + .assertFileNotExists("A.md") + .assertContent("B.md", "edited after rename during pause"), + } ] }; diff --git a/frontend/deterministic-tests/src/tests/server-pause-update-and-create.test.ts b/frontend/deterministic-tests/src/tests/server-pause-update-and-create.test.ts index 4cb42b5f..3523cf79 100644 --- a/frontend/deterministic-tests/src/tests/server-pause-update-and-create.test.ts +++ b/frontend/deterministic-tests/src/tests/server-pause-update-and-create.test.ts @@ -1,44 +1,10 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyFinalState(state: ClientState): void { - // The updated file must exist with the new content - assert( - state.files.has("shared.md"), - `Expected shared.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - const sharedContent = state.files.get("shared.md") ?? ""; - assert( - sharedContent === "updated during pause", - `Expected shared.md to be "updated during pause", got: "${sharedContent}"` - ); - - // The new file created by client 1 during the pause must also exist - assert( - state.files.has("new-file.md"), - `Expected new-file.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - const newContent = state.files.get("new-file.md") ?? ""; - assert( - newContent === "created by client 1", - `Expected new-file.md to be "created by client 1", got: "${newContent}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const serverPauseUpdateAndCreateTest: TestDefinition = { - name: "Server Pause — Update and Create Simultaneously", description: - "Client 0 creates shared.md and both clients sync. The server is paused. " + - "Client 0 updates shared.md to new content. Client 1 creates an entirely " + - "new file new-file.md. Both HTTP requests stall. After the server resumes, " + - "the update and the create should both complete. Client 1 should see the " + - "updated content in shared.md, and Client 0 should see new-file.md. " + - "This tests that mixed operation types (update + create) from different " + - "clients both survive a server outage and that the WebSocket reconnection " + - "delivers all missed broadcasts.", + "Client 0 updates a shared file while client 1 creates a new file, both during a server pause. After the server resumes, both operations should complete and propagate to both clients.", clients: 2, steps: [ - // Setup: create shared.md and sync { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { @@ -50,23 +16,18 @@ export const serverPauseUpdateAndCreateTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, { - type: "assert-content", - client: 1, - path: "shared.md", - content: "initial content" + type: "assert-consistent", + verify: (s) => s.assertContent("shared.md", "initial content"), }, - // Pause the server { type: "pause-server" }, - // Client 0 updates the existing file (stalls) { type: "update", client: 0, path: "shared.md", content: "updated during pause" }, - // Client 1 creates a brand-new file (stalls) { type: "create", client: 1, @@ -74,17 +35,17 @@ export const serverPauseUpdateAndCreateTest: TestDefinition = { content: "created by client 1" }, - // Resume server — both operations should complete { type: "resume-server" }, { type: "sync" }, { type: "barrier" }, - // Verify final state - { type: "assert-exists", client: 0, path: "shared.md" }, - { type: "assert-exists", client: 0, path: "new-file.md" }, - { type: "assert-exists", client: 1, path: "shared.md" }, - { type: "assert-exists", client: 1, path: "new-file.md" }, - { type: "assert-consistent", verify: verifyFinalState } + { + type: "assert-consistent", + verify: (s) => + s + .assertContent("shared.md", "updated during pause") + .assertContent("new-file.md", "created by client 1"), + } ] }; 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 dc16aaee..2e74b3a5 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 @@ -1,61 +1,39 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyConflictResolution(state: ClientState): void { - // The delete and offline update conflict on the same document. - // Either outcome is acceptable — the key invariant is convergence - // (checked by assert-consistent). But we verify content correctness - // for whichever outcome the system chose. - if (state.files.has("A.md")) { - // Update won: A.md should have the offline-modified content - assert( - state.files.get("A.md") === "modified by 1 while offline", - `If A.md survived, it should have "modified by 1 while offline", got: "${state.files.get("A.md")}"` - ); - assert( - state.files.size === 1, - `Expected exactly 1 file if update won, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - } else { - // Delete won: no files should exist - assert( - state.files.size === 0, - `Expected 0 files if delete won, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - } -} +import type { TestDefinition } from "../test-definition"; export const simultaneousCreateDeleteSamePathTest: TestDefinition = { - name: "Simultaneous Create and Delete at Same Path", 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.", clients: 2, steps: [ - // Setup: Client 0 creates and syncs A.md { type: "create", client: 0, path: "A.md", content: "original from 0" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Client 1 goes offline { type: "disable-sync", client: 1 }, - // Client 0 deletes A.md { type: "delete", client: 0, path: "A.md" }, { type: "sync", client: 0 }, - // Client 1 updates A.md while offline (it still has it) { type: "update", client: 1, path: "A.md", content: "modified by 1 while offline" }, - // Client 1 reconnects { type: "enable-sync", client: 1 }, { type: "sync", client: 1 }, { type: "barrier" }, - // Both must agree — key invariant is convergence - { type: "assert-consistent", verify: verifyConflictResolution } + { + type: "assert-consistent", + verify: (s) => { + s.ifFileExists("A.md", (s) => + s.assertFileCount(1).assertContent("A.md", "modified by 1 while offline") + ); + if (!s.files.has("A.md")) { + s.assertFileCount(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 d213d965..d434dde3 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 @@ -1,52 +1,12 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * COMPLEX EDGE CASE: Three clients perform conflicting operations simultaneously. - * - * Client A renames X→Y, Client B deletes X, Client C creates Y. - * This exercises multiple conflict resolution paths at once: - * - * - Client A's rename needs the old path X (which Client B is deleting) - * - Client C's create at Y conflicts with Client A's rename destination - * - The server must handle all three operations arriving in arbitrary order - * - * Expected behavior: - * - The rename from A should succeed (it was initiated before B's delete) - * - B's delete of X is effectively a no-op since A already moved it away - * - C's create at Y triggers a smart merge with A's renamed document - * - Final state: Y exists with merged content from A and C - */ -function verifyFinalState(state: ClientState): void { - // X should not exist (renamed/deleted) - assert( - !state.files.has("X.md"), - `X.md should not exist, files: ${Array.from(state.files.keys()).join(", ")}` - ); - - // Y should exist with content from both A's original and C's create - assert( - state.files.has("Y.md"), - `Y.md should exist, files: ${Array.from(state.files.keys()).join(", ")}` - ); - const content = state.files.get("Y.md") ?? ""; - // Both contents should be merged (A's rename + C's create at same path) - assert( - content.includes("original from A") && - content.includes("new from C"), - `Y.md should contain merged content from both A and C, got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const threeClientRenameCreateDeleteTest: TestDefinition = { - name: "Three Clients: Rename + Delete + Create Conflict", description: "Client 0 renames X→Y, Client 1 deletes X, Client 2 creates Y. " + "All three operations happen while the other clients are offline. " + "Tests that the system handles the three-way conflict and converges.", clients: 3, steps: [ - // Setup: Client 0 creates X.md, all sync { type: "create", client: 0, @@ -59,18 +19,14 @@ export const threeClientRenameCreateDeleteTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // All clients go offline { type: "disable-sync", client: 0 }, { type: "disable-sync", client: 1 }, { type: "disable-sync", client: 2 }, - // Client 0: rename X→Y { type: "rename", client: 0, oldPath: "X.md", newPath: "Y.md" }, - // Client 1: delete X { type: "delete", client: 1, path: "X.md" }, - // Client 2: create Y with different content { type: "create", client: 2, @@ -78,7 +34,6 @@ export const threeClientRenameCreateDeleteTest: TestDefinition = { content: "new from C" }, - // Bring all clients back online, one at a time { type: "enable-sync", client: 0 }, { type: "sync", client: 0 }, @@ -89,7 +44,12 @@ export const threeClientRenameCreateDeleteTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // All clients should converge - { type: "assert-consistent", verify: verifyFinalState } + { + type: "assert-consistent", + verify: (s) => + s + .assertFileNotExists("X.md") + .assertContains("Y.md", "original from A", "new from C"), + } ] }; diff --git a/frontend/deterministic-tests/src/tests/update-during-create-processing.test.ts b/frontend/deterministic-tests/src/tests/update-during-create-processing.test.ts index 774bd23e..43536bed 100644 --- a/frontend/deterministic-tests/src/tests/update-during-create-processing.test.ts +++ b/frontend/deterministic-tests/src/tests/update-during-create-processing.test.ts @@ -1,46 +1,8 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG: Events for a currently-processing document may be lost. - * - * Found by: sync-event-queue.ts analysis (agent #3) - * - * In sync-event-queue.ts, when processNext() starts executing an action - * for a document key, it removes the key from documentStates (line 259) - * and sets currentlyProcessing to the key (line 258). - * - * If a new event arrives for the SAME key while the executor is running: - * 1. enqueue() coalesces into documentStates (line 50 or 47) - * 2. Tries to add to processingOrder (line 71-76) - * 3. The guard checks: currentlyProcessing !== key → FALSE - * 4. So the key is NOT added to processingOrder - * 5. When the executor finishes, processNext() picks the NEXT key - * 6. The new event sits in documentStates but is never processed - * - * The system recovers via runFinalConsistencyCheck() which does a fresh - * filesystem scan, but the immediate update is lost until then. - * - * This test creates a file, then updates it while the create is being - * processed (using server pause to control timing). The update should - * be reflected on both clients. - */ -function verifyUpdatedContent(state: ClientState): void { - assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); - assert(state.files.has("file.md"), "Expected file.md to exist"); - const content = state.files.get("file.md") ?? ""; - assert( - content === "updated during create", - `Expected "updated during create", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const updateDuringCreateProcessingTest: TestDefinition = { - name: "Update During Create Processing — Event Not Lost", description: - "Client creates a file, then updates it while the create HTTP request " + - "is in-flight (server paused). The update should eventually propagate " + - "to the other client, not be silently lost in the queue.", + "Client 0 creates a file while the server is paused, then immediately updates it. After the server resumes, both clients should converge with the updated content.", clients: 2, steps: [ { type: "enable-sync", client: 0 }, @@ -48,10 +10,8 @@ export const updateDuringCreateProcessingTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Pause server so create stalls mid-processing { type: "pause-server" }, - // Create file (request stalls) { type: "create", client: 0, @@ -59,9 +19,6 @@ export const updateDuringCreateProcessingTest: TestDefinition = { content: "initial" }, - // Wait a bit for the create to enter the executor - - // Update while create is in-flight { type: "update", client: 0, @@ -69,12 +26,14 @@ export const updateDuringCreateProcessingTest: TestDefinition = { content: "updated during create" }, - // Resume server — create completes { type: "resume-server" }, { type: "sync" }, { type: "barrier" }, - // Updated content should be on both clients - { type: "assert-consistent", verify: verifyUpdatedContent } + { + type: "assert-consistent", + verify: (s) => + s.assertFileCount(1).assertContent("file.md", "updated during create"), + } ] }; diff --git a/frontend/deterministic-tests/src/tests/update-during-server-pause.test.ts b/frontend/deterministic-tests/src/tests/update-during-server-pause.test.ts deleted file mode 100644 index 91769f0d..00000000 --- a/frontend/deterministic-tests/src/tests/update-during-server-pause.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { TestDefinition } from "../test-definition"; - -export const updateDuringServerPauseTest: TestDefinition = { - name: "Update During Server Pause", - description: - "Client 0 creates a file and syncs. Server is paused. Client 0 updates " + - "the file (request stalls). Server resumes. The update should eventually " + - "propagate to Client 1.", - clients: 2, - steps: [ - // Setup: create and sync - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "create", client: 0, path: "doc.md", content: "v1" }, - { type: "sync" }, - { type: "barrier" }, - { type: "assert-content", client: 1, path: "doc.md", content: "v1" }, - - // Pause server, update file - { type: "pause-server" }, - { type: "update", client: 0, path: "doc.md", content: "v2 during pause" }, - - // Resume server - { type: "resume-server" }, - { type: "sync" }, - { type: "barrier" }, - - // Both should have updated content - { - type: "assert-content", - client: 0, - path: "doc.md", - content: "v2 during pause" - }, - { - type: "assert-content", - client: 1, - path: "doc.md", - content: "v2 during pause" - }, - { type: "assert-consistent" } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/update-survives-remote-delete.test.ts b/frontend/deterministic-tests/src/tests/update-survives-remote-delete.test.ts index 4a46343e..5bc713ba 100644 --- a/frontend/deterministic-tests/src/tests/update-survives-remote-delete.test.ts +++ b/frontend/deterministic-tests/src/tests/update-survives-remote-delete.test.ts @@ -1,60 +1,33 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG FIX: Local edit must survive a concurrent remote delete. - * - * Scenario: - * 1. Both clients have doc.md = "original" - * 2. Client 0 deletes doc.md - * 3. Client 1 edits doc.md to "edited by client 1" - * 4. Client 0 syncs first (delete reaches server) - * 5. Client 1 syncs — sees remote delete, but local edit takes precedence - * 6. Client 1 creates a NEW document at doc.md with the edited content - */ -function verifyEditSurvived(state: ClientState): void { - assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); - assert(state.files.has("doc.md"), "Expected doc.md to exist"); - const content = state.files.get("doc.md") ?? ""; - assert( - content.includes("edited by client 1"), - `Expected content to include "edited by client 1", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const updateSurvivesRemoteDeleteTest: TestDefinition = { - name: "Local Edit Survives Remote Delete", description: - "When a user edits a file and another client deletes it concurrently, " + - "the local edit should take precedence and the file should survive.", + "Client 0 deletes a file while client 1 edits it offline. Client 0 syncs the delete first, then client 1 reconnects. The edited file should survive on both clients.", clients: 2, steps: [ - // Setup { type: "create", client: 0, path: "doc.md", content: "original" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Both go offline { type: "disable-sync", client: 0 }, { type: "disable-sync", client: 1 }, - // Client 0 deletes, client 1 edits { type: "delete", client: 0, path: "doc.md" }, { type: "update", client: 1, path: "doc.md", content: "edited by client 1" }, - // Client 0 goes online first — delete reaches server before - // Client 1 reconnects. This ensures Client 1's update sees - // the remote delete and falls back to creating a new document. { type: "enable-sync", client: 0 }, { type: "sync", client: 0 }, - // Client 1 goes online — remote delete coalesces with local edit { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyEditSurvived }, + { + type: "assert-consistent", + verify: (s) => + s.assertFileCount(1).assertContains("doc.md", "edited by client 1"), + }, ], }; diff --git a/frontend/deterministic-tests/src/tests/update-then-rename.test.ts b/frontend/deterministic-tests/src/tests/update-then-rename.test.ts deleted file mode 100644 index 4588f72e..00000000 --- a/frontend/deterministic-tests/src/tests/update-then-rename.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { TestDefinition } from "../test-definition"; - -export const updateThenRenameTest: TestDefinition = { - name: "Update Then Rename While Online", - description: - "Client 0 updates A.md then immediately renames it to B.md while online. " + - "Both the content change and rename should propagate to Client 1.", - clients: 2, - steps: [ - // Setup - { type: "create", client: 0, path: "A.md", content: "v1" }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - { type: "assert-content", client: 1, path: "A.md", content: "v1" }, - - // Update then rename (both while online) - { type: "update", client: 0, path: "A.md", content: "v2-updated" }, - { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, - { type: "sync" }, - { type: "barrier" }, - - // A.md gone, B.md has updated content - { type: "assert-not-exists", client: 0, path: "A.md" }, - { type: "assert-not-exists", client: 1, path: "A.md" }, - { type: "assert-exists", client: 0, path: "B.md" }, - { type: "assert-exists", client: 1, path: "B.md" }, - { type: "assert-content", client: 0, path: "B.md", content: "v2-updated" }, - { type: "assert-content", client: 1, path: "B.md", content: "v2-updated" }, - { type: "assert-consistent" } - ] -}; 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 4ab1a1f9..202bd437 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 @@ -1,30 +1,8 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG FIX: Watermark must advance even when remote updates are skipped. - * - * When a remote update is skipped (e.g., because the document already - * exists locally, or a pending create covers it), the vaultUpdateId - * must still be recorded via addSeenUpdateId. Otherwise, the watermark - * stalls and every subsequent reconnect replays stale updates. - * - * This test creates a scenario where one client has a pending create - * at the same path as a remote create. The skipped remote create's - * vaultUpdateId must be recorded. After a reconnect cycle, the - * watermark should be past the skipped update. - */ -function verifyConverged(state: ClientState): void { - assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); - assert(state.files.has("doc.md"), "Expected doc.md to exist"); -} +import type { TestDefinition } from "../test-definition"; export const watermarkAdvancesOnSkipTest: TestDefinition = { - name: "Watermark Advances When Remote Update Is Skipped", description: - "When a remote update is skipped (already exists, pending create, " + - "etc.), the vaultUpdateId must still be recorded to prevent " + - "watermark stalls and unnecessary replays on reconnect.", + "Both clients create the same file offline. After syncing, both disconnect and reconnect. The reconnect should not replay already-processed updates.", clients: 2, steps: [ { type: "enable-sync", client: 0 }, @@ -32,19 +10,16 @@ export const watermarkAdvancesOnSkipTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Both go offline and create at the same path { type: "disable-sync", client: 0 }, { type: "disable-sync", client: 1 }, { type: "create", client: 0, path: "doc.md", content: "from client 0" }, { type: "create", client: 1, path: "doc.md", content: "from client 1" }, - // Both come online - one will skip the other's remote create { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Disconnect and reconnect to test watermark { type: "disable-sync", client: 0 }, { type: "disable-sync", client: 1 }, { type: "enable-sync", client: 0 }, @@ -52,6 +27,9 @@ export const watermarkAdvancesOnSkipTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyConverged }, + { + type: "assert-consistent", + verify: (s) => s.assertFileCount(1).assertFileExists("doc.md"), + }, ], }; 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 5b525b11..0f5ade3d 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 @@ -1,83 +1,38 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG: executeRemoteUpdate for tracked docs doesn't record the remote - * version's vaultUpdateId. - * - * In sync-actions.ts executeRemoteUpdate (line 1124-1135): - * if (doc?.state === "tracked") { - * if (doc.serverVersion >= remoteVersion.vaultUpdateId) { - * deps.vfs.addSeenUpdateId(remoteVersion.vaultUpdateId); - * return; - * } - * return executeSyncUpdateFull(deps, doc, undefined, true); - * } - * - * When doc.serverVersion < remoteVersion.vaultUpdateId, the code delegates - * to executeSyncUpdateFull WITHOUT first recording remoteVersion.vaultUpdateId. - * executeSyncUpdateFull fetches the latest version from the server, which may - * have a HIGHER vaultUpdateId than the broadcast's. The response's - * vaultUpdateId is recorded, but the broadcast's original vaultUpdateId - * is never recorded — creating a permanent gap in CoveredValues. - * - * Similarly, when remote-update events coalesce (remote-update + - * remote-update = remote-update), the first event's vaultUpdateId - * is replaced by the second's and never recorded. - * - * This causes the watermark to stall, and every reconnect replays - * updates from the stuck point — wasting bandwidth. - * - * This test proves the watermark gap by doing two updates on one client, - * having the other client receive and process them, then disconnecting - * and reconnecting to see if the second sync is a no-op. - */ -function verifyConvergence(state: ClientState): void { - assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); - assert(state.files.has("doc.md"), "Expected doc.md to exist"); - const content = state.files.get("doc.md")!; - assert( - content === "update 2", - `Expected "update 2", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const watermarkGapRemoteUpdateNotRecordedTest: TestDefinition = { - name: "Watermark Gap When Remote Update vaultUpdateId Not Recorded", description: - "When a tracked document receives a remote update and the client " + - "fetches a newer version from the server, the broadcast's original " + - "vaultUpdateId is never recorded. This creates a watermark gap " + - "that causes unnecessary replays on reconnect.", + "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.", clients: 2, steps: [ - // Setup: both clients have doc.md { type: "create", client: 0, path: "doc.md", content: "original" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Client 0 sends two rapid updates { 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: "sync", client: 0 }, - // Client 1 processes the broadcasts { type: "sync", client: 1 }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyConvergence }, + { + type: "assert-consistent", + verify: (s) => + s.assertFileCount(1).assertContent("doc.md", "update 2"), + }, - // Disconnect and reconnect client 1 — the watermark should have - // advanced past both updates. If there's a gap, the server will - // replay the older update, causing unnecessary work. { type: "disable-sync", client: 1 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Verify convergence is maintained after reconnect - { type: "assert-consistent", verify: verifyConvergence } + { + type: "assert-consistent", + verify: (s) => + s.assertFileCount(1).assertContent("doc.md", "update 2"), + } ] }; diff --git a/frontend/deterministic-tests/src/utils/assertable-state.ts b/frontend/deterministic-tests/src/utils/assertable-state.ts new file mode 100644 index 00000000..05414342 --- /dev/null +++ b/frontend/deterministic-tests/src/utils/assertable-state.ts @@ -0,0 +1,132 @@ +import type { ClientState } from "../test-definition"; + +export class AssertableState { + readonly files: Map; + readonly clientFiles: Map[]; + + constructor(state: ClientState) { + this.files = state.files; + this.clientFiles = state.clientFiles; + } + + assertFileCount(expected: number): this { + if (this.files.size !== expected) { + const keys = Array.from(this.files.keys()).join(", "); + throw new Error( + `Expected ${expected} file(s), got ${this.files.size}: [${keys}]` + ); + } + return this; + } + + assertFileExists(path: string): this { + if (!this.files.has(path)) { + const keys = Array.from(this.files.keys()).join(", "); + throw new Error( + `Expected "${path}" to exist. Files: [${keys}]` + ); + } + return this; + } + + assertFileNotExists(path: string): this { + if (this.files.has(path)) { + const keys = Array.from(this.files.keys()).join(", "); + throw new Error( + `Expected "${path}" not to exist. Files: [${keys}]` + ); + } + return this; + } + + assertContent(path: string, expected: string): this { + this.assertFileExists(path); + const actual = this.files.get(path) ?? ""; + if (actual !== expected) { + throw new Error( + `Expected "${path}" to have content "${expected}", got: "${actual}"` + ); + } + return this; + } + + assertContains(path: string, ...substrings: string[]): this { + this.assertFileExists(path); + const content = this.files.get(path) ?? ""; + const missing = substrings.filter((s) => !content.includes(s)); + if (missing.length > 0) { + throw new Error( + `Expected "${path}" to contain ${missing.map((s) => `"${s}"`).join(", ")}. Content: "${content}"` + ); + } + return this; + } + + assertContainsAny(path: string, ...substrings: string[]): this { + this.assertFileExists(path); + const content = this.files.get(path) ?? ""; + const found = substrings.some((s) => content.includes(s)); + if (!found) { + throw new Error( + `Expected "${path}" to contain at least one of ${substrings.map((s) => `"${s}"`).join(", ")}. Content: "${content}"` + ); + } + return this; + } + + assertAnyFileContains(...substrings: string[]): this { + const allContent = Array.from(this.files.values()).join("\n"); + const missing = substrings.filter((s) => !allContent.includes(s)); + if (missing.length > 0) { + const dump = Array.from(this.files.entries()) + .map(([k, v]) => ` ${k}: "${v}"`) + .join("\n"); + throw new Error( + `Expected some file to contain ${missing.map((s) => `"${s}"`).join(", ")}.\nFiles:\n${dump}` + ); + } + return this; + } + + assertSubstringCount( + path: string, + substring: string, + expected: number + ): this { + this.assertFileExists(path); + const content = this.files.get(path) ?? ""; + const actual = content.split(substring).length - 1; + if (actual !== expected) { + throw new Error( + `Expected "${substring}" to appear ${expected} time(s) in "${path}", found ${actual}. Content: "${content}"` + ); + } + return this; + } + + assertContentInAtMostOneFile(substring: string): this { + const matches = Array.from(this.files.entries()).filter(([, content]) => + content.includes(substring) + ); + if (matches.length > 1) { + const dump = Array.from(this.files.entries()) + .map(([k, v]) => ` ${k}: "${v}"`) + .join("\n"); + throw new Error( + `Expected "${substring}" in at most 1 file, found in ${matches.length}: [${matches.map(([p]) => p).join(", ")}].\nFiles:\n${dump}` + ); + } + return this; + } + + ifFileExists(path: string, fn: (state: this) => void): this { + if (this.files.has(path)) { + fn(this); + } + return this; + } + + getContent(path: string): string { + return this.files.get(path) ?? ""; + } +}