.
This commit is contained in:
parent
9e81343ab1
commit
3160e850ca
27 changed files with 292 additions and 107 deletions
|
|
@ -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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue