Better det tests

This commit is contained in:
Andras Schmelczer 2026-09-27 21:51:01 +01:00
commit 77521c970b
62 changed files with 395 additions and 646 deletions

View file

@ -34,10 +34,9 @@ export const binaryPendingCreateNotDisplacedTest: TestDefinition = {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertFileCount(2) s.assertFileCount(2)
.assertFileExists("data.bin") .assertContent("data.bin", "binary data from client 0")
.assertFileExists(s.conflictPath("data.bin")) .assertContent(
.assertAnyFileContains( s.conflictPath("data.bin"),
"binary data from client 0",
"binary data from client 1" "binary data from client 1"
); );
} }

View file

@ -38,11 +38,7 @@ export const binaryToTextTransitionTest: TestDefinition = {
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContainsAny( s.assertFileCount(1).assertContent("data.bin", "version A");
"data.bin",
"version A",
"version B"
);
} }
}, },
@ -90,7 +86,10 @@ export const binaryToTextTransitionTest: TestDefinition = {
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContains("data.md", "alpha", "beta"); s.assertFileCount(1).assertContent(
"data.md",
"alpha\nmiddle line\nbeta"
);
} }
} }
] ]

View file

@ -45,13 +45,11 @@ export const coalesceUpdateRemoteUpdateDataLossTest: TestDefinition = {
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (state: AssertableState): void => { verify: (s: AssertableState): void => {
state s.assertFileCount(1);
.assertFileCount(1) s.assertContent(
.assertContains(
"doc.md", "doc.md",
"client 0 addition", "client 1 addition\nline 1\nline 2\nline 3\nclient 0 addition"
"client 1 addition"
); );
} }
} }

View file

@ -42,7 +42,11 @@ export const concurrentEditExactSamePositionTest: TestDefinition = {
verify: (state: AssertableState): void => { verify: (state: AssertableState): void => {
state state
.assertFileCount(1) .assertFileCount(1)
.assertContains("doc.md", "slow", "fast", "brown fox"); .assertContentOneOf(
"doc.md",
"the fast slow brown fox",
"the slow fast brown fox"
);
} }
} }
] ]

View file

@ -1,7 +1,8 @@
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 concurrentRenameAndCreateAtTargetCreateFirstTest: TestDefinition = { export const concurrentRenameAndCreateAtTargetCreateFirstTest: TestDefinition =
{
description: description:
"One client renames X to Y while another creates a new file at Y, " + "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 offline. After syncing, Y should contain merged content from " +
@ -38,12 +39,14 @@ export const concurrentRenameAndCreateAtTargetCreateFirstTest: TestDefinition =
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (state: AssertableState): void => { verify: (s: AssertableState): void => {
state s.assertFileCount(2)
.assertFileCount(2) .assertContent("Y.md", "brand new Y content")
.assertContains(state.conflictPath("Y.md"), "original file X") .assertContent(
.assertContains("Y.md", "brand new Y content"); s.conflictPath("Y.md"),
"original file X"
);
} }
} }
] ]
}; };

View file

@ -1,7 +1,8 @@
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 concurrentRenameAndCreateAtTargetRenameFirstTest: TestDefinition = { export const concurrentRenameAndCreateAtTargetRenameFirstTest: TestDefinition =
{
description: description:
"One client renames X to Y while another creates a new file at Y, " + "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", "both offline. We can't merge the create because it would result in a cycle",
@ -37,16 +38,14 @@ export const concurrentRenameAndCreateAtTargetRenameFirstTest: TestDefinition =
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (state: AssertableState): void => { verify: (s: AssertableState): void => {
state s.assertFileCount(2)
.assertFileNotExists("X.md") .assertContent("Y.md", "original file X")
.assertFileExists("Y.md") .assertContent(
.assertFileExists(state.conflictPath("Y.md")) s.conflictPath("Y.md"),
.assertAnyFileContains(
"original file X",
"brand new Y content" "brand new Y content"
); );
} }
} }
] ]
}; };

View file

@ -25,14 +25,10 @@ export const concurrentRenameSameTargetTest: TestDefinition = {
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (state: AssertableState): void => { verify: (s: AssertableState): void => {
state s.assertFileCount(2)
.assertFileCount(2) .assertContent("C.md", "content-a")
.assertFileNotExists("A.md") .assertContent(s.conflictPath("C.md"), "content-b");
.assertFileNotExists("B.md")
.assertFileExists("C.md")
.assertFileExists(state.conflictPath("C.md"))
.assertAnyFileContains("content-a", "content-b");
} }
} }
] ]

View file

@ -20,7 +20,7 @@ export const createDeleteNoopTest: TestDefinition = {
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertFileNotExists("temp.md"); s.assertFileCount(0).assertFileNotExists("temp.md");
} }
} }
] ]

View file

@ -17,10 +17,14 @@ export const createMergeDeleteTest: TestDefinition = {
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (state: AssertableState): void => { verify: (s: AssertableState): void => {
state s.assertFileCount(1).assertContentOneOf(
.assertFileCount(1) "A.md",
.assertContains("A.md", "from-zero", "from-one"); "from-zerofrom-one",
"from-onefrom-zero",
"from-zero from-one",
"from-one from-zero"
);
} }
}, },

View file

@ -17,8 +17,14 @@ export const createMergePreservesRenamedUpdateTest: TestDefinition = {
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (state: AssertableState): void => { verify: (s: AssertableState): void => {
state.assertContains("doc.md", "alpha", "beta"); s.assertFileCount(1).assertContentOneOf(
"doc.md",
"alphabeta",
"betaalpha",
"alpha beta",
"beta alpha"
);
} }
}, },

View file

@ -29,7 +29,8 @@ export const createRenameResponseSkipsFileTest: TestDefinition = {
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertFileCount(1).assertAnyFileContains("the-content"); s.assertFileCount(1);
s.assertContent("renamed.md", "the-content");
} }
} }
] ]

View file

@ -18,7 +18,7 @@ export const deleteByOtherClientThenRecreateTest: TestDefinition = {
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertFileNotExists("A.md"); s.assertFileCount(0).assertFileNotExists("A.md");
} }
}, },

View file

@ -3,15 +3,15 @@ import type { TestDefinition } from "../test-definition";
export const deleteDuringPendingCreateTest: TestDefinition = { export const deleteDuringPendingCreateTest: TestDefinition = {
description: description:
"Client 0 creates a file while the server is paused, then deletes it before the server resumes. " + "Client 0 creates a file while its create request is held, then deletes it before releasing the request. " +
"After resume, the file should end up deleted on both clients.", "After release, the file should end up deleted on both clients.",
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" },
{ type: "pause-server" }, { type: "hold-request", client: 0, kind: "create", point: "before" },
{ {
type: "create", type: "create",
@ -19,10 +19,11 @@ export const deleteDuringPendingCreateTest: TestDefinition = {
path: "ephemeral.md", path: "ephemeral.md",
content: "this will be deleted" content: "this will be deleted"
}, },
{ type: "wait-for-request", client: 0 },
{ type: "delete", client: 0, path: "ephemeral.md" }, { type: "delete", client: 0, path: "ephemeral.md" },
{ type: "resume-server" }, { type: "release-request", client: 0 },
{ type: "barrier" }, { type: "barrier" },
{ {

View file

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

View file

@ -4,10 +4,9 @@ import type { TestDefinition } from "../test-definition";
export const deleteRecreatedPendingCreateWithStaleDeletingRecordTest: TestDefinition = export const deleteRecreatedPendingCreateWithStaleDeletingRecordTest: TestDefinition =
{ {
description: description:
"A local delete for a recreated pending create must target the " + "Delete two successive generations while each create request is " +
"new pending create, not an older same-path record whose server " + "held before send. Retrying and acknowledging the old generation " +
"delete has been acked but whose WebSocket delete receipt is " + "must not resurrect either file, even with delayed WebSocket hints.",
"still paused.",
clients: 2, clients: 2,
steps: [ steps: [
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
@ -15,28 +14,38 @@ export const deleteRecreatedPendingCreateWithStaleDeletingRecordTest: TestDefini
{ type: "barrier" }, { type: "barrier" },
{ type: "pause-websocket", client: 0 }, { type: "pause-websocket", client: 0 },
{ type: "pause-server" }, {
type: "hold-request",
client: 0,
kind: "create",
point: "before"
},
{ {
type: "create", type: "create",
client: 0, client: 0,
path: "binary-14.bin", path: "binary-14.bin",
content: "BINARY:first" content: "BINARY:first"
}, },
{ type: "sleep", ms: 100 }, { type: "wait-for-request", client: 0 },
{ type: "delete", client: 0, path: "binary-14.bin" }, { type: "delete", client: 0, path: "binary-14.bin" },
{ type: "resume-server" }, { type: "release-request", client: 0 },
{ type: "sync", client: 0 }, { type: "sync", client: 0 },
{ type: "pause-server" }, {
type: "hold-request",
client: 0,
kind: "create",
point: "before"
},
{ {
type: "create", type: "create",
client: 0, client: 0,
path: "binary-14.bin", path: "binary-14.bin",
content: "BINARY:second" content: "BINARY:second"
}, },
{ type: "sleep", ms: 100 }, { type: "wait-for-request", client: 0 },
{ type: "delete", client: 0, path: "binary-14.bin" }, { type: "delete", client: 0, path: "binary-14.bin" },
{ type: "resume-server" }, { type: "release-request", client: 0 },
{ type: "sync", client: 0 }, { type: "sync", client: 0 },
{ type: "resume-websocket", client: 0 }, { type: "resume-websocket", client: 0 },

View file

@ -15,7 +15,8 @@ export const deleteRenameConflictTest: TestDefinition = {
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertFileExists("A.md").assertFileExists("B.md"); s.assertContent("A.md", "content-a");
s.assertContent("B.md", "content-b");
} }
}, },
@ -32,11 +33,8 @@ export const deleteRenameConflictTest: TestDefinition = {
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertFileCount(1);
s.assertContent("B.md", "content-b"); s.assertContent("B.md", "content-b");
s.assertFileNotExists("A.md");
s.ifFileExists("C.md", (inner) =>
inner.assertContent("C.md", "content-a")
);
} }
} }
] ]

View file

@ -3,13 +3,13 @@ import type { TestDefinition } from "../test-definition";
export const idempotencyAfterServerPauseTest: TestDefinition = { export const idempotencyAfterServerPauseTest: TestDefinition = {
description: description:
"Client 0 creates a file, then the server is paused mid-response. " + "Lose an accepted create response and require an identical retry, preserving one exact copy.",
"After the server resumes, both clients must converge to a single copy of the file with no duplicates.",
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" },
{ type: "drop-response", client: 0, kind: "create", point: "after" },
{ {
type: "create", type: "create",
@ -17,9 +17,7 @@ export const idempotencyAfterServerPauseTest: TestDefinition = {
path: "doc.md", path: "doc.md",
content: "important data" content: "important data"
}, },
{ type: "pause-server" }, { type: "wait-for-response-drop", client: 0 },
{ type: "resume-server" },
{ type: "barrier" }, { type: "barrier" },

View file

@ -3,20 +3,19 @@ import type { TestDefinition } from "../test-definition";
export const interruptedDeleteRetryTest: TestDefinition = { export const interruptedDeleteRetryTest: TestDefinition = {
description: description:
"Client 0 deletes a file, then the server is paused. " + "Lose an accepted deletion manifest response and require an identical retry, with both clients empty.",
"After the server resumes, both clients should have zero files.",
clients: 2, clients: 2,
steps: [ steps: [
{ type: "create", client: 0, path: "doc.md", content: "to be deleted" }, { type: "create", client: 0, path: "doc.md", content: "to be deleted" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "barrier" }, { type: "barrier" },
{ type: "drop-response", client: 0, kind: "manifest", point: "after" },
{ type: "delete", client: 0, path: "doc.md" }, { type: "delete", client: 0, path: "doc.md" },
{ type: "pause-server" }, { type: "wait-for-response-drop", client: 0 },
{ type: "resume-server" },
{ type: "barrier" }, { type: "barrier" },
{ {

View file

@ -30,10 +30,12 @@ export const localEditLostDuringCreateMergeTest: TestDefinition = {
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s: AssertableState): void => { verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContains( s.assertFileCount(1).assertContentOneOf(
"doc.md", "doc.md",
"from-client-1", "from-client-1local-edit-during-create",
"local-edit-during-create" "local-edit-during-createfrom-client-1",
"from-client-1 local-edit-during-create",
"local-edit-during-create from-client-1"
); );
} }
} }

View file

@ -1,21 +1,8 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition"; import type { TestDefinition } from "../test-definition";
export const localRenameSurvivesRemoteRenameTest: TestDefinition = { export const localRenameSurvivesRemoteRenameTest: TestDefinition = {
description: description:
"Drain processes a RemoteChange (remote rename for doc D) while a " + "A held remote manifest races with a local rename and a separate edit; the first committed rename wins and both identities survive.",
"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, clients: 2,
steps: [ steps: [
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
@ -23,58 +10,31 @@ export const localRenameSurvivesRemoteRenameTest: TestDefinition = {
{ type: "create", client: 0, path: "doc.md", content: "v1\n" }, { type: "create", client: 0, path: "doc.md", content: "v1\n" },
{ type: "create", client: 0, path: "sentinel.md", content: "s\n" }, { type: "create", client: 0, path: "sentinel.md", content: "s\n" },
{ type: "barrier" }, { type: "barrier" },
{
// Pause client 0's WebSocket so the upcoming remote rename buffers. type: "assert-documents",
{ type: "pause-websocket", client: 0 }, expected: [
{ key: "note", path: "doc.md", content: "v1\n" },
// Server applies remote rename of doc.md -> remote.md. Broadcast { key: "sentinel", path: "sentinel.md", content: "s\n" }
// is buffered on client 0's WebSocket. ]
},
{ type: "hold-request", client: 0, kind: "events", point: "after" },
{ type: "rename", client: 1, oldPath: "doc.md", newPath: "remote.md" }, { type: "rename", client: 1, oldPath: "doc.md", newPath: "remote.md" },
{ type: "sync", client: 1 }, { type: "wait-for-request", client: 0 },
// 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", type: "update",
client: 0, client: 0,
path: "sentinel.md", path: "sentinel.md",
content: "s\nedit\n" 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" }, { type: "rename", client: 0, oldPath: "doc.md", newPath: "local.md" },
{ type: "release-request", client: 0 },
// 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: "barrier" },
{ {
type: "assert-consistent", type: "assert-documents",
verify: (state: AssertableState): void => { expected: [
state.assertFileCount(2); { key: "note", path: "remote.md", content: "v1\n" },
} { key: "sentinel", path: "sentinel.md", content: "s\nedit\n" }
]
} }
] ]
}; };

Some files were not shown because too many files have changed in this diff Show more