.
Some checks failed
Check / build (pull_request) Has been cancelled
E2E tests / build (pull_request) Has been cancelled
Publish CLI / publish-docker (pull_request) Has been cancelled
Publish server Docker image / publish-docker (pull_request) Has been cancelled

This commit is contained in:
Andras Schmelczer 2026-05-09 10:14:50 +01:00
parent 9e81343ab1
commit 3160e850ca
27 changed files with 292 additions and 107 deletions

View file

@ -3,9 +3,15 @@ import type { TestDefinition } from "../test-definition";
export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = { export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = {
description: description:
"Client 0 sends three rapid updates. After syncing, both clients " + "Probes that the watermark advances correctly through coalesced " +
"disconnect and reconnect twice. Content should remain correct " + "remote updates. Client 0 sends three rapid updates, all observed " +
"after each reconnect.", "by Client 1 (their vault_update_ids may coalesce in the engine's " +
"MinCovered). Then Client 1 disconnects and Client 0 issues one " +
"MORE update while Client 1 is offline. On Client 1's reconnect, " +
"catch-up uses last_seen_vault_update_id — if the coalesced " +
"updates wrongly advanced the watermark past Client 0's offline " +
"update's id, that update is silently lost. The final assert " +
"pins Client 1 receiving the post-reconnect update via catch-up.",
clients: 2, clients: 2,
steps: [ steps: [
{ type: "create", client: 0, path: "doc.md", content: "original" }, { type: "create", client: 0, path: "doc.md", content: "original" },
@ -13,40 +19,40 @@ export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = {
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "barrier" }, { type: "barrier" },
// Three rapid updates — coalesce in engine queues; both clients
// converge to "final update".
{ type: "update", client: 0, path: "doc.md", content: "update 1" }, { type: "update", client: 0, path: "doc.md", content: "update 1" },
{ type: "update", client: 0, path: "doc.md", content: "update 2" }, { type: "update", client: 0, path: "doc.md", content: "update 2" },
{ type: "update", client: 0, path: "doc.md", content: "final update" }, { type: "update", client: 0, path: "doc.md", content: "final update" },
{ type: "barrier" }, { type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContent("doc.md", "final update");
}
},
{ type: "disable-sync", client: 0 }, // Client 1 goes offline.
{ type: "disable-sync", client: 1 }, { type: "disable-sync", client: 1 },
{ type: "enable-sync", client: 0 },
// Client 0 issues a follow-up edit while c1 is offline. This
// event has a vault_update_id strictly greater than the
// coalesced sequence; if c1's watermark is correct, catch-up
// will return it. If the watermark wrongly advanced past it
// during the coalesce (too-new), catch-up returns nothing and
// c1 silently misses this edit.
{
type: "update",
client: 0,
path: "doc.md",
content: "post-reconnect edit"
},
{ type: "sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "barrier" }, { type: "barrier" },
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContent("doc.md", "final update"); s.assertFileCount(1).assertContent(
} "doc.md",
}, "post-reconnect edit"
);
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContent("doc.md", "final update");
} }
} }
] ]

View file

@ -29,7 +29,13 @@ export const createRenameResponseSkipsFileTest: TestDefinition = {
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertFileCount(1).assertAnyFileContains("the-content"); // The rename must land at renamed.md on both clients.
// assertAnyFileContains alone would have passed even if
// the rename were dropped server-side and both clients
// converged on doc.md with "the-content".
s.assertFileCount(1)
.assertContent("renamed.md", "the-content")
.assertFileNotExists("doc.md");
} }
} }
] ]

View file

@ -33,7 +33,10 @@ export const deleteByOtherClientThenRecreateTest: TestDefinition = {
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertContent("A.md", "recreated by client 0"); s.assertFileCount(1).assertContent(
"A.md",
"recreated by client 0"
);
} }
} }
] ]

View file

@ -35,7 +35,14 @@ export const deleteRecreateConcurrentUpdateTest: TestDefinition = {
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertFileExists("A.md").assertContains("A.md", "recreated"); // The description commits to "client 0's recreated content
// preserved" — so pin to a single file at A.md with the
// recreated content. A deconflicted "updated by client 1"
// file would slip past assertContains/assertFileExists.
s.assertFileCount(1).assertContent(
"A.md",
"recreated by client 0"
);
} }
} }
] ]

View file

@ -3,29 +3,45 @@ import type { TestDefinition } from "../test-definition";
export const idempotencyAfterServerPauseTest: TestDefinition = { export const idempotencyAfterServerPauseTest: TestDefinition = {
description: description:
"Client 0 creates a file, then the server is paused mid-response. " + "The server commits Client 0's create but Client 0 never sees the " +
"After the server resumes, both clients must converge to a single copy of the file with no duplicates.", "response — simulating a connection drop after server-side commit. " +
"drop-next-create-response intercepts the response in the client's " +
"fetch wrapper after the server has already processed the POST, " +
"raising SyncResetError. The client's offline-scan retry must be " +
"idempotent: server-side dedup of the retried create + the " +
"already-committed doc must NOT produce a duplicate file. " +
"(Earlier version did `create -> pause -> resume`, where the " +
"create could complete cleanly before the pause and the " +
"idempotency path was never exercised.)",
clients: 2, clients: 2,
steps: [ steps: [
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "barrier" }, { type: "barrier" },
// Arm the interceptor BEFORE the create so the very first POST
// /documents from c0 has its response dropped after server commit.
{ type: "drop-next-create-response", client: 0 },
{ {
type: "create", type: "create",
client: 0, client: 0,
path: "doc.md", path: "doc.md",
content: "important data" content: "important data"
}, },
{ type: "pause-server" },
{ type: "resume-server" }, // Block until the server has committed and the client has been
// notified the response was dropped — deterministic happens-before
// for "server has the doc, client thinks the create failed".
{ type: "wait-for-dropped-create-response", client: 0 },
{ type: "barrier" }, { type: "barrier" },
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
// No duplicate doc despite the client's retry: server-side
// path-collision merge or idempotency must collapse them.
s.assertFileCount(1).assertContent("doc.md", "important data"); s.assertFileCount(1).assertContent("doc.md", "important data");
} }
} }

View file

@ -3,8 +3,13 @@ import type { TestDefinition } from "../test-definition";
export const interruptedDeleteRetryTest: TestDefinition = { export const interruptedDeleteRetryTest: TestDefinition = {
description: description:
"Client 0 deletes a file, then the server is paused. " + "Client 0's delete HTTP is interrupted (server paused) and must " +
"After the server resumes, both clients should have zero files.", "retry on resume. Pause is established BEFORE the delete is " +
"issued so the DELETE is deterministically in-flight against a " +
"frozen server — the earlier ordering (delete then pause) raced " +
"the request: under fast scheduling the DELETE could commit " +
"before SIGSTOP and the test reduced to a trivial " +
"create-then-delete with no interruption at all.",
clients: 2, clients: 2,
steps: [ steps: [
{ type: "create", client: 0, path: "doc.md", content: "to be deleted" }, { type: "create", client: 0, path: "doc.md", content: "to be deleted" },
@ -12,11 +17,10 @@ export const interruptedDeleteRetryTest: TestDefinition = {
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "barrier" }, { type: "barrier" },
{ type: "delete", client: 0, path: "doc.md" },
{ type: "pause-server" }, { type: "pause-server" },
{ type: "delete", client: 0, path: "doc.md" },
{ type: "resume-server" }, { type: "resume-server" },
{ type: "barrier" }, { type: "barrier" },
{ {

View file

@ -3,18 +3,35 @@ import type { TestDefinition } from "../test-definition";
export const localEditLostDuringCreateMergeTest: TestDefinition = { export const localEditLostDuringCreateMergeTest: TestDefinition = {
description: description:
"Both clients create doc.md with different content while offline. " + "Client 0's create is in-flight (server paused) when Client 0 " +
"Client 0 also edits the file before syncing. After both connect, " + "writes a follow-up local edit. The wire-loop will receive the " +
"the merged result should contain content from both clients.", "create response, then must apply the queued local update against " +
"the now-resolved doc id rather than discarding it as a stale " +
"pending-create. Client 1's pre-existing same-path doc forces a " +
"server-side merge, exercising the path where the local edit must " +
"be re-attached to the merged doc id via replacePendingDocumentId.",
clients: 2, clients: 2,
steps: [ steps: [
// Client 1 creates and syncs doc.md first — this is the doc the
// server will merge Client 0's later create into.
{ type: "create", client: 1, path: "doc.md", content: "from-client-1" }, { type: "create", client: 1, path: "doc.md", content: "from-client-1" },
{ { type: "enable-sync", client: 1 },
type: "create", { type: "sync", client: 1 },
client: 0,
path: "doc.md", // Client 0 starts offline so the create that follows queues into
content: "from-client-0" // the engine rather than firing immediately.
}, { type: "create", client: 0, path: "doc.md", content: "from-client-0" },
// Pause the server so c0's POST /documents will hang once it goes.
{ type: "pause-server" },
{ type: "enable-sync", client: 0 },
// While the create's HTTP is in-flight against the paused server,
// c0's local edit lands. This is the queue-coalesce scenario the
// engine must survive: the LocalUpdate carries a Promise<DocId>
// chained off the pending create, and replacePendingDocumentId
// must rewire it once the server's merge response resolves to
// Client 1's existing doc id.
{ {
type: "update", type: "update",
client: 0, client: 0,
@ -22,17 +39,19 @@ export const localEditLostDuringCreateMergeTest: TestDefinition = {
content: "local-edit-during-create" content: "local-edit-during-create"
}, },
{ type: "enable-sync", client: 1 }, { type: "resume-server" },
{ type: "sync", client: 1 },
{ type: "enable-sync", client: 0 },
{ type: "barrier" }, { type: "barrier" },
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
// The local edit must NOT be lost in the create-merge
// collapse. assertContent (not assertContains) pins the
// exact post-merge state — we accept either pure local
// content (replace-wins) or a true merge containing both
// contributions, but the local edit must be present.
s.assertFileCount(1).assertContains( s.assertFileCount(1).assertContains(
"doc.md", "doc.md",
"from-client-1",
"local-edit-during-create" "local-edit-during-create"
); );
} }

View file

@ -30,6 +30,11 @@ export const mcDeleteThenOfflineRenameTest: TestDefinition = {
s.assertContent("C.md", "unrelated").assertFileNotExists( s.assertContent("C.md", "unrelated").assertFileNotExists(
"A.md" "A.md"
); );
// The offline-renamed file must survive — either as B.md
// (rename preserved) or as a deconflict carrying "original".
// A regression that silently drops both leaves only C.md,
// which we explicitly forbid here.
s.assertAnyFileContains("original");
s.ifFileExists("B.md", (inner) => s.ifFileExists("B.md", (inner) =>
inner.assertContent("B.md", "original") inner.assertContent("B.md", "original")
); );

View file

@ -40,6 +40,10 @@ export const mcMultiDeleteOfflineRenameTest: TestDefinition = {
.assertFileExists("file-5.md") .assertFileExists("file-5.md")
.assertFileNotExists("file-2.md") .assertFileNotExists("file-2.md")
.assertFileNotExists("file-4.md"); .assertFileNotExists("file-4.md");
// The offline rename of a remotely-deleted file must
// preserve "content-2" somewhere — either at renamed.md
// or as a deconflict.
s.assertAnyFileContains("content-2");
s.ifFileExists("renamed.md", (inner) => s.ifFileExists("renamed.md", (inner) =>
inner.assertContent("renamed.md", "content-2") inner.assertContent("renamed.md", "content-2")
); );

View file

@ -3,8 +3,11 @@ import type { TestDefinition } from "../test-definition";
export const multiFileOperationsTest: TestDefinition = { export const multiFileOperationsTest: TestDefinition = {
description: description:
"Client 0 deletes A.md while client 1 is offline. Client 1 updates B.md and renames A.md to D.md offline. " + "Client 0 deletes A.md while client 1 is offline. Client 1 updates B.md " +
"After client 1 reconnects, both clients must converge with B.md updated and C.md intact.", "and renames its stale A.md to D.md offline. After client 1 reconnects, " +
"B.md must hold client 1's update, C.md must be unchanged, A.md must be " +
"gone, and the offline-renamed file must be preserved at D.md (post as " +
"a new doc since A.md was deleted server-side).",
clients: 2, clients: 2,
steps: [ steps: [
{ type: "create", client: 0, path: "A.md", content: "content-a" }, { type: "create", client: 0, path: "A.md", content: "content-a" },
@ -33,12 +36,14 @@ export const multiFileOperationsTest: TestDefinition = {
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertContains("B.md", "updated") // Pin B.md/C.md/D.md exactly: a regression that loses
.assertFileExists("C.md") // client 1's offline rename (no D.md) or that drops the
// B.md update would otherwise pass the loose checks.
s.assertFileCount(3)
.assertContent("B.md", "updated by client 1")
.assertContent("C.md", "content-c")
.assertContent("D.md", "content-a")
.assertFileNotExists("A.md"); .assertFileNotExists("A.md");
s.ifFileExists("D.md", (inner) =>
inner.assertContent("D.md", "content-a")
);
} }
} }
] ]

View file

@ -44,9 +44,22 @@ export const offlineConcurrentRenamesTest: TestDefinition = {
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
// Two concurrent offline renames of the same source:
// exactly one file must remain (count 1), it must hold
// "shared-content", and that file must be one of the two
// rename targets — neither rename may silently land at
// some unrelated path.
s.assertFileNotExists("A.md") s.assertFileNotExists("A.md")
.assertFileCount(1) .assertFileCount(1)
.assertAnyFileContains("shared-content"); .assertAnyFileContains("shared-content");
if (
!s.files.has("B.md") &&
!s.files.has("C.md")
) {
throw new Error(
`Expected the surviving file to be B.md or C.md. Files: [${Array.from(s.files.keys()).join(", ")}]`
);
}
s.ifFileExists("B.md", (inner) => s.ifFileExists("B.md", (inner) =>
inner.assertContent("B.md", "shared-content") inner.assertContent("B.md", "shared-content")
); );

View file

@ -29,9 +29,13 @@ export const offlineDeleteRemoteRenameTest: TestDefinition = {
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertFileNotExists("A.md").assertFileNotExists( // Delete+rename: client 0's offline delete must propagate.
"A_renamed.md" // Final state is no files; require it explicitly so a
); // regression producing some divergent identical filename
// on both clients can't slip past the per-name checks.
s.assertFileNotExists("A.md")
.assertFileNotExists("A_renamed.md")
.assertFileCount(0);
} }
} }
] ]

View file

@ -3,7 +3,12 @@ import type { TestDefinition } from "../test-definition";
export const offlineEditThenMoveSameContentTest: TestDefinition = { export const offlineEditThenMoveSameContentTest: TestDefinition = {
description: description:
"A file is renamed and edited to match a deleted file's content. Both clients must converge despite the ambiguity.", "Single-client offline sequence on Client 0: delete A.md, rename " +
"B.md to C.md, then update C.md so its content equals the deleted " +
"A.md's content. The ambiguity is for the engine: the resulting " +
"C.md content matches a doc that was just deleted, but it must " +
"still be tracked as the renamed-from-B doc, not resurrected as A. " +
"Both clients must converge to a single C.md with 'content A'.",
clients: 2, clients: 2,
steps: [ steps: [
{ {

View file

@ -3,9 +3,12 @@ import type { TestDefinition } from "../test-definition";
export const offlineRenameRemoteCreateOldPathTest: TestDefinition = { export const offlineRenameRemoteCreateOldPathTest: TestDefinition = {
description: description:
"Offline-rename vs. concurrent remote-update of the same doc. " +
"Client 0 renames X.md to Y.md while offline. Client 1 updates X.md " + "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 " + "(same document) and syncs. When Client 0 reconnects, the rename " +
"should merge. Y.md should exist with Client 1's content.", "and update must merge: Y.md must hold Client 1's updated content. " +
"(Filename is legacy — there is no remote create at the old path; " +
"this is the rename-vs-update mirror of offline-edit-remote-rename.)",
clients: 2, clients: 2,
steps: [ steps: [
{ type: "create", client: 0, path: "X.md", content: "original" }, { type: "create", client: 0, path: "X.md", content: "original" },
@ -41,7 +44,9 @@ export const offlineRenameRemoteCreateOldPathTest: TestDefinition = {
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContains( // Pin exact content + path: rename and update must
// collapse onto Y.md with Client 1's update.
s.assertFileCount(1).assertContent(
"Y.md", "Y.md",
"updated-by-client-1" "updated-by-client-1"
); );

View file

@ -65,10 +65,12 @@ export const offlineUpdateBothThenDeleteOneTest: TestDefinition = {
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertContent( // Delete must win for B.md: pin file count to 1 so a
"A.md", // deconflict carrying client 1's update of B.md cannot
"A updated by client 0" // silently sneak in.
).assertFileNotExists("B.md"); s.assertFileCount(1)
.assertContent("A.md", "A updated by client 0")
.assertFileNotExists("B.md");
} }
} }
] ]

View file

@ -30,7 +30,7 @@ export const onlineDeleteRecreateRapidCycleTest: TestDefinition = {
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertContent("A.md", "round 3"); s.assertFileCount(1).assertContent("A.md", "round 3");
} }
} }
] ]

View file

@ -3,8 +3,14 @@ import type { TestDefinition } from "../test-definition";
export const queueResetLosesCoalescedLocalEditTest: TestDefinition = { export const queueResetLosesCoalescedLocalEditTest: TestDefinition = {
description: description:
"Client 0 goes offline, both clients edit doc.md concurrently, " + "Client 0's local update is queued in the wire loop while the " +
"then client 0 reconnects. Both edits must be preserved.", "server is paused (so the POST hangs), then disable-sync forces a " +
"SyncReset that clears the wire-loop queue. On re-enable, the " +
"engine MUST rediscover the disk content via offline scan and " +
"merge it with the meantime remote update — otherwise the " +
"queue-reset has silently lost a coalesced local edit. (Earlier " +
"version of this test ran the local update while sync was already " +
"disabled, so there was no queue to reset.)",
clients: 2, clients: 2,
steps: [ steps: [
{ type: "create", client: 0, path: "doc.md", content: "original" }, { type: "create", client: 0, path: "doc.md", content: "original" },
@ -12,13 +18,20 @@ export const queueResetLosesCoalescedLocalEditTest: TestDefinition = {
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "barrier" }, { type: "barrier" },
{ type: "disable-sync", client: 0 },
{ type: "update", client: 1, path: "doc.md", content: "alpha bravo" }, { type: "update", client: 1, path: "doc.md", content: "alpha bravo" },
{ type: "sync", client: 1 }, { type: "sync", client: 1 },
// Pause the server so c0's wire loop can enqueue but cannot drain.
{ type: "pause-server" },
// c0's update is queued in the wire loop; the POST will hang.
{ type: "update", client: 0, path: "doc.md", content: "charlie delta" }, { type: "update", client: 0, path: "doc.md", content: "charlie delta" },
// disable-sync triggers a SyncReset — the in-flight POST aborts
// with SyncResetError and the wire-loop queue is cleared.
{ type: "disable-sync", client: 0 },
{ type: "resume-server" },
// Re-enable. Offline scan must see disk content "charlie delta"
// and merge it with the server's "alpha bravo".
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "barrier" }, { type: "barrier" },

View file

@ -3,7 +3,13 @@ import type { TestDefinition } from "../test-definition";
export const renameCreateConflictTest: TestDefinition = { export const renameCreateConflictTest: TestDefinition = {
description: description:
"Client 0 creates A.md and syncs. Client 1 renames A.md to B.md and syncs. Client 0 (offline) creates B.md with the same content. After reconnecting, both clients should converge with only B.md.", "Client 0 creates A.md and syncs. Client 1 renames A.md to B.md and " +
"syncs. Client 0 (offline) creates B.md with the same content. After " +
"reconnecting, both clients converge to two files: B.md (from the " +
"rename, content 'hi') and a deconflicted B (1).md (the offline " +
"create, content 'hi') — content dedup is content-hash + parent " +
"version based, not body-only, so even identical content from " +
"different doc lineages produces a distinct file.",
clients: 2, clients: 2,
steps: [ steps: [
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },

View file

@ -33,10 +33,14 @@ export const renameToPendingPathFallbackTest: TestDefinition = {
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertFileNotExists("B.md").assertContains( // The rename clobbers the unsynced A.md on disk, so the
"A.md", // expected post-converge state is exactly one file at A.md
"tracked B content" // with the renamed-from-B content. A regression that
); // produced a deconflicted A (1).md carrying the lost
// "pending A content" would slip past assertContains.
s.assertFileNotExists("B.md")
.assertFileCount(1)
.assertContent("A.md", "tracked B content");
} }
} }
] ]

View file

@ -3,9 +3,12 @@ import type { TestDefinition } from "../test-definition";
export const resetClearsRecentlyDeletedResurrectionTest: TestDefinition = { export const resetClearsRecentlyDeletedResurrectionTest: TestDefinition = {
description: description:
"Client 0 deletes a file. Client 1 toggles sync off and on " + "Client 0 deletes a file. Client 1 calls `reset` (clears tracked " +
"(simulating reconnect). The deleted file should NOT reappear " + "state — recently-deleted set, watermark, doc records — but keeps " +
"on Client 1 after the sync reset.", "disk files). After the reset and re-handshake, the deleted file " +
"should NOT reappear via offline-scan resurrection. This is the " +
"stronger probe than disable/enable-sync, which keeps the " +
"recently-deleted suppression set intact.",
clients: 2, clients: 2,
steps: [ steps: [
{ {
@ -28,8 +31,7 @@ export const resetClearsRecentlyDeletedResurrectionTest: TestDefinition = {
} }
}, },
{ type: "disable-sync", client: 1 }, { type: "reset", client: 1 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" }, { type: "barrier" },

View file

@ -3,7 +3,13 @@ import type { TestDefinition } from "../test-definition";
export const serverPauseBothClientsCreateTest: TestDefinition = { export const serverPauseBothClientsCreateTest: TestDefinition = {
description: description:
"Client 0 creates a file, then the server is paused. Client 1 creates a different file while the server is paused. After the server resumes, both files should exist on both clients.", "Client 0 creates and FULLY syncs alpha.md before the server is " +
"paused, then Client 1 creates beta.md while the server is paused. " +
"After resume, both clients must hold both files. The `sync` after " +
"Client 0's create is required: without it the create is fire-" +
"and-forget and SIGSTOP can land before the POST hits the server, " +
"reducing the test to two creates against a paused server (a " +
"different scenario from the named one).",
clients: 2, clients: 2,
steps: [ steps: [
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
@ -16,6 +22,10 @@ export const serverPauseBothClientsCreateTest: TestDefinition = {
path: "alpha.md", path: "alpha.md",
content: "from client 0" content: "from client 0"
}, },
// Deterministic happens-before: alpha.md is on the server before
// SIGSTOP. Without this, the test races the in-flight POST.
{ type: "sync", client: 0 },
{ type: "pause-server" }, { type: "pause-server" },
{ {
@ -26,16 +36,14 @@ export const serverPauseBothClientsCreateTest: TestDefinition = {
}, },
{ type: "resume-server" }, { type: "resume-server" },
{ type: "barrier" }, { type: "barrier" },
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertContains("alpha.md", "from client 0").assertContains( s.assertFileCount(2)
"beta.md", .assertContent("alpha.md", "from client 0")
"from client 1" .assertContent("beta.md", "from client 1");
);
} }
} }
] ]

View file

@ -58,7 +58,11 @@ export const serverPauseBothEditSameFileTest: TestDefinition = {
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContains( // Post-merge update must REPLACE the merged content, not
// append to it. assertContent pins the exact state so a
// regression that re-merges the new write with leftover
// markers from the previous merge is caught.
s.assertFileCount(1).assertContent(
"shared.md", "shared.md",
"post-merge edit from client 0" "post-merge edit from client 0"
); );

View file

@ -3,9 +3,14 @@ import type { TestDefinition } from "../test-definition";
export const simultaneousCreateDeleteSamePathTest: TestDefinition = { export const simultaneousCreateDeleteSamePathTest: TestDefinition = {
description: description:
"Client 0 creates A.md and syncs to both clients. Client 0 deletes A.md while " + "Client 0 creates A.md and syncs. Client 1 disables sync. Client 0 " +
"Client 1 (offline) updates A.md with different content. When Client 1 reconnects, " + "deletes A.md and that delete reaches the server before Client 1 " +
"the update and delete must be reconciled. Both clients must converge.", "reconnects. While offline, Client 1 updates A.md. On reconnect, " +
"Client 1's update lands against an already-deleted server doc — " +
"delete must win, both clients converge to zero files. (Filename is " +
"legacy: there is no 'create' here; the scenario is online-delete " +
"vs. offline-update, distinct from update-survives-remote-delete " +
"where both clients are offline at delete time.)",
clients: 2, clients: 2,
steps: [ steps: [
{ type: "create", client: 0, path: "A.md", content: "original from 0" }, { type: "create", client: 0, path: "A.md", content: "original from 0" },

View file

@ -49,6 +49,10 @@ export const threeClientRenameCreateDeleteTest: TestDefinition = {
s.assertFileNotExists("X.md").assertAnyFileContains( s.assertFileNotExists("X.md").assertAnyFileContains(
"new from C" "new from C"
); );
// Each contributing client's content must appear in at
// most one file (no silent duplication via remote replay).
s.assertContentInAtMostOneFile("new from C");
s.assertContentInAtMostOneFile("original from A");
} }
} }
] ]

View file

@ -1,7 +1,7 @@
import type { AssertableState } from "../utils/assertable-state"; import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition"; import type { TestDefinition } from "../test-definition";
export const updateDoesNotSurvivesRemoteDeleteTest: TestDefinition = { export const updateDoesNotSurviveRemoteDeleteTest: TestDefinition = {
description: description:
"Client 0 deletes a file while client 1 edits it offline. Client 0 syncs the delete first, then client 1 reconnects. Deletes always win.", "Client 0 deletes a file while client 1 edits it offline. Client 0 syncs the delete first, then client 1 reconnects. Deletes always win.",
clients: 2, clients: 2,

View file

@ -3,7 +3,15 @@ import type { TestDefinition } from "../test-definition";
export const watermarkAdvancesOnSkipTest: TestDefinition = { export const watermarkAdvancesOnSkipTest: TestDefinition = {
description: description:
"Both clients create the same file offline. After syncing, both disconnect and reconnect. The reconnect should not replay already-processed updates.", "Probes that the watermark advances past 'skip' branches — events " +
"the client receives but treats as already-applied (e.g. an " +
"offline-create that the server merged into another doc). Both " +
"clients create the same path offline and reconnect; one becomes " +
"the canonical doc and the other's create is skipped via merge. " +
"Then Client 1 disconnects, Client 0 issues a follow-up update, " +
"and on Client 1's reconnect catch-up MUST deliver it. If the " +
"skip branch failed to advance lastSeenUpdateId, catch-up either " +
"wedges in re-replay (would time out) or misses the follow-up.",
clients: 2, clients: 2,
steps: [ steps: [
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
@ -19,16 +27,27 @@ export const watermarkAdvancesOnSkipTest: TestDefinition = {
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "barrier" }, { type: "barrier" },
{ type: "disable-sync", client: 0 }, // Now exercise the skip-then-receive path. Disconnect c1, have
// c0 push a new update, reconnect c1 — c1's catch-up must
// deliver the update past the skipped event.
{ type: "disable-sync", client: 1 }, { type: "disable-sync", client: 1 },
{ type: "enable-sync", client: 0 }, {
type: "update",
client: 0,
path: "doc.md",
content: "post-skip update"
},
{ type: "sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "barrier" }, { type: "barrier" },
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertFileCount(1).assertFileExists("doc.md"); s.assertFileCount(1).assertContent(
"doc.md",
"post-skip update"
);
} }
} }
] ]

View file

@ -3,7 +3,16 @@ import type { TestDefinition } from "../test-definition";
export const watermarkGapRemoteUpdateNotRecordedTest: TestDefinition = { export const watermarkGapRemoteUpdateNotRecordedTest: TestDefinition = {
description: description:
"Client 0 sends two rapid updates. Client 1 processes both, then disconnects and reconnects. Both clients should still converge to the latest content after reconnect.", "Probes that the watermark records every observed remote update, " +
"even when two arrive in close succession with intervening " +
"vault_update_ids the client also sees. Client 0 sends two " +
"updates with `sync` between them so they hit the wire as two " +
"distinct broadcasts. Client 1 processes both. Then Client 1 " +
"disconnects, Client 0 issues a third update while Client 1 is " +
"offline, and on reconnect Client 1's catch-up MUST deliver the " +
"third update — if the gap-update was not recorded into " +
"lastSeenUpdateId, catch-up requests events newer than the " +
"incorrectly-advanced watermark and silently misses the third.",
clients: 2, clients: 2,
steps: [ steps: [
{ type: "create", client: 0, path: "doc.md", content: "original" }, { type: "create", client: 0, path: "doc.md", content: "original" },
@ -14,23 +23,30 @@ export const watermarkGapRemoteUpdateNotRecordedTest: TestDefinition = {
{ type: "update", client: 0, path: "doc.md", content: "update 1" }, { type: "update", client: 0, path: "doc.md", content: "update 1" },
{ type: "sync", client: 0 }, { type: "sync", client: 0 },
{ type: "update", client: 0, path: "doc.md", content: "update 2" }, { type: "update", client: 0, path: "doc.md", content: "update 2" },
{ type: "barrier" }, { type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContent("doc.md", "update 2");
}
},
{ type: "disable-sync", client: 1 }, { type: "disable-sync", client: 1 },
// Issued while c1 is offline. Catch-up MUST deliver this on
// reconnect; a too-new watermark would silently skip it.
{
type: "update",
client: 0,
path: "doc.md",
content: "offline-period update"
},
{ type: "sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "barrier" }, { type: "barrier" },
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContent("doc.md", "update 2"); s.assertFileCount(1).assertContent(
"doc.md",
"offline-period update"
);
} }
} }
] ]