Improve tests

This commit is contained in:
Andras Schmelczer 2026-09-20 21:26:53 +01:00
commit 12d85f567e
15 changed files with 63 additions and 119 deletions

View file

@ -7,6 +7,12 @@ export const binaryPendingCreateNotDisplacedTest: TestDefinition = {
"After syncing, both files should exist on both clients at separate paths.", "After syncing, both files should exist on both clients at separate paths.",
clients: 2, clients: 2,
steps: [ steps: [
// Bootstrap both empty clients before making independent documents.
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
{ {
type: "create", type: "create",
client: 0, client: 0,
@ -29,7 +35,7 @@ export const binaryPendingCreateNotDisplacedTest: TestDefinition = {
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertFileCount(2) s.assertFileCount(2)
.assertFileExists("data.bin") .assertFileExists("data.bin")
.assertFileExists("data (1).bin") .assertFileExists(s.conflictPath("data.bin"))
.assertAnyFileContains( .assertAnyFileContains(
"binary data from client 0", "binary data from client 0",
"binary data from client 1" "binary data from client 1"

View file

@ -41,7 +41,7 @@ export const concurrentRenameAndCreateAtTargetCreateFirstTest: TestDefinition =
verify: (state: AssertableState): void => { verify: (state: AssertableState): void => {
state state
.assertFileCount(2) .assertFileCount(2)
.assertContains("Y (1).md", "original file X") .assertContains(state.conflictPath("Y.md"), "original file X")
.assertContains("Y.md", "brand new Y content"); .assertContains("Y.md", "brand new Y content");
} }
} }

View file

@ -41,7 +41,7 @@ export const concurrentRenameAndCreateAtTargetRenameFirstTest: TestDefinition =
state state
.assertFileNotExists("X.md") .assertFileNotExists("X.md")
.assertFileExists("Y.md") .assertFileExists("Y.md")
.assertFileExists("Y (1).md") .assertFileExists(state.conflictPath("Y.md"))
.assertAnyFileContains( .assertAnyFileContains(
"original file X", "original file X",
"brand new Y content" "brand new Y content"

View file

@ -52,9 +52,9 @@ export const concurrentRenameFirstWinsTest: TestDefinition = {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertFileNotExists("A.md") s.assertFileNotExists("A.md")
.assertFileCount(2) .assertFileCount(1)
.assertContent("B.md", "edit from 0\nline 2\nline 3") .assertFileNotExists("C.md")
.assertContent("C.md", "line 1\nline 2\nedit from 1"); .assertContent("B.md", "edit from 0\nline 2\nedit from 1");
} }
} }
] ]

View file

@ -31,7 +31,7 @@ export const concurrentRenameSameTargetTest: TestDefinition = {
.assertFileNotExists("A.md") .assertFileNotExists("A.md")
.assertFileNotExists("B.md") .assertFileNotExists("B.md")
.assertFileExists("C.md") .assertFileExists("C.md")
.assertFileExists("C (1).md") .assertFileExists(state.conflictPath("C.md"))
.assertAnyFileContains("content-a", "content-b"); .assertAnyFileContains("content-a", "content-b");
} }
} }

View file

@ -4,7 +4,7 @@ import type { TestDefinition } from "../test-definition";
export const deleteRecreateDifferentContentTest: TestDefinition = { export const deleteRecreateDifferentContentTest: TestDefinition = {
description: description:
"Client 0 deletes and recreates A.md with new content offline while client 1 edits A.md offline. " + "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.", "Recreation gets a new UUID; an update to the deleted UUID must not edit the replacement.",
clients: 2, clients: 2,
steps: [ steps: [
{ {
@ -43,11 +43,7 @@ export const deleteRecreateDifferentContentTest: TestDefinition = {
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContains( s.assertFileCount(1).assertContent("A.md", "brand new content");
"A.md",
"brand new",
"client 1"
);
} }
} }
] ]

View file

@ -29,9 +29,8 @@ export const offlineDeleteRemoteRenameTest: TestDefinition = {
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertFileNotExists("A.md").assertFileNotExists( // Both membership/path changed from the base: remote wins.
"A_renamed.md" s.assertFileCount(1).assertFileNotExists("A.md").assertContent("A_renamed.md", "content-a");
);
} }
} }
] ]

View file

@ -26,8 +26,8 @@ export const onlineBothCreateSamePathDeconflictTest: TestDefinition = {
type: "assert-consistent", type: "assert-consistent",
verify: (state: AssertableState): void => { verify: (state: AssertableState): void => {
state state
.assertFileCount(1) .assertFileCount(2)
.assertContains("A.md", "updated-by-0", "from-client-1 "); .assertAnyFileContains("updated-by-0", "from-client-1 ");
} }
} }
] ]

View file

@ -3,7 +3,7 @@ import type { TestDefinition } from "../test-definition";
export const renameCircularTest: TestDefinition = { export const renameCircularTest: TestDefinition = {
description: 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.", "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 UUIDs and exact contents must follow the requested cycle.",
clients: 2, clients: 2,
steps: [ steps: [
{ type: "create", client: 0, path: "A.md", content: "content-a" }, { type: "create", client: 0, path: "A.md", content: "content-a" },
@ -21,6 +21,9 @@ export const renameCircularTest: TestDefinition = {
} }
}, },
{ type: "remember-identity", key: "A", path: "A.md" },
{ type: "remember-identity", key: "B", path: "B.md" },
{ type: "remember-identity", key: "C", path: "C.md" },
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
{ type: "rename", client: 0, oldPath: "A.md", newPath: "temp-a.md" }, { 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: "C.md", newPath: "A.md" },
@ -29,15 +32,18 @@ export const renameCircularTest: TestDefinition = {
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-identity", key: "A", path: "B.md" },
{ type: "assert-identity", key: "B", path: "C.md" },
{ type: "assert-identity", key: "C", path: "A.md" },
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertFileNotExists("temp-a.md") s.assertFileNotExists("temp-a.md")
.assertFileCount(3) .assertFileCount(3)
.assertAnyFileContains("content-c") .assertContent("A.md", "content-c")
.assertAnyFileContains("content-a") .assertContent("B.md", "content-a")
.assertAnyFileContains("content-b"); .assertContent("C.md", "content-b");
} }
} }
] ]

View file

@ -27,7 +27,7 @@ export const renameCreateConflictTest: TestDefinition = {
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertFileCount(2) s.assertFileCount(2)
.assertContent("B.md", "hi") .assertContent("B.md", "hi")
.assertContent("B (1).md", "hi"); .assertContent(s.conflictPath("B.md"), "hi");
} }
} }
] ]

View file

@ -5,7 +5,7 @@ export const renameSwapTest: TestDefinition = {
description: description:
"Client 0 has A.md and B.md synced. Goes offline and swaps them using " + "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. " + "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.", "When Client 0 reconnects, both UUIDs must follow their files to the swapped paths.",
clients: 2, clients: 2,
steps: [ steps: [
{ type: "create", client: 0, path: "A.md", content: "content-a" }, { type: "create", client: 0, path: "A.md", content: "content-a" },
@ -23,6 +23,8 @@ export const renameSwapTest: TestDefinition = {
} }
}, },
{ type: "remember-identity", key: "A", path: "A.md" },
{ type: "remember-identity", key: "B", path: "B.md" },
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
{ type: "rename", client: 0, oldPath: "A.md", newPath: "temp.md" }, { 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: "B.md", newPath: "A.md" },
@ -30,14 +32,16 @@ export const renameSwapTest: TestDefinition = {
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-identity", key: "A", path: "B.md" },
{ type: "assert-identity", key: "B", path: "A.md" },
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertFileNotExists("temp.md") s.assertFileNotExists("temp.md")
.assertFileCount(2) .assertFileCount(2)
.assertAnyFileContains("content-b") .assertContent("A.md", "content-b")
.assertAnyFileContains("content-a"); .assertContent("B.md", "content-a");
} }
} }
] ]

View file

@ -1,121 +1,50 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition"; import type { TestDefinition } from "../test-definition";
// Historical registry name retained so old reproduction commands still work.
export const sameDocIdCollapseOnLocalCreateAfterRemoteCreateTest: TestDefinition = export const sameDocIdCollapseOnLocalCreateAfterRemoteCreateTest: TestDefinition =
{ {
description: description:
"Client B creates X with content C2; the server commits and " + "Two initialized clients create distinct UUIDs at X.md while remote observation is held. V4 must preserve both identities/content after catchup; the server never merges creates by path.",
"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, clients: 2,
steps: [ steps: [
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "barrier" }, { type: "barrier" },
// Freeze HTTP catchup as well as WS. Pausing only WS is insufficient.
// Buffer broadcasts to client 0 (A) so client 1's create { type: "pause-observation", client: 0 },
// 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", type: "create",
client: 1, client: 1,
path: "X.md", path: "X.md",
content: "from-client-1 " content: "from-client-1"
}, },
{ type: "sync", 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" }, { 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", type: "create",
client: 0, client: 0,
path: "X.md", path: "X.md",
content: "from-client-0 " content: "from-client-0"
}, },
{ type: "wait-for-observation", client: 0 },
// Release A's WS. The buffered RemoteCreate for doc-X is { type: "resume-observation", client: 0 },
// 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: "resume-server" },
{ type: "barrier" }, { type: "barrier" },
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (state: AssertableState): void => { verify: (state) => {
state.assertFileCount(1); state
state.assertFileExists("X.md"); .assertFileCount(2)
// Server-side merge of the two text creates must .assertContent("X.md", "from-client-1");
// carry both contributions through to the state.assertContent(
// converged file. state.conflictPath("X.md"),
state.assertContains( "from-client-0"
"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()) { type: "assert-markers",
if (path.startsWith("conflict-")) { markers: ["from-client-0", "from-client-1"]
throw new Error(
`Unexpected conflict-uuid stash on a per-client view: ${path}`
);
}
}
}
}
} }
] ]
}; };

View file

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

View file

@ -8,6 +8,9 @@ export const userParenthesizedFileNotDeletedTest: TestDefinition = {
clients: 2, clients: 2,
steps: [ steps: [
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "disable-sync", client: 1 },
{ {
type: "create", type: "create",
@ -40,7 +43,8 @@ export const userParenthesizedFileNotDeletedTest: TestDefinition = {
.assertFileCount(3) .assertFileCount(3)
.assertFileExists("Chapter.bin") .assertFileExists("Chapter.bin")
.assertFileExists("Chapter (1).bin") .assertFileExists("Chapter (1).bin")
.assertFileExists("Chapter (2).bin"); .assertContent("Chapter (1).bin", "chapter one notes")
.assertContent(state.conflictPath("Chapter.bin"), "chapter one notes");
} }
} }
] ]

View file

@ -28,7 +28,7 @@ export const watermarkAdvancesOnSkipTest: TestDefinition = {
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertFileCount(1).assertFileExists("doc.md"); s.assertFileCount(2).assertFileExists("doc.md").assertAnyFileContains("from client 0", "from client 1");
} }
} }
] ]