Add deterministic-tests workspace
Scripted multi-client harness against a real server (~110 scenario tests, server-control, managed-websocket, test-runner). Wires the new package into frontend/package.json workspaces and the lint script. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4482e0155f
commit
a33e4bbcb9
129 changed files with 7626 additions and 1 deletions
|
|
@ -0,0 +1,36 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const textPendingCreateNotDisplacedTest: TestDefinition = {
|
||||
description:
|
||||
"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: [
|
||||
{
|
||||
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: (s: AssertableState): void => {
|
||||
s.assertFileCount(1)
|
||||
.assertFileExists("data.txt")
|
||||
.assertAnyFileContains("client-0", "client-1");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const concurrentUpdateDiffConsistencyTest: TestDefinition = {
|
||||
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: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(1)
|
||||
.assertContent(
|
||||
"doc.md",
|
||||
"header by 0\nmiddle\nfooter by 1"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
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: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(3)
|
||||
.assertFileExists("Chapter.bin")
|
||||
.assertFileExists("Chapter (1).bin")
|
||||
.assertFileExists("Chapter (2).bin");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const createDeleteNoopTest: TestDefinition = {
|
||||
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-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileNotExists("temp.md");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const createMergeDeleteTest: TestDefinition = {
|
||||
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: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(1)
|
||||
.assertContains("A.md", "from-zero", "from-one");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(0).assertFileNotExists("A.md");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const moveIdenticalContentAmbiguityTest: TestDefinition = {
|
||||
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: "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: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(1)
|
||||
.assertFileNotExists("A.md")
|
||||
.assertFileNotExists("B.md")
|
||||
.assertContent("C.md", "identical content");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const createUpdateCoalesceServerPauseTest: TestDefinition = {
|
||||
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: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(1)
|
||||
.assertContent("doc.md", "final version");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const createDuringReconciliationTest: TestDefinition = {
|
||||
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: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(3)
|
||||
.assertContent("A.md", "offline A")
|
||||
.assertContent("B.md", "offline B")
|
||||
.assertContent("C.md", "post-reconnect C");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
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: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state.assertContains("doc.md", "alpha", "beta");
|
||||
}
|
||||
},
|
||||
|
||||
{ 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: AssertableState): void => {
|
||||
state
|
||||
.assertContent("moved.md", "alpha beta extra-update")
|
||||
.assertContent("doc.md", "new-content");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
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: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(3)
|
||||
.assertContent("B.md", "first file")
|
||||
.assertContent("C.md", "second file")
|
||||
.assertContent("A.md", "third file");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
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: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(3)
|
||||
.assertContent("A.md", "was C")
|
||||
.assertContent("B.md", "was A")
|
||||
.assertContent("C.md", "was B");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const binaryPendingCreateNotDisplacedTest: TestDefinition = {
|
||||
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: (s: AssertableState): void => {
|
||||
s.assertFileCount(2)
|
||||
.assertFileExists("data.bin")
|
||||
.assertFileExists("data (1).bin")
|
||||
.assertAnyFileContains(
|
||||
"binary data from client 0",
|
||||
"binary data from client 1"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const coalesceUpdateRemoteUpdateDataLossTest: TestDefinition = {
|
||||
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: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(1)
|
||||
.assertContains(
|
||||
"doc.md",
|
||||
"client 0 addition",
|
||||
"client 1 addition"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 sends three rapid updates. After syncing, both clients " +
|
||||
"disconnect and reconnect twice. Content should remain correct " +
|
||||
"after each reconnect.",
|
||||
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: "update", client: 0, path: "doc.md", content: "update 1" },
|
||||
{ type: "update", client: 0, path: "doc.md", content: "update 2" },
|
||||
{ type: "update", client: 0, path: "doc.md", content: "final update" },
|
||||
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContent("doc.md", "final update");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ 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");
|
||||
}
|
||||
},
|
||||
|
||||
{ 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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const concurrentDeleteDuringRemoteUpdateTest: TestDefinition = {
|
||||
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: AssertableState): void => {
|
||||
state.assertFileCount(0);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const concurrentEditExactSamePositionTest: TestDefinition = {
|
||||
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: "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: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(1)
|
||||
.assertContains("doc.md", "slow", "fast", "brown fox");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const concurrentRenameAndCreateAtTargetTest: TestDefinition = {
|
||||
description:
|
||||
"One client renames X to Y while another creates a new file at Y, " +
|
||||
"both offline. We can't merge the create because it would result in a cycle",
|
||||
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: AssertableState): void => {
|
||||
state
|
||||
.assertFileNotExists("X.md")
|
||||
.assertFileExists("Y.md")
|
||||
.assertFileExists("Y (1).md")
|
||||
.assertAnyFileContains(
|
||||
"original file X",
|
||||
"brand new Y content"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const concurrentRenameAndCreateAtTargetTest: TestDefinition = {
|
||||
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: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(2)
|
||||
.assertContains("Y (1).md", "original file X")
|
||||
.assertContains("Y.md", "brand new Y content");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const concurrentRenameSameTargetTest: TestDefinition = {
|
||||
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: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(2)
|
||||
.assertFileNotExists("A.md")
|
||||
.assertFileNotExists("B.md")
|
||||
.assertFileExists("C.md")
|
||||
.assertFileExists("C (1).md")
|
||||
.assertAnyFileContains("content-a", "content-b");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const binaryToTextTransitionTest: TestDefinition = {
|
||||
description:
|
||||
"A .bin file is created and synced. Both clients edit it offline " +
|
||||
"(binary last-write-wins), then client 0 renames it to .md and " +
|
||||
"writes a clean text baseline. Both clients edit different sections " +
|
||||
"offline. The text 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: AssertableState): void => {
|
||||
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" },
|
||||
{ type: "update", client: 1, path: "data.bin", content: "version B" },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContainsAny(
|
||||
"data.bin",
|
||||
"version A",
|
||||
"version B"
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 1 },
|
||||
{ type: "rename", client: 0, oldPath: "data.bin", newPath: "data.md" },
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "data.md",
|
||||
content: "top line\nmiddle line\nbottom line"
|
||||
},
|
||||
{ type: "sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent(
|
||||
"data.md",
|
||||
"top line\nmiddle line\nbottom line"
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "data.md",
|
||||
content: "alpha\nmiddle line\nbottom line"
|
||||
},
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "data.md",
|
||||
content: "top line\nmiddle line\nbeta"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContains("data.md", "alpha", "beta");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const catchupCreateAndUpdateNotSkippedTest: TestDefinition = {
|
||||
description:
|
||||
"Client 1 disconnects (sync disabled). Client 0 creates a doc and " +
|
||||
"then updates it. When Client 1 reconnects, the server's catch-up " +
|
||||
"stream sends only the doc's *latest* version (the update), not the " +
|
||||
"full history. Pre-fix the wire's `is_new_file` was set to " +
|
||||
"`creation == latest_version`, so the catch-up flagged the doc as " +
|
||||
"non-new even though Client 1 had never seen its creation. Client " +
|
||||
"1's `processRemoteChange` then dropped it as a 'stale RemoteChange " +
|
||||
"for untracked, non-new document' and the doc was silently lost. " +
|
||||
"Post-fix `is_new_file` in the catch-up stream means 'new relative " +
|
||||
"to the recipient's watermark' (`creation > last_seen_vault_update_id`).",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
// Establish a baseline so Client 1's last_seen is non-zero before
|
||||
// we take it offline. This makes the bug genuinely about catch-up
|
||||
// missing the create rather than just an empty-vault first sync.
|
||||
{ type: "create", client: 0, path: "warmup.md", content: "w\n" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Client 1 goes offline.
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
// Client 0 creates the doc (vault_update_id v_C, after Client 1's
|
||||
// watermark). Client 1 doesn't see this because it's offline.
|
||||
{ type: "create", client: 0, path: "doc.md", content: "v1\n" },
|
||||
// Wait for the create's HTTP to land before the update; otherwise
|
||||
// both writes are coalesced into a single POST and the server
|
||||
// never sees the doc as "create followed by update".
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
// Client 0 updates the doc (vault_update_id v_X > v_C). The
|
||||
// server's `latest_document_versions` view now returns the
|
||||
// *update* row — its `creation_vault_update_id != vault_update_id`.
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "v1\nupdate\n"
|
||||
},
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
// Client 1 reconnects. Server's catch-up replays docs with
|
||||
// `vault_update_id > last_seen`. For doc.md it sends v_X with
|
||||
// `is_new_file` derived from `creation_vault_update_id >
|
||||
// last_seen_vault_update_id` (post-fix) — so Client 1 treats it
|
||||
// as a fresh create and downloads the latest content.
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state.assertFileCount(2);
|
||||
state.assertFileExists("doc.md");
|
||||
state.assertContent("doc.md", "v1\nupdate\n");
|
||||
state.assertContent("warmup.md", "w\n");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
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: AssertableState): void => {
|
||||
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: AssertableState): void => {
|
||||
s.assertFileNotExists("A.md")
|
||||
.assertFileCount(2)
|
||||
.assertContent("B.md", "edit from 0\nline 2\nline 3")
|
||||
.assertContent("C.md", "line 1\nline 2\nedit from 1");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const createRenameResponseSkipsFileTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 creates a file online then immediately renames it. " +
|
||||
"Client 1 must receive the file content at the renamed path.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "the-content"
|
||||
},
|
||||
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "doc.md",
|
||||
newPath: "renamed.md"
|
||||
},
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertAnyFileContains("the-content");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
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: AssertableState): void => {
|
||||
s.assertFileNotExists("A.md");
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "recreated by client 0"
|
||||
},
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("A.md", "recreated by client 0");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const deleteDuringPendingCreateTest: TestDefinition = {
|
||||
description:
|
||||
"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 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "pause-server" },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "ephemeral.md",
|
||||
content: "this will be deleted"
|
||||
},
|
||||
|
||||
{ type: "delete", client: 0, path: "ephemeral.md" },
|
||||
|
||||
{ type: "resume-server" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(0).assertFileNotExists("ephemeral.md");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const deleteRecreateConcurrentUpdateTest: TestDefinition = {
|
||||
description:
|
||||
"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: [
|
||||
{ type: "create", client: 0, path: "A.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "recreated by client 0"
|
||||
},
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "A.md",
|
||||
content: "updated by client 1"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileExists("A.md").assertContains("A.md", "recreated");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const deleteRecreateDifferentContentTest: TestDefinition = {
|
||||
description:
|
||||
"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: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "original content here"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "brand new content"
|
||||
},
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "A.md",
|
||||
content: "edit from client 1"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContains(
|
||||
"A.md",
|
||||
"brand new",
|
||||
"client 1"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const deleteRecreateSamePathTest: TestDefinition = {
|
||||
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: [
|
||||
{ type: "create", client: 0, path: "A.md", content: "version 1" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("A.md", "version 1");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
{ type: "create", client: 0, path: "A.md", content: "version 2" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("A.md", "version 2");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const deleteRecreatedPendingCreateWithStaleDeletingRecordTest: TestDefinition =
|
||||
{
|
||||
description:
|
||||
"A local delete for a recreated pending create must target the " +
|
||||
"new pending create, not an older same-path record whose server " +
|
||||
"delete has been acked but whose WebSocket delete receipt is " +
|
||||
"still paused.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "pause-websocket", client: 0 },
|
||||
{ type: "pause-server" },
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "binary-14.bin",
|
||||
content: "BINARY:first"
|
||||
},
|
||||
{ type: "sleep", ms: 100 },
|
||||
{ type: "delete", client: 0, path: "binary-14.bin" },
|
||||
{ type: "resume-server" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
{ type: "pause-server" },
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "binary-14.bin",
|
||||
content: "BINARY:second"
|
||||
},
|
||||
{ type: "sleep", ms: 100 },
|
||||
{ type: "delete", client: 0, path: "binary-14.bin" },
|
||||
{ type: "resume-server" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
{ type: "resume-websocket", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state.assertFileCount(0);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const deleteRenameConflictTest: TestDefinition = {
|
||||
description:
|
||||
"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: [
|
||||
{ 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: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileExists("A.md").assertFileExists("B.md");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
{ type: "rename", client: 1, oldPath: "A.md", newPath: "C.md" },
|
||||
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("B.md", "content-b");
|
||||
s.assertFileNotExists("A.md");
|
||||
s.ifFileExists("C.md", (inner) =>
|
||||
inner.assertContent("C.md", "content-a")
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const displacedFileNotMarkedDeletedTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 creates a new file at path B.md while client 1 renames " +
|
||||
"A.md to B.md. The remote download of B.md displaces client 1's " +
|
||||
"renamed file. The displaced document must not be permanently " +
|
||||
"marked as recently deleted, so it can still be synced.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "A.md", content: "content of A" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
{ type: "create", client: 0, path: "B.md", content: "content of B" },
|
||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "C.md" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(2)
|
||||
.assertContent("B.md", "content of B")
|
||||
.assertContent("C.md", "content of A");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const doubleOfflineCycleTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 goes through three offline-edit-reconnect cycles. " +
|
||||
"Each offline edit must propagate to client 1 after reconnection.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "initial"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("doc.md", "initial");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "first edit"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("doc.md", "first edit");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "second edit"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("doc.md", "second edit");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "third edit"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContent("doc.md", "third edit");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const idempotencyAfterServerPauseTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 creates a file, then the server is paused mid-response. " +
|
||||
"After the server resumes, both clients must converge to a single copy of the file with no duplicates.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "important data"
|
||||
},
|
||||
{ type: "pause-server" },
|
||||
|
||||
{ type: "resume-server" },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContent("doc.md", "important data");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const interruptedDeleteRetryTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 deletes a file, then the server is paused. " +
|
||||
"After the server resumes, both clients should have zero files.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "doc.md", content: "to be deleted" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "delete", client: 0, path: "doc.md" },
|
||||
|
||||
{ type: "pause-server" },
|
||||
|
||||
{ type: "resume-server" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(0);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const keyMigrationEventDropTest: TestDefinition = {
|
||||
description:
|
||||
"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 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "pause-server" },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "initial content"
|
||||
},
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "updated content"
|
||||
},
|
||||
|
||||
{ type: "resume-server" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContent("A.md", "updated content");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const localEditLostDuringCreateMergeTest: TestDefinition = {
|
||||
description:
|
||||
"Both clients create doc.md with different content while offline. " +
|
||||
"Client 0 also edits the file before syncing. After both connect, " +
|
||||
"the merged result should contain content from both clients.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 1, path: "doc.md", content: "from-client-1" },
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "from-client-0"
|
||||
},
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "local-edit-during-create"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync", client: 1 },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContains(
|
||||
"doc.md",
|
||||
"from-client-1",
|
||||
"local-edit-during-create"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const localRenameSurvivesRemoteRenameTest: TestDefinition = {
|
||||
description:
|
||||
"Drain processes a RemoteChange (remote rename for doc D) while a " +
|
||||
"LocalUpdate (user rename of D) is also queued behind it. " +
|
||||
"`processRemoteUpdate` moves the disk file and, because there is a " +
|
||||
"pending LocalUpdate, takes the else branch — but its setDocument " +
|
||||
"uses the stale `record.path` (= the user-rename target) instead of " +
|
||||
"the actualPath the file just moved to. The queued LocalUpdate then " +
|
||||
"reads from `record.path`, throws FileNotFoundError, and is " +
|
||||
"silently dropped. Setup pins the queue order: a sentinel " +
|
||||
"LocalUpdate keeps drain busy on a SIGSTOPped HTTP roundtrip while " +
|
||||
"we resume client 0's WebSocket (enqueues RemoteChange) and then " +
|
||||
"user-rename D (enqueues LocalUpdate after the RemoteChange). On " +
|
||||
"server resume the drain pops the sentinel, then RemoteChange, then " +
|
||||
"LocalUpdate — exactly the order that triggers the bug.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "create", client: 0, path: "doc.md", content: "v1\n" },
|
||||
{ type: "create", client: 0, path: "sentinel.md", content: "s\n" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Pause client 0's WebSocket so the upcoming remote rename buffers.
|
||||
{ type: "pause-websocket", client: 0 },
|
||||
|
||||
// Server applies remote rename of doc.md -> remote.md. Broadcast
|
||||
// is buffered on client 0's WebSocket.
|
||||
{ type: "rename", client: 1, oldPath: "doc.md", newPath: "remote.md" },
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Pause the server BEFORE arming the sentinel, so the sentinel's
|
||||
// HTTP request will buffer at the kernel and keep drain occupied.
|
||||
{ type: "pause-server" },
|
||||
|
||||
// Sentinel: a LocalUpdate on a *different* doc that drain pops
|
||||
// first. Its HTTP roundtrip stalls on SIGSTOP, freezing drain
|
||||
// until we resume the server. While drain is frozen we can grow
|
||||
// the queue with additional events whose order we control.
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "sentinel.md",
|
||||
content: "s\nedit\n"
|
||||
},
|
||||
|
||||
// Resume the WebSocket — buffered remote rename enqueues as a
|
||||
// RemoteChange. Drain is still stuck on the sentinel HTTP.
|
||||
{ type: "resume-websocket", client: 0 },
|
||||
|
||||
// User renames doc.md -> local.md on client 0. queue.enqueue
|
||||
// mutates the doc's record.path to "local.md" and pushes a
|
||||
// LocalUpdate(rename) onto the tail of the queue. Queue is now
|
||||
// [sentinel-update (in-flight), RemoteChange, LocalUpdate-rename].
|
||||
{ type: "rename", client: 0, oldPath: "doc.md", newPath: "local.md" },
|
||||
|
||||
// Resume the server. Drain pops sentinel-update (succeeds), then
|
||||
// RemoteChange. Pre-fix: processRemoteUpdate moves disk
|
||||
// local.md -> remote.md, takes the else branch, and
|
||||
// setDocument(record.path = "local.md", …) leaves record.path
|
||||
// stale. Drain pops the LocalUpdate-rename and reads from the
|
||||
// stale record.path, hits FileNotFoundError, silent skip.
|
||||
// Post-fix: when a local event is pending, we re-queue the
|
||||
// remote update without touching disk or record, so the local
|
||||
// rename drains first and both ends converge.
|
||||
{ type: "resume-server" },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state.assertFileCount(2);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const localUpdateSurvivesRemoteRenameTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 has a local content edit pending while a remote rename for " +
|
||||
"the same doc arrives over the WebSocket. The remote rename's internal " +
|
||||
"move relocates the disk file from the old path (where the user wrote) " +
|
||||
"to the new server path. Previously, the queued LocalUpdate's " +
|
||||
"`event.path` was left pointing at the now-vacated old path, so " +
|
||||
"`skipIfOversized`'s `getFileSize(event.path)` threw " +
|
||||
"`FileNotFoundError`, which `processEvent`'s catch silently swallowed " +
|
||||
"as 'Skipping sync event 'local-update' because the file no longer " +
|
||||
"exists' — and the user's edit was lost. The fix routes the size " +
|
||||
"check through `tracked.path` (the doc's current disk path), " +
|
||||
"matching the path `processLocalUpdate` itself reads from.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "doc.md", content: "v1\n" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Pause client 0's WebSocket so the upcoming remote rename buffers
|
||||
// there until we've already enqueued client 0's local content
|
||||
// edit. This guarantees the LocalUpdate sits in client 0's queue
|
||||
// when the rename's RemoteChange drains.
|
||||
{ type: "pause-websocket", client: 0 },
|
||||
|
||||
{
|
||||
type: "rename",
|
||||
client: 1,
|
||||
oldPath: "doc.md",
|
||||
newPath: "renamed.md"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Client 0 still believes the file is at `doc.md` (its WebSocket is
|
||||
// paused, so the rename hasn't reached it). The user edits content
|
||||
// at `doc.md`. This pushes a LocalUpdate(D, path=doc.md,
|
||||
// originalPath=doc.md, isUserRename=false) into client 0's queue.
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "v1\nclient 0 edit\n"
|
||||
},
|
||||
|
||||
// Resume the WebSocket. The buffered remote rename (server-broadcast)
|
||||
// drains. `processRemoteUpdate` does an internal `move(doc.md,
|
||||
// renamed.md)` and, because there's a pending LocalUpdate for D,
|
||||
// takes the else branch (re-enqueue v_K, setDocument(renamed.md, …)).
|
||||
// Then drain reaches the LocalUpdate. Pre-fix: skipped silently.
|
||||
// Post-fix: PUTs the user's content to the doc (at its new path,
|
||||
// since this is a content-only edit, not a user rename).
|
||||
{ type: "resume-websocket", client: 0 },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state.assertFileCount(1);
|
||||
state.assertFileExists("renamed.md");
|
||||
state.assertContent("renamed.md", "v1\nclient 0 edit\n");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const mcCrossCreateRenameSameTargetTest: TestDefinition = {
|
||||
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: [
|
||||
{ type: "create", client: 0, path: "X.md", content: "content-x" },
|
||||
{ type: "create", client: 1, path: "Y.md", content: "content-y" },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileExists("X.md").assertFileExists("Y.md");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
{ type: "rename", client: 0, oldPath: "X.md", newPath: "Z.md" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
{ type: "rename", client: 1, oldPath: "Y.md", newPath: "Z.md" },
|
||||
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(2)
|
||||
.assertFileNotExists("X.md")
|
||||
.assertFileNotExists("Y.md")
|
||||
.assertFileExists("Z.md")
|
||||
.assertAnyFileContains("content-x", "content-y");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const mcDeleteThenOfflineRenameTest: TestDefinition = {
|
||||
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: [
|
||||
{ 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: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
|
||||
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("C.md", "unrelated").assertFileNotExists(
|
||||
"A.md"
|
||||
);
|
||||
s.ifFileExists("B.md", (inner) =>
|
||||
inner.assertContent("B.md", "original")
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const mcMultiDeleteOfflineRenameTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 creates 5 files. Client 1 deletes 2 while Client 0 (offline) " +
|
||||
"renames one of the deleted files. Both must converge.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "file-1.md", content: "content-1" },
|
||||
{ type: "create", client: 0, path: "file-2.md", content: "content-2" },
|
||||
{ type: "create", client: 0, path: "file-3.md", content: "content-3" },
|
||||
{ type: "create", client: 0, path: "file-4.md", content: "content-4" },
|
||||
{ type: "create", client: 0, path: "file-5.md", content: "content-5" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
|
||||
{ type: "delete", client: 1, path: "file-2.md" },
|
||||
{ type: "delete", client: 1, path: "file-4.md" },
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "file-2.md",
|
||||
newPath: "renamed.md"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
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", (inner) =>
|
||||
inner.assertContent("renamed.md", "content-2")
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const mcThreeClientRenameOfflineUpdateTest: TestDefinition = {
|
||||
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: [
|
||||
{ type: "create", client: 0, path: "A.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "enable-sync", client: 2 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 2 },
|
||||
|
||||
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
|
||||
{ type: "sync", client: 1 },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 2,
|
||||
path: "A.md",
|
||||
content: "updated-by-client-2"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 2 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1)
|
||||
.assertFileNotExists("A.md")
|
||||
.assertContains("B.md", "updated-by-client-2");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const mergingUpdateResponseSurvivesUserRenameTest: TestDefinition = {
|
||||
description:
|
||||
"Client 1 sends a content update with a stale `parent_version_id` " +
|
||||
"(its WebSocket is paused, so it hasn't seen Client 0's intervening " +
|
||||
"edit). The server merges and replies with `MergingUpdate` carrying " +
|
||||
"the merged text. Before the response lands, the user renames the " +
|
||||
"doc on Client 1, vacating the disk path the in-flight " +
|
||||
"`processLocalUpdate` captured. Pre-fix: " +
|
||||
"`handleMaybeMergingResponse`'s `operations.write(diskPath, …)` " +
|
||||
"hits the `we wont recreate it` early-return inside `write`, " +
|
||||
"silently dropping the server-merged content — Client 0's edit is " +
|
||||
"lost on Client 1's disk, and Client 1's next local-update PUT " +
|
||||
"(rebased on the now-untracked merged version) deletes Client 0's " +
|
||||
"edit on the server too. Post-fix: the response is written to the " +
|
||||
"doc's current tracked disk path, preserving both edits.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "create", client: 0, path: "doc.md", content: "0\n" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Stop Client 1 from seeing Client 0's next edit, so its next
|
||||
// outbound PUT carries a stale `parent_version_id` and the server
|
||||
// is forced to merge.
|
||||
{ type: "pause-websocket", client: 1 },
|
||||
|
||||
// Server now holds v_b = "0\nA\n". Client 1's tracked parent
|
||||
// version stays at v_a = "0\n".
|
||||
{ type: "update", client: 0, path: "doc.md", content: "0\nA\n" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
// Pause the server. Subsequent HTTP PUTs from Client 1 buffer at
|
||||
// the OS layer until resume. This guarantees the merge response
|
||||
// for Client 1's update is still in flight when the rename below
|
||||
// mutates `queue.documents`.
|
||||
{ type: "pause-server" },
|
||||
|
||||
// Client 1 edits doc.md with "B". The drain pops the LocalUpdate,
|
||||
// captures `diskPath = "doc.md"`, reads the file, and sends the
|
||||
// HTTP PUT — which buffers because the server is SIGSTOPped.
|
||||
{ type: "update", client: 1, path: "doc.md", content: "0\nB\n" },
|
||||
|
||||
// User renames the file while the previous PUT is still in flight.
|
||||
// `queue.enqueue`'s rename branch updates `documents` to point at
|
||||
// `renamed.md` synchronously, but `processLocalUpdate`'s captured
|
||||
// `diskPath` ("doc.md") is a local — it can't be retargeted.
|
||||
{ type: "rename", client: 1, oldPath: "doc.md", newPath: "renamed.md" },
|
||||
|
||||
// Resume the server. It reconciles parent=v_a, latest=v_b,
|
||||
// new="0\nB\n" → v_c with both edits, replies `MergingUpdate`.
|
||||
// Pre-fix: write("doc.md", …) sees no file at that path
|
||||
// (renamed.md now holds the data) and bails out without ever
|
||||
// writing the merged bytes. Post-fix: the merged bytes land at
|
||||
// the tracked path (renamed.md).
|
||||
{ type: "resume-server" },
|
||||
{ type: "resume-websocket", client: 1 },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state.assertFileCount(1);
|
||||
state.assertFileExists("renamed.md");
|
||||
state.assertFileNotExists("doc.md");
|
||||
// Both edits survive: Client 0's "A" and Client 1's "B".
|
||||
// The reconcile may interleave them either way; assert
|
||||
// both tokens are present in the converged content.
|
||||
state.assertContains("renamed.md", "A", "B");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const migrateKeyPreservesExistingTest: TestDefinition = {
|
||||
description:
|
||||
"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 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "pause-server" },
|
||||
|
||||
{ type: "create", client: 0, path: "A.md", content: "initial" },
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "updated by client 0"
|
||||
},
|
||||
|
||||
{ type: "resume-server" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContains(
|
||||
"A.md",
|
||||
"updated by client 0"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const moveAndConcurrentRemoteUpdateTest: TestDefinition = {
|
||||
description:
|
||||
"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: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "original content"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "A.md",
|
||||
content: "updated by client 1"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1)
|
||||
.assertFileNotExists("A.md")
|
||||
.assertContains("B.md", "updated by client 1");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const movePreservesRemoteUpdateTest: TestDefinition = {
|
||||
description:
|
||||
"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: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "line 1\nline 2"
|
||||
},
|
||||
{ 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: "doc.md", newPath: "renamed.md" },
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "doc.md",
|
||||
content: "line 1\nclient 1 edit\nline 2"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1);
|
||||
const [content] = Array.from(s.files.values());
|
||||
if (!content.includes("client 1 edit")) {
|
||||
throw new Error(
|
||||
`Expected merged content to include "client 1 edit", got: "${content}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const moveRemoteUpdateRevertsRenameTest: TestDefinition = {
|
||||
description:
|
||||
"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: [
|
||||
{ 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: "update",
|
||||
client: 1,
|
||||
path: "doc.md",
|
||||
content: "updated by client 1"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "rename", client: 0, oldPath: "doc.md", newPath: "renamed.md" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContent(
|
||||
"renamed.md",
|
||||
"updated by client 1"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const moveThenDeleteStalePathTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 renames A.md to B.md and immediately deletes B.md. " +
|
||||
"Both clients should end up with zero files.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "content to delete"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
||||
{ type: "delete", client: 0, path: "B.md" },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(0)
|
||||
.assertFileNotExists("A.md")
|
||||
.assertFileNotExists("B.md");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const multiFileOperationsTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 deletes A.md while client 1 is offline. Client 1 updates B.md and renames A.md to D.md offline. " +
|
||||
"After client 1 reconnects, both clients must converge with B.md updated and C.md intact.",
|
||||
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: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "B.md",
|
||||
content: "updated by client 1"
|
||||
},
|
||||
{ type: "rename", client: 1, oldPath: "A.md", newPath: "D.md" },
|
||||
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContains("B.md", "updated")
|
||||
.assertFileExists("C.md")
|
||||
.assertFileNotExists("A.md");
|
||||
s.ifFileExists("D.md", (inner) =>
|
||||
inner.assertContent("D.md", "content-a")
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const offlineConcurrentRenamesTest: TestDefinition = {
|
||||
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. " +
|
||||
"Both reconnect. The system must converge -- both clients should " +
|
||||
"agree on the final state and the content must not be lost.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "A.md", content: "shared-content" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("A.md", "shared-content");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "A.md",
|
||||
newPath: "B.md"
|
||||
},
|
||||
|
||||
{
|
||||
type: "rename",
|
||||
client: 1,
|
||||
oldPath: "A.md",
|
||||
newPath: "C.md"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileNotExists("A.md")
|
||||
.assertFileCount(1)
|
||||
.assertAnyFileContains("shared-content");
|
||||
s.ifFileExists("B.md", (inner) =>
|
||||
inner.assertContent("B.md", "shared-content")
|
||||
);
|
||||
s.ifFileExists("C.md", (inner) =>
|
||||
inner.assertContent("C.md", "shared-content")
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const offlineCreateSamePathMergeableTest: TestDefinition = {
|
||||
description:
|
||||
"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: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "notes.md",
|
||||
content: "alpha wrote this line"
|
||||
},
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "notes.md",
|
||||
content: "beta wrote this different line"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1)
|
||||
.assertFileExists("notes.md")
|
||||
.assertContains(
|
||||
"notes.md",
|
||||
"alpha wrote this line",
|
||||
"beta wrote this different line"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const offlineDeleteRemoteRenameTest: TestDefinition = {
|
||||
description:
|
||||
"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: [
|
||||
{ type: "create", client: 0, path: "A.md", content: "content-a" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
|
||||
{
|
||||
type: "rename",
|
||||
client: 1,
|
||||
oldPath: "A.md",
|
||||
newPath: "A_renamed.md"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileNotExists("A.md").assertFileNotExists(
|
||||
"A_renamed.md"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const offlineDeleteVsRemoteUpdateTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 deletes A.md offline while client 1 updates it. Both clients must converge.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "original content"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("A.md", "original content");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "A.md",
|
||||
content: "important update by client 1"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(0);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const offlineEditRemoteRenameTest: TestDefinition = {
|
||||
description:
|
||||
"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: [
|
||||
{ type: "create", client: 0, path: "A.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("A.md", "original");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "edited by client 0"
|
||||
},
|
||||
|
||||
{
|
||||
type: "rename",
|
||||
client: 1,
|
||||
oldPath: "A.md",
|
||||
newPath: "B.md"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileNotExists("A.md")
|
||||
.assertFileCount(1)
|
||||
.assertContains("B.md", "edited by client 0");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const offlineEditThenMoveSameContentTest: TestDefinition = {
|
||||
description:
|
||||
"A file is renamed and edited to match a deleted file's content. Both clients must converge despite the ambiguity.",
|
||||
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: 0 },
|
||||
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
|
||||
{ type: "rename", client: 0, oldPath: "B.md", newPath: "C.md" },
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "C.md",
|
||||
content: "content A"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileNotExists("A.md")
|
||||
.assertFileNotExists("B.md")
|
||||
.assertContent("C.md", "content A")
|
||||
.assertFileCount(1);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const offlineMixedOperationsTest: TestDefinition = {
|
||||
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: [
|
||||
{ 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" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("file1.md", "content-1")
|
||||
.assertContent("file2.md", "content-2")
|
||||
.assertContent("file3.md", "content-3");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
|
||||
{ type: "delete", client: 0, path: "file1.md" },
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "file2.md",
|
||||
newPath: "moved.md"
|
||||
},
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "file3.md",
|
||||
content: "updated-content-3"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileNotExists("file1.md")
|
||||
.assertFileNotExists("file2.md")
|
||||
.assertContent("moved.md", "content-2")
|
||||
.assertContent("file3.md", "updated-content-3")
|
||||
.assertFileCount(2);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const offlineMoveThenRemoteDeleteTest: TestDefinition = {
|
||||
description:
|
||||
"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: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "content to delete"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
||||
|
||||
{ type: "delete", client: 1, path: "A.md" },
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(0);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const offlineMultipleEditsTest: TestDefinition = {
|
||||
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: [
|
||||
{ type: "create", client: 0, path: "doc.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("doc.md", "original");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
|
||||
{ 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" },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContent("doc.md", "edit-5-final");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const offlineRenameAndEditTest: TestDefinition = {
|
||||
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: [
|
||||
{ type: "create", client: 0, path: "A.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("A.md", "original");
|
||||
}
|
||||
},
|
||||
|
||||
{ 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"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileNotExists("A.md")
|
||||
.assertFileCount(1)
|
||||
.assertContent("B.md", "edited after rename");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const offlineRenameRemoteCreateOldPathTest: TestDefinition = {
|
||||
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: [
|
||||
{ type: "create", client: 0, path: "X.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("X.md", "original");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "X.md",
|
||||
newPath: "Y.md"
|
||||
},
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "X.md",
|
||||
content: "updated-by-client-1"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContains(
|
||||
"Y.md",
|
||||
"updated-by-client-1"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const offlineUpdateBothThenDeleteOneTest: TestDefinition = {
|
||||
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 " +
|
||||
"reconnects, A.md should have the update and B.md should be " +
|
||||
"consistently resolved (delete wins).",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "A original"
|
||||
},
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "B.md",
|
||||
content: "B original"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("A.md", "A original").assertContent(
|
||||
"B.md",
|
||||
"B original"
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "A updated by client 0"
|
||||
},
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "B.md",
|
||||
content: "B updated by client 0"
|
||||
},
|
||||
|
||||
{ type: "delete", client: 0, path: "B.md" },
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "B.md",
|
||||
content: "B updated by client 1"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent(
|
||||
"A.md",
|
||||
"A updated by client 0"
|
||||
).assertFileNotExists("B.md");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const onlineBothCreateSamePathDeconflictTest: TestDefinition = {
|
||||
description:
|
||||
"Both clients create a file at the same path while online. " +
|
||||
"One client's create gets deconflicted by the server. " +
|
||||
"Both files must exist on both clients after convergence.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "pause-websocket", client: 1 },
|
||||
{ type: "create", client: 0, path: "A.md", content: " from-client-0 " },
|
||||
{ type: "update", client: 0, path: "A.md", content: " updated-by-0 " },
|
||||
{ type: "sync" },
|
||||
|
||||
{ type: "create", client: 1, path: "A.md", content: " from-client-1 " },
|
||||
{ type: "resume-websocket", client: 1 },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(1)
|
||||
.assertContains("A.md", "updated-by-0", "from-client-1 ");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
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: AssertableState): void => {
|
||||
state.assertFileCount(0);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const onlineCreateUpdateWhileOtherCreatesSamePathTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 creates a binary file and updates it while client 1 also " +
|
||||
"creates a binary file at the same path. Both clients are online. " +
|
||||
"Both clients must end up with the same file set.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
|
||||
{ type: "pause-websocket", client: 1 },
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "data.bin",
|
||||
content: "BINARY:content-v1"
|
||||
},
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "data.bin",
|
||||
content: "BINARY:content-v2"
|
||||
},
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "data.bin",
|
||||
content: "BINARY:other-content"
|
||||
},
|
||||
{ type: "resume-websocket", client: 1 },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(2)
|
||||
.assertNoFileContains("content-v1")
|
||||
.assertAnyFileContains("content-v2")
|
||||
.assertAnyFileContains("other-content");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
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: AssertableState): void => {
|
||||
s.assertContent("A.md", "round 3");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
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: AssertableState): void => {
|
||||
state.assertFileCount(0);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const overlappingEditsSameSectionTest: TestDefinition = {
|
||||
description:
|
||||
"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: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "# Title\n\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: "# Title\nalpha addition\n\nfooter"
|
||||
},
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "doc.md",
|
||||
content: "# Title\n\nbeta addition\nfooter"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContains(
|
||||
"doc.md",
|
||||
"# Title",
|
||||
"alpha addition",
|
||||
"beta addition",
|
||||
"footer"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const queueResetLosesCoalescedLocalEditTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 goes offline, both clients edit doc.md concurrently, " +
|
||||
"then client 0 reconnects. Both edits must be preserved.",
|
||||
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: "update", client: 1, path: "doc.md", content: "alpha bravo" },
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
{ type: "update", client: 0, path: "doc.md", content: "charlie delta" },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContains(
|
||||
"doc.md",
|
||||
"alpha",
|
||||
"charlie"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const queuedCreateDeleteDoesNotHijackReusedPathTest: TestDefinition = {
|
||||
description:
|
||||
"A create/delete pair that is still queued behind another request " +
|
||||
"must collapse locally. It must not later read a different file " +
|
||||
"that reused the same path before the queued create drained.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "pause-server" },
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "blocker.bin",
|
||||
content: "BINARY:blocker"
|
||||
},
|
||||
{ type: "sleep", ms: 100 },
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "target.bin",
|
||||
content: "BINARY:old"
|
||||
},
|
||||
{ type: "delete", client: 1, path: "target.bin" },
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "source.bin",
|
||||
content: "BINARY:new"
|
||||
},
|
||||
{
|
||||
type: "rename",
|
||||
client: 1,
|
||||
oldPath: "source.bin",
|
||||
newPath: "target.bin"
|
||||
},
|
||||
{ type: "resume-server" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(2)
|
||||
.assertContent("blocker.bin", "BINARY:blocker")
|
||||
.assertContent("target.bin", "BINARY:new")
|
||||
.assertFileNotExists("source.bin");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const rapidCreateUpdateDeleteCycleTest: TestDefinition = {
|
||||
description:
|
||||
"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 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "pause-server" },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "cycle.md",
|
||||
content: "version 1"
|
||||
},
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "cycle.md",
|
||||
content: "version 2"
|
||||
},
|
||||
{ type: "delete", client: 0, path: "cycle.md" },
|
||||
|
||||
{ type: "resume-server" },
|
||||
{ type: "sync" },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "cycle.md",
|
||||
content: "final creation"
|
||||
},
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContent(
|
||||
"cycle.md",
|
||||
"final creation"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
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: AssertableState): void => {
|
||||
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}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const rapidUpdatesAfterMergeTest: TestDefinition = {
|
||||
description:
|
||||
"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: [
|
||||
{ type: "create", client: 0, path: "doc.md", content: "from client 0" },
|
||||
{ type: "create", client: 1, path: "doc.md", content: "from client 1" },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
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 },
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "update 3"
|
||||
},
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContains("doc.md", "update 3");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const recentlyDeletedClearedOnReconnectTest: TestDefinition = {
|
||||
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: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
|
||||
{ type: "create", client: 0, path: "doc.md", content: "original" },
|
||||
{ type: "sync" },
|
||||
|
||||
{ type: "delete", client: 0, path: "doc.md" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "doc.md",
|
||||
content: "new content from client 1"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync", client: 1 },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContent(
|
||||
"doc.md",
|
||||
"new content from client 1"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const remoteQuickWriteRenameBeforeRecordTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 receives a remote create and the user renames the new " +
|
||||
"file immediately after the syncer writes it. The watcher event " +
|
||||
"must bind to the new document instead of being dropped before " +
|
||||
"the remote-create handler persists the record.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
|
||||
{
|
||||
type: "rename-next-write",
|
||||
client: 0,
|
||||
oldPath: "doc.md",
|
||||
newPath: "renamed.md"
|
||||
},
|
||||
|
||||
{ type: "create", client: 1, path: "doc.md", content: "v1\n" },
|
||||
{ type: "sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1);
|
||||
s.assertFileExists("renamed.md");
|
||||
s.assertFileNotExists("doc.md");
|
||||
s.assertContent("renamed.md", "v1\n");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const remoteRenameCollidesWithPendingLocalCreateTest: TestDefinition = {
|
||||
// TODO(refactor): the failure mode described below is the
|
||||
// pre-refactor "deflect-to-conflict-uuid" path that no longer
|
||||
// exists. Under the new model the wire loop never moves files for
|
||||
// path placement, so the remote rename can't deflect anywhere; the
|
||||
// reconciler waits for the slot to free. Convergence assertion is
|
||||
// still valid (no conflict-uuid stashes, both files present, the
|
||||
// local create lands at a server-deconflicted sibling).
|
||||
description:
|
||||
"Client 0 has doc D tracked at `original.md`. Client 1 owns doc E " +
|
||||
"and renames it to `target.md` server-side. Before client 0's " +
|
||||
"drain processes the WS broadcast for E, the user creates a new " +
|
||||
"local file `target.md` (a different doc, untracked). When the " +
|
||||
"buffered RemoteChange for E drains, the engine has to reconcile " +
|
||||
"doc E onto `target.md` even though the slot is held by client " +
|
||||
"0's pending LocalCreate. Convergence requires both clients end " +
|
||||
"up with [target.md = E] and the local create lands at a " +
|
||||
"server-deconflicted sibling (e.g. `target (1).md`).",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
|
||||
{ type: "create", client: 1, path: "original.md", content: "v1\n" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Pause client 0's WS so the upcoming remote rename buffers and
|
||||
// we can stage a colliding local create before the rename
|
||||
// drains on client 0.
|
||||
{ type: "pause-websocket", client: 0 },
|
||||
|
||||
// Client 1 renames the doc. Server commits, broadcasts to
|
||||
// client 0 (buffered).
|
||||
{
|
||||
type: "rename",
|
||||
client: 1,
|
||||
oldPath: "original.md",
|
||||
newPath: "target.md"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Client 0 still believes the doc is at `original.md`. The user
|
||||
// creates a NEW file at `target.md` (an unrelated untracked
|
||||
// doc). Disk on client 0 now has both `original.md` (the
|
||||
// tracked doc) and `target.md` (the new untracked file).
|
||||
{ type: "create", client: 0, path: "target.md", content: "extra\n" },
|
||||
|
||||
// Resume client 0's WS. The buffered RemoteChange drains.
|
||||
// The reconciler must converge without ever leaving a
|
||||
// conflict-uuid stash on disk.
|
||||
{ type: "resume-websocket", client: 0 },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state.assertFileCount(2);
|
||||
for (const path of state.files.keys()) {
|
||||
if (path.startsWith("conflict-")) {
|
||||
throw new Error(
|
||||
`Unexpected conflict-uuid stash on a converged client: ${path}`
|
||||
);
|
||||
}
|
||||
}
|
||||
state.assertFileExists("target.md");
|
||||
state.assertContent("target.md", "v1\n");
|
||||
// The local create gets server-deconflicted to a
|
||||
// sibling path (e.g. `target (1).md`).
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const remoteUpdateResurrectsDeletedDocTest: TestDefinition = {
|
||||
description:
|
||||
"Client 1 updates, deletes, and recreates P (with a new docId D2). " +
|
||||
"While the buffered remote events are being processed by client 0, " +
|
||||
"client 0 also makes a local edit to P. The local edit lands in the " +
|
||||
"queue while v17 is mid-process, sending v17 down processRemoteUpdate's " +
|
||||
"re-enqueue branch. The deferred v17 must NOT later resurrect D1 as a " +
|
||||
"conflict-… file at P after the delete and the D2 create have drained.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
|
||||
{ type: "create", client: 1, path: "P.md", content: "v8 content\n" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "pause-websocket", client: 0 },
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "P.md",
|
||||
content: "v17 content from client 1\n"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
{ type: "delete", client: 1, path: "P.md" },
|
||||
{ type: "sync", client: 1 },
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "P.md",
|
||||
content: "v21 content (D2)\n"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
{ type: "resume-websocket", client: 0 },
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "P.md",
|
||||
content: "local edit by client 0\n"
|
||||
},
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(1)
|
||||
.assertContent("P.md", "v21 content (D2)\n");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const remoteUpdateSurvivesUserRenameTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 updates a tracked doc; while Client 1 is processing the " +
|
||||
"broadcast and parked on the GET for the new version's content, the " +
|
||||
"user renames the doc on Client 1. Pre-fix: `processRemoteUpdate` " +
|
||||
"captures `actualPath` before the await and, after the GET returns, " +
|
||||
"calls `write(actualPath, …)` (no-op — file was renamed away), " +
|
||||
"`updateCache(actualPath, …)`, and `setDocument(actualPath, …)`. " +
|
||||
"`setDocument` mutates the same record in place so its `path` is " +
|
||||
"yanked from the user's renamed slot back to the pre-rename path, " +
|
||||
"wiping the rename out of the queue's documents map. The queued " +
|
||||
"`LocalUpdate` then reads from the now-stale `record.path`, hits " +
|
||||
"`FileNotFoundError`, and is silently dropped — the user's rename " +
|
||||
"never reaches the server. Post-fix: the handler defers when a " +
|
||||
"local event landed mid-await, so the rename drains first and " +
|
||||
"the deferred remote update is folded into the broadcast that " +
|
||||
"follows the rename round-trip.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "doc.md", content: "v1\n" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Buffer Client 1's incoming broadcasts so it doesn't see
|
||||
// Client 0's update until we've paused the server.
|
||||
{ type: "pause-websocket", client: 1 },
|
||||
|
||||
// Server now holds v=2 of doc.md.
|
||||
{ type: "update", client: 0, path: "doc.md", content: "v2\n" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
// Pause the server. Client 1's upcoming GET for the new version
|
||||
// content blocks at the OS layer until resume.
|
||||
{ type: "pause-server" },
|
||||
|
||||
// Release the buffered broadcast. Client 1's drain enters
|
||||
// `processRemoteUpdate`, captures `actualPath`, fires the GET,
|
||||
// and parks awaiting the response.
|
||||
{ type: "resume-websocket", client: 1 },
|
||||
|
||||
// Yield long enough for the drain to traverse all microtask
|
||||
// hops between the WS handler and the GET, so the HTTP request
|
||||
// is queued at the (paused) server before the rename runs.
|
||||
// Without this yield the rename would be enqueued before
|
||||
// `processRemoteUpdate`'s entry-time `hasPendingLocalEvents`
|
||||
// check and the early-defer branch would mask the bug.
|
||||
{ type: "sleep", ms: 50 },
|
||||
|
||||
// While the GET is in flight the user renames the doc. The queue
|
||||
// mutates `record.path` to "renamed.md" in place and pushes a
|
||||
// LocalUpdate carrying the rename target.
|
||||
{
|
||||
type: "rename",
|
||||
client: 1,
|
||||
oldPath: "doc.md",
|
||||
newPath: "renamed.md"
|
||||
},
|
||||
|
||||
// Resume the server. The GET response unblocks
|
||||
// `processRemoteUpdate`. With the fix in place it sees the
|
||||
// queued LocalUpdate and defers; without the fix it walks past
|
||||
// the rename and clobbers the documents map, dropping the
|
||||
// pending LocalUpdate's read on the way back through.
|
||||
{ type: "resume-server" },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1);
|
||||
s.assertFileExists("renamed.md");
|
||||
s.assertFileNotExists("doc.md");
|
||||
// Both edits survive: the user's rename and Client 0's
|
||||
// content update at v=2.
|
||||
s.assertContent("renamed.md", "v2\n");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const renameChainDuringPendingCreateTest: TestDefinition = {
|
||||
description:
|
||||
"User creates a doc, then renames it twice while the LocalCreate's " +
|
||||
"HTTP roundtrip is still in flight (server paused). Each rename " +
|
||||
"pushes a LocalUpdate whose `documentId` is the create's Promise " +
|
||||
"(see `pendingDocumentId` in `SyncEventQueue.enqueue`). After the " +
|
||||
"create resolves, the first rename drains successfully and " +
|
||||
"`setDocument` walks `events[]` to retarget queued LocalUpdates' " +
|
||||
"`event.path` to the new disk location — but the comparison " +
|
||||
"`e.documentId === record.documentId` mismatches the still-Promise " +
|
||||
"references, so the second rename's `event.path` stays at the " +
|
||||
"vacated previous slot. On the next drain step `skipIfOversized`'s " +
|
||||
"`getFileSize(event.path)` throws FileNotFoundError, which " +
|
||||
"`processEvent` swallows as 'Skipping sync event ... because the " +
|
||||
"file no longer exists' — losing the user's final rename. " +
|
||||
"Post-fix: `resolveCreate` (and the displacement-merge branch in " +
|
||||
"`processCreate`) swap the Promise references for the resolved id " +
|
||||
"before `setDocument` runs, so retarget works.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Pause the server so client 0's create stalls on the HTTP PUT
|
||||
// while we queue rename events behind it.
|
||||
{ type: "pause-server" },
|
||||
|
||||
{ type: "create", client: 0, path: "first.md", content: "v1\n" },
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "first.md",
|
||||
newPath: "second.md"
|
||||
},
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "second.md",
|
||||
newPath: "third.md"
|
||||
},
|
||||
|
||||
// Resume — drain pops LocalCreate (now resolves), then the two
|
||||
// queued LocalUpdates. Pre-fix: only the first rename's
|
||||
// file-system effect lands; the second is silently dropped.
|
||||
// The server ends up with the doc at second.md, leaving
|
||||
// client 0's local third.md untracked / out-of-sync.
|
||||
{ type: "resume-server" },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state.assertFileCount(1);
|
||||
state.assertFileExists("third.md");
|
||||
state.assertContent("third.md", "v1\n");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const renameChainThenDeleteTest: TestDefinition = {
|
||||
description:
|
||||
"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: [
|
||||
{ type: "create", client: 0, path: "X.md", content: "chain-content" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("X.md", "chain-content");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "X.md",
|
||||
newPath: "Y.md"
|
||||
},
|
||||
{ type: "sync", client: 0 },
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "Y.md",
|
||||
newPath: "Z.md"
|
||||
},
|
||||
{ type: "sync", client: 0 },
|
||||
{ type: "delete", client: 0, path: "Z.md" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(0);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
34
frontend/deterministic-tests/src/tests/rename-chain.test.ts
Normal file
34
frontend/deterministic-tests/src/tests/rename-chain.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const renameChainTest: TestDefinition = {
|
||||
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 " +
|
||||
"with the original content. Intermediate paths should never appear.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 1 },
|
||||
|
||||
{
|
||||
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" },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileNotExists("A.md")
|
||||
.assertFileNotExists("B.md")
|
||||
.assertContent("C.md", "important content");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const renameCircularTest: TestDefinition = {
|
||||
description:
|
||||
"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, all three contents should exist across three files but paths may be deconflicted.",
|
||||
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: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("A.md", "content-a")
|
||||
.assertContent("B.md", "content-b")
|
||||
.assertContent("C.md", "content-c");
|
||||
}
|
||||
},
|
||||
|
||||
{ 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" },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileNotExists("temp-a.md")
|
||||
.assertFileCount(3)
|
||||
.assertAnyFileContains("content-c")
|
||||
.assertAnyFileContains("content-a")
|
||||
.assertAnyFileContains("content-b");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const renameCreateConflictTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 creates A.md and syncs. Client 1 renames A.md to B.md and syncs. Client 0 (offline) creates B.md with the same content. After reconnecting, both clients should converge with only B.md.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "create", client: 0, path: "A.md", content: "hi" },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("A.md", "hi");
|
||||
}
|
||||
},
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
|
||||
{ type: "sync", client: 1 },
|
||||
{ type: "create", client: 0, path: "B.md", content: "hi" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(2)
|
||||
.assertContent("B.md", "hi")
|
||||
.assertContent("B (1).md", "hi");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const renameOverwritesPendingCreateThenDeleteTest: TestDefinition = {
|
||||
description:
|
||||
"A pending local create at a path must not mask a synced document renamed onto that path; later rename/delete events still belong to the synced document.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "tracked.bin",
|
||||
content: "BINARY:tracked"
|
||||
},
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "pause-server" },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "pending.bin",
|
||||
content: "BINARY:pending"
|
||||
},
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "tracked.bin",
|
||||
newPath: "pending.bin"
|
||||
},
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "pending.bin",
|
||||
newPath: "final.bin"
|
||||
},
|
||||
{ type: "delete", client: 0, path: "final.bin" },
|
||||
|
||||
{ type: "resume-server" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state.assertFileCount(0);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const renamePendingCreateBeforeResponseTest: TestDefinition = {
|
||||
description:
|
||||
"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: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
|
||||
{ type: "pause-server" },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "original-content"
|
||||
},
|
||||
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "doc.md",
|
||||
newPath: "renamed.md"
|
||||
},
|
||||
|
||||
{ type: "resume-server" },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContent(
|
||||
"renamed.md",
|
||||
"original-content"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const renamePendingCreateOntoPendingDeletePathTest: TestDefinition = {
|
||||
description:
|
||||
"A pending create is renamed onto a path whose old server document " +
|
||||
"has a queued delete. The delete must reach the server before the " +
|
||||
"new create so the new generation is not merged into the soon-to-be " +
|
||||
"deleted document.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "file-17.md",
|
||||
content: "old\n"
|
||||
},
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "pause-server" },
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "blocker.md",
|
||||
content: "blocker\n"
|
||||
},
|
||||
{ type: "sleep", ms: 100 },
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "file-23.md",
|
||||
content: "new\n"
|
||||
},
|
||||
{ type: "delete", client: 1, path: "file-17.md" },
|
||||
{
|
||||
type: "rename",
|
||||
client: 1,
|
||||
oldPath: "file-23.md",
|
||||
newPath: "file-17.md"
|
||||
},
|
||||
{ type: "resume-server" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(2)
|
||||
.assertContent("blocker.md", "blocker\n")
|
||||
.assertContent("file-17.md", "new\n")
|
||||
.assertFileNotExists("file-23.md");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const renameRoundtripTest: TestDefinition = {
|
||||
description:
|
||||
"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: [
|
||||
{ type: "create", client: 0, path: "A.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("A.md", "original");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileNotExists("A.md").assertContent("B.md", "original");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileNotExists("B.md").assertContent("A.md", "original");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
44
frontend/deterministic-tests/src/tests/rename-swap.test.ts
Normal file
44
frontend/deterministic-tests/src/tests/rename-swap.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const renameSwapTest: TestDefinition = {
|
||||
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. " +
|
||||
"When Client 0 reconnects, both contents should exist across two files.",
|
||||
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: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("A.md", "content-a").assertContent(
|
||||
"B.md",
|
||||
"content-b"
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
{ 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" },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileNotExists("temp.md")
|
||||
.assertFileCount(2)
|
||||
.assertAnyFileContains("content-b")
|
||||
.assertAnyFileContains("content-a");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const renameToPathOfUnconfirmedDeleteTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 deletes A.md then renames B.md to A.md. After syncing, " +
|
||||
"B's content should exist and the old A.md content should be gone. " +
|
||||
"The server may deconflict the path if the delete and move arrive " +
|
||||
"in the same transaction.",
|
||||
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: "sync" },
|
||||
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileNotExists("B.md").assertContains(
|
||||
"A.md",
|
||||
"content B"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const renameToPendingPathFallbackTest: TestDefinition = {
|
||||
description:
|
||||
"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: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "B.md",
|
||||
content: "tracked B content"
|
||||
},
|
||||
{ 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: "pending A content"
|
||||
},
|
||||
|
||||
{ type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileNotExists("B.md").assertContains(
|
||||
"A.md",
|
||||
"tracked B content"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const renameUpdateConflictTest: TestDefinition = {
|
||||
description:
|
||||
"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: [
|
||||
{ type: "create", client: 0, path: "A.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("A.md", "original");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "A.md",
|
||||
content: "updated by client 1"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileNotExists("A.md").assertContains("B.md", "updated");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const renamedPendingCreateReusedPathThenDeleteTest: TestDefinition = {
|
||||
description:
|
||||
"A queued create is renamed away from file-59.md, a newer local " +
|
||||
"file reuses file-59.md before the queued create drains, and the " +
|
||||
"renamed-away generation is deleted. The delete must not erase or " +
|
||||
"orphan the newer file-59.md generation.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "pause-server" },
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "blocker.md",
|
||||
content: "blocker\n"
|
||||
},
|
||||
{ type: "sleep", ms: 100 },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "file-59.md",
|
||||
content: "old\n"
|
||||
},
|
||||
{
|
||||
type: "rename",
|
||||
client: 1,
|
||||
oldPath: "file-59.md",
|
||||
newPath: "file-33.md"
|
||||
},
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "file-59.md",
|
||||
content: "new\n"
|
||||
},
|
||||
|
||||
{
|
||||
type: "resume-server-until-history-then-pause",
|
||||
client: 1,
|
||||
syncType: "CREATE",
|
||||
path: "file-33.md"
|
||||
},
|
||||
{ type: "delete", client: 1, path: "file-33.md" },
|
||||
{ type: "resume-server" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(2)
|
||||
.assertContent("blocker.md", "blocker\n")
|
||||
.assertContent("file-59.md", "new\n")
|
||||
.assertFileNotExists("file-33.md");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const resetClearsRecentlyDeletedResurrectionTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 deletes a file. Client 1 toggles sync off and on " +
|
||||
"(simulating reconnect). The deleted file should NOT reappear " +
|
||||
"on Client 1 after the sync reset.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "ghost.md",
|
||||
content: "should be deleted"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "delete", client: 0, path: "ghost.md" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileNotExists("ghost.md");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 1 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(0);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const sameDocIdCollapseAfterRemoteQuickWriteAndPendingRenameTest: TestDefinition =
|
||||
{
|
||||
description:
|
||||
"A remote create starts quick-writing at doc.md while a local " +
|
||||
"create for the same path is queued and renamed to renamed.md. " +
|
||||
"Because the local create was renamed before it reached the " +
|
||||
"server, the two generations should remain separate tracked " +
|
||||
"documents.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
|
||||
// Create a deleted latest version before client 1 joins.
|
||||
// Catch-up will advance MinCovered with a non-contiguous id,
|
||||
// keeping client 1's create lastSeen low enough to exercise
|
||||
// the server's same-doc merge path from the e2e failure.
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "history.md",
|
||||
content: "history-v1"
|
||||
},
|
||||
{ type: "sync", client: 0 },
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "history.md",
|
||||
content: "history-v2"
|
||||
},
|
||||
{ type: "sync", client: 0 },
|
||||
{ type: "delete", client: 0, path: "history.md" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
{ type: "pause-websocket", client: 1 },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "remote\n"
|
||||
},
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
// Let client 1's buffered RemoteCreate enter the quick-write
|
||||
// path, but hold the content fetch until the local create has
|
||||
// appeared and moved away from doc.md.
|
||||
{ type: "pause-server" },
|
||||
{ type: "resume-websocket", client: 1 },
|
||||
{ type: "sleep", ms: 100 },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "doc.md",
|
||||
content: "local\n"
|
||||
},
|
||||
{
|
||||
type: "rename",
|
||||
client: 1,
|
||||
oldPath: "doc.md",
|
||||
newPath: "renamed.md"
|
||||
},
|
||||
|
||||
{ type: "resume-server" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state.assertFileCount(2);
|
||||
state.assertContent("doc.md", "remote\n");
|
||||
state.assertContent("renamed.md", "local\n");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const sameDocIdCollapseOnLocalCreateAfterRemoteCreateTest: TestDefinition =
|
||||
{
|
||||
description:
|
||||
"Client B creates X with content C2; the server commits and " +
|
||||
"broadcasts. Client A's WS is paused so the RemoteCreate buffers. " +
|
||||
"Server is then paused so A's about-to-POST LocalCreate will " +
|
||||
"hang. A creates X with content C1: file lands on disk, " +
|
||||
"LocalCreate enqueues, drain starts the POST, the POST stalls " +
|
||||
"at the paused server. A's WS is resumed: the buffered " +
|
||||
"RemoteCreate for doc-X is delivered to A and enqueues behind " +
|
||||
"the in-flight LocalCreate. Per the lazy-paths model, when " +
|
||||
"the RemoteCreate is processed it observes that path X is " +
|
||||
"occupied locally by A's pending-create bytes, so it tracks " +
|
||||
"doc-X with `localPath = undefined` / `remoteRelativePath = " +
|
||||
"X` and does NOT fetch content. The server is then resumed: " +
|
||||
"A's LocalCreate POST returns. The server, finding X already " +
|
||||
"taken by doc-X, replies with doc-X's existing documentId " +
|
||||
"(typically a MergingUpdate carrying the merged bytes). A's " +
|
||||
"processCreate handler detects that response.documentId " +
|
||||
"matches the no-localPath record built from the RemoteCreate " +
|
||||
"and collapses the two: it sets localPath = X on that " +
|
||||
"record, writes the merged bytes, and resolves the pending " +
|
||||
"create promise. Final state: exactly one file at X on both " +
|
||||
"clients, both pointing at doc-X's documentId, content " +
|
||||
"carrying both contributions, and no conflict-<uuid>- " +
|
||||
"stash anywhere.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Buffer broadcasts to client 0 (A) so client 1's create
|
||||
// doesn't reach A's WS handler until we say so.
|
||||
{ type: "pause-websocket", client: 0 },
|
||||
|
||||
// Client 1 (B) commits doc-X at path X with content C2.
|
||||
// The server commits, broadcasts (broadcast queued at A's
|
||||
// paused WS).
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "X.md",
|
||||
content: "from-client-1 "
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Pause the server so A's upcoming LocalCreate POST hangs.
|
||||
// This holds A's drain on the in-flight POST while we
|
||||
// release the WS so the RemoteCreate enqueues behind it.
|
||||
{ type: "pause-server" },
|
||||
|
||||
// Client 0 (A) creates X locally with content C1. The
|
||||
// file lands on A's disk; LocalCreate enqueues; drain
|
||||
// starts the POST; POST stalls at the paused server.
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "X.md",
|
||||
content: "from-client-0 "
|
||||
},
|
||||
|
||||
// Release A's WS. The buffered RemoteCreate for doc-X is
|
||||
// delivered to A and enqueues behind the in-flight
|
||||
// LocalCreate. Whichever of (RemoteCreate processed first
|
||||
// → no-localPath record, then LocalCreate POST returns
|
||||
// with merging response that collapses) or (LocalCreate
|
||||
// POST returns first with merging response that creates
|
||||
// the canonical record, then RemoteCreate finds the doc
|
||||
// already tracked by id and no-ops) actually plays out
|
||||
// depends on the fine-grained interleaving the runtime
|
||||
// produces, but both paths are required to converge to
|
||||
// the same single-record same-docId state.
|
||||
{ type: "resume-websocket", client: 0 },
|
||||
|
||||
// Resume the server: A's LocalCreate POST completes.
|
||||
// Server returns doc-X's existing documentId (MergingUpdate
|
||||
// with merged content). processCreate runs the collapse
|
||||
// path.
|
||||
{ type: "resume-server" },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state.assertFileCount(1);
|
||||
state.assertFileExists("X.md");
|
||||
// Server-side merge of the two text creates must
|
||||
// carry both contributions through to the
|
||||
// converged file.
|
||||
state.assertContains(
|
||||
"X.md",
|
||||
"from-client-0",
|
||||
"from-client-1"
|
||||
);
|
||||
// The lazy-paths collapse path must not leave a
|
||||
// conflict-<uuid>- stash on either client.
|
||||
for (const path of state.files.keys()) {
|
||||
if (path.startsWith("conflict-")) {
|
||||
throw new Error(
|
||||
`Unexpected conflict-uuid stash on a converged client: ${path}`
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const perClient of state.clientFiles) {
|
||||
for (const path of perClient.keys()) {
|
||||
if (path.startsWith("conflict-")) {
|
||||
throw new Error(
|
||||
`Unexpected conflict-uuid stash on a per-client view: ${path}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const selfMergePendingRenameAliasesSecondCreateTest: TestDefinition = {
|
||||
description:
|
||||
"Single client makes two distinct creates that briefly share a path. " +
|
||||
"Client 0 POSTs the first create at primary.md while the server is " +
|
||||
"paused. While that POST is in flight: a second create is queued at " +
|
||||
"staging.md, primary.md is renamed to moved.md (rewriting the in- " +
|
||||
"flight create's event.path to moved.md and pushing a rename " +
|
||||
"LocalUpdate at the queue tail), and staging.md is renamed onto the " +
|
||||
"now-vacated primary.md slot (rewriting the second create's " +
|
||||
"event.path to primary.md and pushing another rename LocalUpdate). " +
|
||||
"Client 0's WS is paused throughout, so its watermark stays at 0. " +
|
||||
"On resume the first POST commits Doc-X at primary.md (creation_vuid " +
|
||||
"= N). The drain then processes the second LocalCreate (POST " +
|
||||
"relativePath=primary.md, last_seen=0); the server's path-based " +
|
||||
"dedup sees N > 0 and merges the second create into Doc-X " +
|
||||
"(MergingUpdate). The buggy behaviour: processCreate's resolveCreate " +
|
||||
"calls upsertRecord with localPath=primary.md, but the existing " +
|
||||
"record (from the first create) already holds localPath=moved.md, " +
|
||||
"and upsertRecord's `existing.localPath !== undefined` guard " +
|
||||
"silently drops the new claim. The file at primary.md is left " +
|
||||
"orphaned: tracked by no record, never broadcast, never deleted. " +
|
||||
"After the user's renames the expected user-visible state is two " +
|
||||
"distinct files at moved.md and primary.md — both clients must " +
|
||||
"converge to that.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Both clients online so the WS connection is established before
|
||||
// the test starts pausing things.
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Pause client 0's WS so its MinCovered watermark stays at 0
|
||||
// through the whole bug sequence. The merge condition the
|
||||
// server is going to fire is `creation_vuid > last_seen`; with
|
||||
// a non-zero gap the same-device second create gets merged
|
||||
// into the same-device first create.
|
||||
{ type: "pause-websocket", client: 0 },
|
||||
|
||||
// Client 1 commits a doc to push the server's vuid above 0.
|
||||
// Without this filler, Doc-X's create vuid could be 1 and
|
||||
// client 0's last_seen.add(1) would advance min to 1, killing
|
||||
// the watermark gap that triggers the merge.
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "filler.md",
|
||||
content: "filler-content "
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Pause the server so client 0's first create POST hangs in
|
||||
// flight, giving us a deterministic window in which to enqueue
|
||||
// the second create and the renames.
|
||||
{ type: "pause-server" },
|
||||
|
||||
// First create — Doc-X. The wire-loop drains it, captures
|
||||
// requestPath = event.path = "primary.md", reads the bytes,
|
||||
// sends the POST, and stalls on the response.
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "primary.md",
|
||||
content: "primary content "
|
||||
},
|
||||
|
||||
// Make sure the POST is actually on the wire with
|
||||
// relativePath="primary.md" before we rewrite event.path.
|
||||
// Without this delay the rename can win the race, the POST
|
||||
// goes out with relativePath="moved.md", and the server-side
|
||||
// path-collision merge never fires.
|
||||
{ type: "sleep", ms: 100 },
|
||||
|
||||
// Second create at a staging path. The wire-loop is still
|
||||
// blocked on Doc-X's POST, so this LocalCreate just queues at
|
||||
// index 1.
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "staging.md",
|
||||
content: "secondary content "
|
||||
},
|
||||
|
||||
// Rename Doc-X's path. enqueue's pending-create branch
|
||||
// rewrites Doc-X's event.path in place (moved.md) and pushes
|
||||
// a LocalUpdate(rename, originalPath=moved.md) at the END of
|
||||
// the queue. Note the ordering: this LocalUpdate is enqueued
|
||||
// AFTER the staging LocalCreate above. That ordering is
|
||||
// load-bearing — it is what makes the second create's POST
|
||||
// drain (and trigger the server-side merge) before Doc-X's
|
||||
// rename PUT moves the doc away from primary.md on the
|
||||
// server.
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "primary.md",
|
||||
newPath: "moved.md"
|
||||
},
|
||||
|
||||
// Rename the staging file onto Doc-X's now-vacated primary.md
|
||||
// slot. enqueue rewrites the staging LocalCreate's event.path
|
||||
// to primary.md and pushes a LocalUpdate(rename,
|
||||
// originalPath=primary.md) at the queue tail. After this the
|
||||
// disk has: moved.md = Doc-X's bytes, primary.md = Doc-Y's
|
||||
// bytes.
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "staging.md",
|
||||
newPath: "primary.md"
|
||||
},
|
||||
|
||||
// Let everything fly: server processes the queued POSTs;
|
||||
// client 0 catches up on broadcasts.
|
||||
{ type: "resume-server" },
|
||||
{ type: "resume-websocket", client: 0 },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
// The user did two distinct creates (Doc-X and Doc-Y);
|
||||
// both contents must survive on both clients.
|
||||
state.assertFileCount(3);
|
||||
state.assertFileExists("filler.md");
|
||||
state.assertFileExists("moved.md");
|
||||
state.assertFileExists("primary.md");
|
||||
|
||||
// After the renames the user expects:
|
||||
// - moved.md = the file that was originally created
|
||||
// at primary.md (Doc-X's content).
|
||||
// - primary.md = the file that was originally created
|
||||
// at staging.md (Doc-Y's content).
|
||||
state.assertContains("moved.md", "primary content");
|
||||
state.assertContains("primary.md", "secondary content");
|
||||
|
||||
// No content cross-contamination: each contribution
|
||||
// should land in exactly one of the user-visible
|
||||
// files. Under the bug, the orphan at primary.md
|
||||
// carries Doc-X's content (because Doc-Y's PUT was
|
||||
// aliased onto Doc-X's record and read Doc-X's bytes
|
||||
// from moved.md), so this catches the leak too.
|
||||
state.assertContentInAtMostOneFile("primary content");
|
||||
state.assertContentInAtMostOneFile("secondary content");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const sequentialCreateDuplicateContentTest: TestDefinition = {
|
||||
description:
|
||||
"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: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "identical content here"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("A.md", "identical content here");
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "B.md",
|
||||
content: "identical content here"
|
||||
},
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(2)
|
||||
.assertContent("A.md", "identical content here")
|
||||
.assertContent("B.md", "identical content here");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const serverPauseBothClientsCreateTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 creates a file, then the server is paused. Client 1 creates a different file while the server is paused. After the server resumes, both files should exist on both clients.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "alpha.md",
|
||||
content: "from client 0"
|
||||
},
|
||||
{ type: "pause-server" },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "beta.md",
|
||||
content: "from client 1"
|
||||
},
|
||||
|
||||
{ type: "resume-server" },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContains("alpha.md", "from client 0").assertContains(
|
||||
"beta.md",
|
||||
"from client 1"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const serverPauseBothEditSameFileTest: TestDefinition = {
|
||||
description:
|
||||
"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: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "shared.md",
|
||||
content: "line 1: original\nline 2: original\nline 3: original"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "pause-server" },
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "shared.md",
|
||||
content:
|
||||
"line 1: edited by client 0\nline 2: original\nline 3: original"
|
||||
},
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "shared.md",
|
||||
content:
|
||||
"line 1: original\nline 2: original\nline 3: edited by client 1"
|
||||
},
|
||||
|
||||
{ type: "resume-server" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContains(
|
||||
"shared.md",
|
||||
"edited by client 0",
|
||||
"edited by client 1"
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "shared.md",
|
||||
content: "post-merge edit from client 0"
|
||||
},
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContains(
|
||||
"shared.md",
|
||||
"post-merge edit from client 0"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
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: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(1)
|
||||
.assertContent("A.md", "recreated during contention");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const serverPauseRenameEditResumeTest: TestDefinition = {
|
||||
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: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "original content"
|
||||
},
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("A.md", "original content");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "pause-server" },
|
||||
|
||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "B.md",
|
||||
content: "edited after rename during pause"
|
||||
},
|
||||
|
||||
{ type: "resume-server" },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1)
|
||||
.assertFileNotExists("A.md")
|
||||
.assertContent("B.md", "edited after rename during pause");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue