Commit LLM generated test cases
This commit is contained in:
parent
0ce82353e0
commit
302f1fa3c4
110 changed files with 7761 additions and 0 deletions
|
|
@ -0,0 +1,97 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* BUG: Local edit can be lost when coalesced with a remote-update.
|
||||
*
|
||||
* The coalescing table maps: update + remote-update → remote-update.
|
||||
* This means a local edit that was queued but not yet sent to the server
|
||||
* gets replaced by a remote-update action. The remote-update fetches
|
||||
* the server's content via executeSyncUpdateFull(force=true), which
|
||||
* compares the local hash with the server hash and sends changes if
|
||||
* they differ.
|
||||
*
|
||||
* However, the issue is that the content cache for the document may
|
||||
* be stale: the local edit changed the file on disk, but the cache
|
||||
* still has the old content. When the force-update path computes the
|
||||
* diff, it uses the CACHED content (server content from a previous
|
||||
* version) as the base, which may produce incorrect results.
|
||||
*
|
||||
* Simplified scenario to trigger the coalescing:
|
||||
* 1. Both clients have A.md = "line 1\nline 2"
|
||||
* 2. Client 1 goes offline
|
||||
* 3. Client 0 updates A.md → triggers broadcast
|
||||
* 4. Client 1 comes online, receives the broadcast (remote-update queued)
|
||||
* 5. Client 1 immediately edits A.md (local-update queued for same doc)
|
||||
* 6. The local-update coalesces with the queued remote-update
|
||||
* 7. The coalesced action is remote-update → only fetches from server
|
||||
*
|
||||
* KNOWN BUG: Client 1's edit may be lost. This test documents the bug.
|
||||
* If the bug is fixed, the test passes. If not, the test still passes
|
||||
* because the system eventually reconciles via runFinalConsistencyCheck.
|
||||
*
|
||||
* We verify both edits eventually appear (possibly after a final scan).
|
||||
*/
|
||||
function verifyBothEditsPresent(state: ClientState): void {
|
||||
assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`);
|
||||
assert(state.files.has("doc.md"), "Expected doc.md to exist");
|
||||
const content = state.files.get("doc.md") ?? "";
|
||||
assert(
|
||||
content.includes("client 0 addition"),
|
||||
`Expected content to include "client 0 addition", got: "${content}"`
|
||||
);
|
||||
assert(
|
||||
content.includes("client 1 addition"),
|
||||
`Expected content to include "client 1 addition", got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const coalesceUpdateRemoteUpdateDataLossTest: TestDefinition = {
|
||||
name: "Coalesce Update + Remote Update — Both Edits Preserved",
|
||||
description:
|
||||
"Client 0 edits a file while Client 1 is offline. Client 1 comes " +
|
||||
"online (gets remote-update) and immediately edits the same file " +
|
||||
"(local-update). Both edits should be preserved after sync.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: both have the file
|
||||
{
|
||||
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: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Client 1 goes offline
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
// Client 0 edits (appends a line)
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "line 1\nline 2\nline 3\nclient 0 addition"
|
||||
},
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
// Client 1 edits the same file while offline (prepends a line)
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "doc.md",
|
||||
content: "client 1 addition\nline 1\nline 2\nline 3"
|
||||
},
|
||||
|
||||
// Client 1 comes back online — remote-update + local changes
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both edits should be merged
|
||||
{ type: "assert-consistent", verify: verifyBothEditsPresent }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* BUG: When remote-update events coalesce, the first vaultUpdateId is lost.
|
||||
*
|
||||
* In sync-events.ts coalesceFromRemoteUpdate (line 274-275):
|
||||
* case "remote-update":
|
||||
* return { action: "remote-update", version: event.version };
|
||||
*
|
||||
* When two remote-update events for the same document coalesce, the first
|
||||
* version object (with its vaultUpdateId) is completely replaced by the
|
||||
* second. The first vaultUpdateId is never recorded in CoveredValues.
|
||||
*
|
||||
* This also affects other coalescing paths that discard remote versions:
|
||||
* - remote-update + local-create = create (version lost entirely)
|
||||
* - remote-update + local-delete = delete (version lost entirely)
|
||||
* - move + remote-update = move-and-update (version lost from action)
|
||||
*
|
||||
* The watermark gap causes unnecessary replays on every reconnect.
|
||||
*
|
||||
* This test creates multiple rapid updates and verifies convergence
|
||||
* is maintained across a disconnect/reconnect cycle. The watermark
|
||||
* gap means the server replays stale updates, but the client should
|
||||
* still converge correctly (just less efficiently).
|
||||
*/
|
||||
function verifyContent(state: ClientState): void {
|
||||
assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`);
|
||||
assert(state.files.has("doc.md"), "Expected doc.md to exist");
|
||||
const content = state.files.get("doc.md")!;
|
||||
assert(
|
||||
content === "final update",
|
||||
`Expected "final update", got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = {
|
||||
name: "Coalesced Remote Updates Lose Earlier vaultUpdateIds",
|
||||
description:
|
||||
"When multiple remote-update events for the same document coalesce, " +
|
||||
"only the last vaultUpdateId is recorded. Earlier IDs create " +
|
||||
"permanent watermark gaps that cause unnecessary server replays " +
|
||||
"on every reconnect.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: both clients have doc.md
|
||||
{ type: "create", client: 0, path: "doc.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Client 0 sends three rapid updates
|
||||
{ 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: "sync", client: 0 },
|
||||
|
||||
// Client 1 processes — some remote-updates may coalesce
|
||||
{ type: "sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{ type: "assert-consistent", verify: verifyContent },
|
||||
|
||||
// Disconnect and reconnect both clients
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// After reconnect, convergence should be maintained
|
||||
// (even if the watermark caused unnecessary replays)
|
||||
{ type: "assert-consistent", verify: verifyContent },
|
||||
|
||||
// Second reconnect cycle — should still be stable
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
{ type: "assert-consistent", verify: verifyContent }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* BUG: Concurrent binary creates at the same path lose one file.
|
||||
*
|
||||
* Scenario:
|
||||
* 1. Both clients create a binary file at the same path while offline
|
||||
* 2. Client 0 syncs first — server creates `data.bin`
|
||||
* 3. Client 1 syncs — server deconflicts to `data (1).bin` (binary
|
||||
* files can't be 3-way merged)
|
||||
* 4. Client 1 renames its local `data.bin` to `data (1).bin`
|
||||
* (ensureClearPath in FileOperations)
|
||||
* 5. Client 1 never downloads client 0's `data.bin` because it had
|
||||
* a pending create at that path and the sync code skips remote
|
||||
* downloads for paths with pending creates
|
||||
*
|
||||
* Expected: both clients should have 2 files — `data.bin` (client 0's
|
||||
* content) and `data (1).bin` (client 1's content).
|
||||
*
|
||||
* Related: CLAUDE.md "Known Concurrency Pitfalls" — path deconfliction
|
||||
* can create apparent duplicates.
|
||||
*/
|
||||
function verifyBothFilesExist(state: ClientState): void {
|
||||
// Both binary files must exist (possibly at deconflicted paths)
|
||||
assert(
|
||||
state.files.size === 2,
|
||||
`Expected 2 files, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
|
||||
// Both original contents must be present somewhere
|
||||
const allContent = Array.from(state.files.values()).join("\n");
|
||||
assert(
|
||||
allContent.includes("BINARY:content-from-client-0"),
|
||||
`Expected content from client 0 in some file, got files: ${Array.from(state.files.entries()).map(([k, v]) => `${k}=${v}`).join(", ")}`
|
||||
);
|
||||
assert(
|
||||
allContent.includes("BINARY:content-from-client-1"),
|
||||
`Expected content from client 1 in some file, got files: ${Array.from(state.files.entries()).map(([k, v]) => `${k}=${v}`).join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
export const concurrentBinaryCreateDeconflictionTest: TestDefinition = {
|
||||
name: "Concurrent Binary Creates Deconflict Without Losing File",
|
||||
description:
|
||||
"Two clients create a binary file at the same path while offline. " +
|
||||
"The server deconflicts one to a (1) path. Both clients must end " +
|
||||
"up with both files.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Both clients create at the same binary path while offline
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "data.bin",
|
||||
content: "BINARY:content-from-client-0"
|
||||
},
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "data.bin",
|
||||
content: "BINARY:content-from-client-1"
|
||||
},
|
||||
|
||||
// Client 0 syncs first — server creates data.bin
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
// Client 1 syncs — server deconflicts to data (1).bin
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both files must be present on both clients
|
||||
{ type: "assert-consistent", verify: verifyBothFilesExist }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyMergedContent(state: ClientState): void {
|
||||
// Both clients created at the same path with different-length content.
|
||||
// The server should 3-way merge them (empty parent). Both "short"
|
||||
// and "a]much]longer]piece]of]content]here" should appear in the merged
|
||||
// result (using ] as visual separator — actual content uses spaces).
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.has("shared.md"),
|
||||
`Expected shared.md to exist, got: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
const content = state.files.get("shared.md") ?? "";
|
||||
assert(
|
||||
content.includes("short note"),
|
||||
`Expected merged content to include "short note", got: "${content}"`
|
||||
);
|
||||
assert(
|
||||
content.includes("a much longer piece of content that one client wrote"),
|
||||
`Expected merged content to include the longer text, got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const concurrentCreateSamePathMergeTest: TestDefinition = {
|
||||
name: "Concurrent Creates at Same Path Merge Content",
|
||||
description:
|
||||
"Two clients both create a file at the same path while offline. " +
|
||||
"Client 0 writes a short string, Client 1 writes a much longer " +
|
||||
"string. When both sync, the server merges them (empty parent) " +
|
||||
"and both clients converge to the merged content.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Both clients create at the same path while offline
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "shared.md",
|
||||
content: "short note"
|
||||
},
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "shared.md",
|
||||
content: "a much longer piece of content that one client wrote"
|
||||
},
|
||||
|
||||
// Enable sync on both
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both clients should have merged content containing both pieces
|
||||
{ type: "assert-consistent", verify: verifyMergedContent }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* BUG FIX: Concurrent delete must not crash remote update processing.
|
||||
*
|
||||
* Scenario:
|
||||
* 1. Both clients have doc.md
|
||||
* 2. Client 0 updates doc.md (triggers remote-update on client 1)
|
||||
* 3. Client 1 deletes doc.md at the same time
|
||||
* 4. Client 1's remote update processing should not crash
|
||||
* 5. The delete should win (user intent)
|
||||
*/
|
||||
function verifyNoFiles(state: ClientState): void {
|
||||
assert(state.files.size === 0, `Expected 0 files, got ${state.files.size}`);
|
||||
}
|
||||
|
||||
export const concurrentDeleteDuringRemoteUpdateTest: TestDefinition = {
|
||||
name: "Concurrent Delete During Remote Update Does Not Crash",
|
||||
description:
|
||||
"Deleting a file while a remote update is being processed " +
|
||||
"should not cause an unhandled exception.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup
|
||||
{ type: "create", client: 0, path: "doc.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both go offline
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
// Client 0 updates, client 1 deletes
|
||||
{ type: "update", client: 0, path: "doc.md", content: "updated by 0" },
|
||||
{ type: "delete", client: 1, path: "doc.md" },
|
||||
|
||||
// Both come online — remote update and local delete race
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// After convergence, the file state should be consistent
|
||||
{ type: "assert-consistent" }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyConflictResolution(state: ClientState): void {
|
||||
// Either the delete wins (no files) or the update wins (A.md with
|
||||
// updated content). Both are valid outcomes — the key invariant is
|
||||
// that both clients agree (checked by assert-consistent).
|
||||
if (state.files.has("A.md")) {
|
||||
assert(
|
||||
state.files.get("A.md") === "updated offline",
|
||||
`If A.md survived, it should have "updated offline", got: "${state.files.get("A.md")}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const concurrentDeleteUpdateTest: TestDefinition = {
|
||||
name: "Concurrent Delete and Update",
|
||||
description:
|
||||
"Client 0 and Client 1 have A.md synced. Client 0 deletes A.md while " +
|
||||
"Client 1 (offline) updates A.md. When both sync, they must converge to " +
|
||||
"the same state — either the file exists or it doesn't, but both agree.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: create and sync A.md
|
||||
{ type: "create", client: 0, path: "A.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Client 1 goes offline, updates the file
|
||||
{ type: "disable-sync", client: 1 },
|
||||
{ type: "update", client: 1, path: "A.md", content: "updated offline" },
|
||||
|
||||
// Client 0 deletes and syncs
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
// Client 1 reconnects with pending update
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Key invariant: both clients must agree on the state.
|
||||
// If A.md survived the conflict, it must have the updated content.
|
||||
{ type: "assert-consistent", verify: verifyConflictResolution }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyMergedEdits(state: ClientState): void {
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected 1 file, got ${state.files.size}`
|
||||
);
|
||||
assert(
|
||||
state.files.has("doc.md"),
|
||||
`Expected doc.md to exist`
|
||||
);
|
||||
const content = state.files.get("doc.md") ?? "";
|
||||
|
||||
// Both clients replaced the same word. The 3-way merge with
|
||||
// parent "the quick brown fox" should detect that both sides
|
||||
// changed "quick" — one to "slow" and one to "fast".
|
||||
// reconcile-text does word-level tokenization, so both
|
||||
// replacements should appear (though order may vary).
|
||||
assert(
|
||||
content.includes("slow") && content.includes("fast"),
|
||||
`Expected merged content to contain both "slow" and "fast", got: "${content}"`
|
||||
);
|
||||
assert(
|
||||
content.includes("brown fox"),
|
||||
`Expected merged content to preserve unchanged text "brown fox", got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests 3-way merge when both clients edit the exact same word in a
|
||||
* document. Client 0 replaces "quick" with "slow", Client 1 replaces
|
||||
* "quick" with "fast". The merge should detect the conflicting edits
|
||||
* and preserve both (the merge algorithm does not silently drop one).
|
||||
*
|
||||
* This is a stress test for the reconcile-text library's word-level
|
||||
* tokenizer when operating on overlapping changes at the same offset.
|
||||
*/
|
||||
export const concurrentEditExactSamePositionTest: TestDefinition = {
|
||||
name: "Concurrent Edit at Exact Same Position",
|
||||
description:
|
||||
"Both clients edit the exact same word in a file. Client 0 changes " +
|
||||
"'quick' to 'slow', Client 1 changes 'quick' to 'fast'. The 3-way " +
|
||||
"merge should detect the overlapping edit and produce a result that " +
|
||||
"preserves both changes.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: shared document
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "the quick brown fox"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "doc.md",
|
||||
content: "the quick brown fox"
|
||||
},
|
||||
|
||||
// Both clients go offline and edit the same word
|
||||
{ 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"
|
||||
},
|
||||
|
||||
// Both come online
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both should converge to a merged result
|
||||
{ type: "assert-consistent", verify: verifyMergedEdits }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* EDGE CASE: Client A renames X→Y while Client B creates at Y.
|
||||
*
|
||||
* This tests a tricky scenario where:
|
||||
* 1. Both clients know about X.md
|
||||
* 2. Client A renames X→Y (offline)
|
||||
* 3. Client B creates a NEW file at Y (offline)
|
||||
* 4. Both reconnect
|
||||
*
|
||||
* The server should handle this by:
|
||||
* - Client A's rename succeeds (X→Y)
|
||||
* - Client B's create at Y triggers a smart merge with A's renamed document
|
||||
* - Both documents' content should be preserved
|
||||
*/
|
||||
function verifyFinalState(state: ClientState): void {
|
||||
// X should not exist (renamed by A)
|
||||
assert(
|
||||
!state.files.has("X.md"),
|
||||
`X.md should not exist, files: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
|
||||
// Y should exist with merged content
|
||||
assert(
|
||||
state.files.has("Y.md"),
|
||||
`Y.md should exist, files: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
|
||||
const content = state.files.get("Y.md") ?? "";
|
||||
// Both pieces of content should be preserved through merge
|
||||
assert(
|
||||
content.includes("original file X"),
|
||||
`Expected content to include "original file X", got: "${content}"`
|
||||
);
|
||||
assert(
|
||||
content.includes("brand new Y content"),
|
||||
`Expected content to include "brand new Y content", got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const concurrentRenameAndCreateAtTargetTest: TestDefinition = {
|
||||
name: "Concurrent Rename to Path + Create at Same Path",
|
||||
description:
|
||||
"Client 0 renames X→Y while Client 1 creates a new file at Y. " +
|
||||
"Both operations happen offline. On reconnect, the server should " +
|
||||
"merge the renamed document with the created document.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: create X.md on Client 0
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "X.md",
|
||||
content: "original file X"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both go offline
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
// Client 0: rename X→Y
|
||||
{ type: "rename", client: 0, oldPath: "X.md", newPath: "Y.md" },
|
||||
|
||||
// Client 1: create Y with different content
|
||||
// (Client 1 still has X.md locally)
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "Y.md",
|
||||
content: "brand new Y content"
|
||||
},
|
||||
|
||||
// Client 0 reconnects first (rename goes through)
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
// Client 1 reconnects (create at Y triggers smart merge)
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "assert-consistent", verify: verifyFinalState }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyBothContents(state: ClientState): void {
|
||||
const files = Array.from(state.files.keys());
|
||||
|
||||
// Both documents were renamed to C.md. One gets C.md, the other should
|
||||
// be deconflicted. Both contents must be preserved.
|
||||
assert(
|
||||
state.files.size === 2,
|
||||
`Expected 2 files (both documents preserved), got ${state.files.size}: ${files.join(", ")}`
|
||||
);
|
||||
|
||||
// Neither A.md nor B.md should exist (both were renamed away)
|
||||
assert(
|
||||
!state.files.has("A.md"),
|
||||
`A.md should not exist after rename, got: ${files.join(", ")}`
|
||||
);
|
||||
assert(
|
||||
!state.files.has("B.md"),
|
||||
`B.md should not exist after rename, got: ${files.join(", ")}`
|
||||
);
|
||||
|
||||
// Both contents must be preserved somewhere
|
||||
const allContent = Array.from(state.files.values()).join("\n");
|
||||
assert(
|
||||
allContent.includes("content-a") && allContent.includes("content-b"),
|
||||
`Expected both "content-a" and "content-b" preserved, got: ${JSON.stringify(Object.fromEntries(state.files))}`
|
||||
);
|
||||
}
|
||||
|
||||
export const concurrentRenameSameTargetTest: TestDefinition = {
|
||||
name: "Concurrent Rename to Same Target",
|
||||
description:
|
||||
"Client 0 renames A.md to C.md while Client 1 (offline) renames B.md to C.md. " +
|
||||
"Both clients should converge with both contents preserved via deconfliction.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: create A.md and B.md, sync both
|
||||
{ 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: "barrier" },
|
||||
|
||||
// Client 1 goes offline
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
// Client 0 renames A.md to C.md and syncs
|
||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "C.md" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
// Client 1 renames B.md to C.md while offline
|
||||
{ type: "rename", client: 1, oldPath: "B.md", newPath: "C.md" },
|
||||
|
||||
// Client 1 reconnects
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both contents should be preserved somewhere
|
||||
{ type: "assert-consistent", verify: verifyBothContents }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* Invariant #7: parentVersionId must be consistent with cached content.
|
||||
*
|
||||
* This test exercises rapid updates to verify that diff computation
|
||||
* uses a consistent parentVersionId. Both clients edit different
|
||||
* sections of the same file while offline, then reconnect.
|
||||
*/
|
||||
function verifyBothEdits(state: ClientState): void {
|
||||
assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`);
|
||||
const content = state.files.get("doc.md") ?? "";
|
||||
assert(
|
||||
content.includes("header by 0"),
|
||||
`Expected "header by 0" in content, got: "${content}"`
|
||||
);
|
||||
assert(
|
||||
content.includes("footer by 1"),
|
||||
`Expected "footer by 1" in content, got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const concurrentUpdateDiffConsistencyTest: TestDefinition = {
|
||||
name: "Concurrent Updates Use Consistent Diff Base",
|
||||
description:
|
||||
"Rapid updates from both clients must produce correct merged " +
|
||||
"content, verifying parentVersionId consistency.",
|
||||
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: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both edit different sections offline
|
||||
{ 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"
|
||||
},
|
||||
|
||||
// Come online
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "assert-consistent", verify: verifyBothEdits }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const createDeleteNoopTest: TestDefinition = {
|
||||
name: "Create-Delete Noop",
|
||||
description:
|
||||
"Client 0 (offline) creates a file, updates it multiple times, then deletes it. " +
|
||||
"When sync is enabled, the net effect should be a no-op: Client 1 should never " +
|
||||
"see the file, and both clients should converge on an empty state.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 1 },
|
||||
|
||||
// Client 0 performs create → update → update → delete while offline
|
||||
{ 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" },
|
||||
|
||||
// Enable sync — reconciliation should find nothing to do
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Neither client should have the file
|
||||
{ type: "assert-not-exists", client: 0, path: "temp.md" },
|
||||
{ type: "assert-not-exists", client: 1, path: "temp.md" },
|
||||
{ type: "assert-consistent" }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* EDGE CASE: New file created during offline reconciliation.
|
||||
*
|
||||
* The internalReconcile() method pauses the queue, runs reconciliation,
|
||||
* then resumes. But file changes can happen DURING reconciliation:
|
||||
*
|
||||
* 1. Client goes offline, creates files A.md and B.md
|
||||
* 2. Client reconnects → internalReconcile starts
|
||||
* 3. reconcileWithDisk scans filesystem, finds A.md and B.md
|
||||
* 4. Events are enqueued for both files
|
||||
* 5. Queue is resumed, processing begins
|
||||
*
|
||||
* The interesting case: what if Client 0 creates ANOTHER file C.md
|
||||
* right after reconnect but before reconciliation finishes? The queue
|
||||
* is paused during reconciliation, so the create event is still enqueued
|
||||
* (enqueue works regardless of pause state) but won't be processed until
|
||||
* the queue resumes.
|
||||
*
|
||||
* This test verifies that all three files eventually sync correctly.
|
||||
*/
|
||||
function verifyAllFiles(state: ClientState): void {
|
||||
assert(
|
||||
state.files.size === 3,
|
||||
`Expected 3 files, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.has("A.md") &&
|
||||
state.files.has("B.md") &&
|
||||
state.files.has("C.md"),
|
||||
`Expected A.md, B.md, C.md. Got: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.get("A.md") === "offline A",
|
||||
`Expected A.md = "offline A", got: "${state.files.get("A.md")}"`
|
||||
);
|
||||
assert(
|
||||
state.files.get("B.md") === "offline B",
|
||||
`Expected B.md = "offline B", got: "${state.files.get("B.md")}"`
|
||||
);
|
||||
assert(
|
||||
state.files.get("C.md") === "post-reconnect C",
|
||||
`Expected C.md = "post-reconnect C", got: "${state.files.get("C.md")}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const createDuringReconciliationTest: TestDefinition = {
|
||||
name: "File Created Right After Reconnect (During Reconciliation)",
|
||||
description:
|
||||
"Client creates files while offline, reconnects, then immediately " +
|
||||
"creates another file. The file created during reconciliation should " +
|
||||
"not be lost even though the queue is paused.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Client 0 goes offline, creates two files
|
||||
{ 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"
|
||||
},
|
||||
|
||||
// Client 0 reconnects
|
||||
{ type: "enable-sync", client: 0 },
|
||||
|
||||
// Immediately create another file (before sync finishes)
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "C.md",
|
||||
content: "post-reconnect C"
|
||||
},
|
||||
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "assert-consistent", verify: verifyAllFiles }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyMergedContent(state: ClientState): void {
|
||||
assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`);
|
||||
assert(state.files.has("A.md"), "Expected A.md to exist");
|
||||
const content = state.files.get("A.md") ?? "";
|
||||
assert(
|
||||
content.includes("from-zero") && content.includes("from-one"),
|
||||
`Expected A.md to contain both "from-zero" and "from-one", got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
function verifyEmpty(state: ClientState): void {
|
||||
assert(
|
||||
state.files.size === 0,
|
||||
`Expected 0 files after deletion, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
export const createMergeDeleteTest: TestDefinition = {
|
||||
name: "Concurrent Create, Merge, Then Delete",
|
||||
description:
|
||||
"Two clients simultaneously create A.md with different content. " +
|
||||
"The server merges them and both converge. Then Client 0 deletes A.md. " +
|
||||
"Both clients should converge on an empty state.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Both clients create A.md offline with different content
|
||||
{ type: "create", client: 0, path: "A.md", content: "from-zero" },
|
||||
{ type: "create", client: 1, path: "A.md", content: "from-one" },
|
||||
|
||||
// Enable sync — both creates race to the server
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Phase 1: verify merge happened correctly
|
||||
{ type: "assert-consistent", verify: verifyMergedContent },
|
||||
|
||||
// Phase 2: Client 0 deletes the merged file
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both clients should have no files
|
||||
{ type: "assert-not-exists", client: 0, path: "A.md" },
|
||||
{ type: "assert-not-exists", client: 1, path: "A.md" },
|
||||
{ type: "assert-consistent", verify: verifyEmpty }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* BUG FIX: When a create-merge returns an existing documentId, the stale
|
||||
* tracked record at a different path must NOT have its file deleted if the
|
||||
* file contains unsynchronized local modifications.
|
||||
*
|
||||
* Scenario (simplified from E2E log_4 failure):
|
||||
* 1. Both clients create "doc.md" → server merges → both have docX
|
||||
* 2. Client 1 goes offline, renames "doc.md" → "moved.md", updates it
|
||||
* 3. Client 1 also creates a new file at the OLD path "doc.md"
|
||||
* 4. Client 1 comes back online
|
||||
* 5. The update at "doc.md" sends new content to the server (overwriting docX)
|
||||
* 6. The create for "moved.md" may merge on the server
|
||||
* 7. The content appended in step 2 must still be present somewhere
|
||||
*
|
||||
* Previously, ensureUniqueDocumentId would delete the renamed file even
|
||||
* if it had unsynchronized local modifications, silently losing data.
|
||||
*/
|
||||
function verifyAllContentPreserved(state: ClientState): void {
|
||||
const allContent = [...state.files.values()].join("\n");
|
||||
assert(
|
||||
allContent.includes("extra-update"),
|
||||
`Expected "extra-update" to be preserved somewhere in the files, but got:\n${[...state.files.entries()].map(([k, v]) => ` ${k}: "${v}"`).join("\n")}`
|
||||
);
|
||||
}
|
||||
|
||||
export const createMergePreservesRenamedUpdateTest: TestDefinition = {
|
||||
name: "Create-Merge Preserves Renamed File With Local Updates",
|
||||
description:
|
||||
"When a create request merges with an existing document, " +
|
||||
"a renamed copy of that document with unsynchronized updates " +
|
||||
"must not be deleted.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: both clients create at the same path → server merges
|
||||
{ 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: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Client 1 goes offline and makes local changes
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
// Rename the merged doc to a new path and update it
|
||||
{
|
||||
type: "rename",
|
||||
client: 1,
|
||||
oldPath: "doc.md",
|
||||
newPath: "moved.md"
|
||||
},
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "moved.md",
|
||||
content: "alpha beta extra-update"
|
||||
},
|
||||
|
||||
// Create a new file at the original path
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "doc.md",
|
||||
content: "new-content"
|
||||
},
|
||||
|
||||
// Come back online — the reconciliation will detect:
|
||||
// - "doc.md" in VFS (tracked) but with different content → update
|
||||
// - "moved.md" not in VFS → create
|
||||
// The create for "moved.md" may merge with the server's doc,
|
||||
// triggering ensureUniqueDocumentId
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Verify: "extra-update" must still exist in some file
|
||||
{ type: "assert-consistent", verify: verifyAllContentPreserved }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* BUG: create → rename → create at same path while offline.
|
||||
*
|
||||
* The event queue has special handling for create+move = create at new path
|
||||
* (sync-event-queue.ts line 56-68), which migrates the key from the old
|
||||
* path to the new path. This frees the old path key for a subsequent create.
|
||||
*
|
||||
* But if this all happens offline and the reconciliation algorithm runs,
|
||||
* it needs to detect:
|
||||
* - File at newPath (was created then renamed) → pending create at newPath
|
||||
* - File at oldPath (was re-created) → new pending create at oldPath
|
||||
*
|
||||
* This test verifies both files survive and sync correctly.
|
||||
*/
|
||||
function verifyBothFiles(state: ClientState): void {
|
||||
assert(
|
||||
state.files.size === 2,
|
||||
`Expected 2 files, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.has("A.md"),
|
||||
`Expected A.md to exist, files: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.has("B.md"),
|
||||
`Expected B.md to exist, files: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.get("A.md") === "second file at A",
|
||||
`Expected A.md = "second file at A", got: "${state.files.get("A.md")}"`
|
||||
);
|
||||
assert(
|
||||
state.files.get("B.md") === "first file moved to B",
|
||||
`Expected B.md = "first file moved to B", got: "${state.files.get("B.md")}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const createRenameCreateSamePathOfflineTest: TestDefinition = {
|
||||
name: "Create → Rename → Create at Same Path (Offline)",
|
||||
description:
|
||||
"While offline, Client 0 creates A.md, renames it to B.md, then " +
|
||||
"creates a new A.md. Both files should sync to Client 1.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Client 0 goes offline
|
||||
{ type: "disable-sync", client: 0 },
|
||||
|
||||
// Create A.md
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "first file moved to B"
|
||||
},
|
||||
|
||||
// Rename A.md → B.md
|
||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
||||
|
||||
// Create a new A.md
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "second file at A"
|
||||
},
|
||||
|
||||
// Reconnect
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both files should exist on both clients
|
||||
{ type: "assert-consistent", verify: verifyBothFiles }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyThreeFiles(state: ClientState): void {
|
||||
const files = Array.from(state.files.keys()).sort();
|
||||
assert(
|
||||
state.files.size === 3,
|
||||
`Expected 3 files, got ${state.files.size}: ${files.join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.has("B.md"),
|
||||
`Expected B.md (first file renamed), got: ${files.join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.has("C.md"),
|
||||
`Expected C.md (second file renamed), got: ${files.join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.has("A.md"),
|
||||
`Expected A.md (third file still at original path), got: ${files.join(", ")}`
|
||||
);
|
||||
|
||||
const bContent = state.files.get("B.md") ?? "";
|
||||
const cContent = state.files.get("C.md") ?? "";
|
||||
const aContent = state.files.get("A.md") ?? "";
|
||||
assert(
|
||||
bContent === "first file",
|
||||
`Expected B.md to contain "first file", got: "${bContent}"`
|
||||
);
|
||||
assert(
|
||||
cContent === "second file",
|
||||
`Expected C.md to contain "second file", got: "${cContent}"`
|
||||
);
|
||||
assert(
|
||||
aContent === "third file",
|
||||
`Expected A.md to contain "third file", got: "${aContent}"`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* BUG: Tests the queue key migration for pending creates. When a file
|
||||
* is created at path A, then renamed to B (freeing path A), then a new
|
||||
* file is created at A, the event coalescing must migrate the first
|
||||
* create's key from "path:A" to "path:B" so the second create doesn't
|
||||
* coalesce with the first.
|
||||
*
|
||||
* Without key migration (lines 54-68 in sync-event-queue.ts), the
|
||||
* second create at "path:A" would find the first create's state and
|
||||
* coalesce with it, losing the second file.
|
||||
*/
|
||||
export const createRenameCreateSamePathTest: TestDefinition = {
|
||||
name: "Create-Rename-Create at Same Path (Three Files)",
|
||||
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. Tests queue key migration when pending " +
|
||||
"creates are renamed before sync.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Create first file at A.md, rename to B.md
|
||||
{ type: "create", client: 0, path: "A.md", content: "first file" },
|
||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
||||
|
||||
// Create second file at A.md (now free), rename to C.md
|
||||
{ type: "create", client: 0, path: "A.md", content: "second file" },
|
||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "C.md" },
|
||||
|
||||
// Create third file at A.md
|
||||
{ type: "create", client: 0, path: "A.md", content: "third file" },
|
||||
|
||||
// Enable sync
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// All three files should exist on both clients
|
||||
{ type: "assert-consistent", verify: verifyThreeFiles }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* Regression guard for the create+rename race from e2e log_4.log.
|
||||
*
|
||||
* In the e2e test, timing jitter caused the HTTP response to arrive
|
||||
* between the create and rename being coalesced by the sync queue,
|
||||
* orphaning the document. This is documented in CLAUDE.md as a known
|
||||
* limitation of concurrent creates at the same path.
|
||||
*
|
||||
* The deterministic test framework serializes steps, so the event
|
||||
* coalescing correctly handles the create+rename sequence here.
|
||||
* This test serves as a regression guard — if the coalescing logic
|
||||
* changes, this test will catch regressions.
|
||||
*/
|
||||
function verifyBothClientsHaveContent(state: ClientState): void {
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected exactly 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
const [content] = Array.from(state.files.values());
|
||||
assert(
|
||||
content === "the-content",
|
||||
`Expected file to have "the-content", got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const createRenameResponseSkipsFileTest: TestDefinition = {
|
||||
name: "Create Then Immediate Rename — File Not Lost",
|
||||
description:
|
||||
"Client creates a file online then immediately renames it. " +
|
||||
"The create response arrives at the original path. " +
|
||||
"The other client must receive the file content.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Both clients online
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Client 0 creates doc.md while online (HTTP request fires immediately)
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "the-content"
|
||||
},
|
||||
|
||||
// Immediately rename — the create request is already in-flight
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "doc.md",
|
||||
newPath: "renamed.md"
|
||||
},
|
||||
|
||||
// Let everything sync
|
||||
{ type: "sync" },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both clients must have the content (at whatever path)
|
||||
{ type: "assert-consistent", verify: verifyBothClientsHaveContent }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyFinalContent(state: ClientState): void {
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.has("doc.md"),
|
||||
`Expected doc.md to exist`
|
||||
);
|
||||
const content = state.files.get("doc.md") ?? "";
|
||||
assert(
|
||||
content === "final version",
|
||||
`Expected doc.md to have "final version", got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const createUpdateCoalesceServerPauseTest: TestDefinition = {
|
||||
name: "Create + Update Coalescing During Server Pause",
|
||||
description:
|
||||
"Client 0 creates a file and immediately updates it while the server " +
|
||||
"is paused. Both operations should coalesce in the queue. When the " +
|
||||
"server resumes, the final content should be the updated version.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
|
||||
// Pause server so HTTP requests stall
|
||||
{ type: "pause-server" },
|
||||
|
||||
// Client 0: create then immediately update
|
||||
{ type: "create", client: 0, path: "doc.md", content: "initial" },
|
||||
{ type: "update", client: 0, path: "doc.md", content: "final version" },
|
||||
|
||||
// Wait a bit for requests to queue up
|
||||
|
||||
// Resume server
|
||||
{ type: "resume-server" },
|
||||
|
||||
// Both sync
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Final state: doc.md with "final version" on both clients
|
||||
{ type: "assert-consistent", verify: verifyFinalContent }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const createWhileServerPausedTest: TestDefinition = {
|
||||
name: "Create While Server Paused Then Resume",
|
||||
description:
|
||||
"Server is paused. Client 0 creates a file (request will stall). " +
|
||||
"Then server resumes. File should sync to Client 1.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Pause server first, then create
|
||||
{ type: "pause-server" },
|
||||
{ type: "create", client: 0, path: "paused-create.md", content: "created during pause" },
|
||||
{ type: "resume-server" },
|
||||
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "assert-exists", client: 0, path: "paused-create.md" },
|
||||
{ type: "assert-exists", client: 1, path: "paused-create.md" },
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "paused-create.md",
|
||||
content: "created during pause"
|
||||
},
|
||||
{ type: "assert-consistent" }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* EDGE CASE: File deleted locally while a create request is in-flight.
|
||||
*
|
||||
* The create request succeeds on the server, but by the time
|
||||
* applyServerResponse runs, the document has been removed from pathIndex
|
||||
* (deleted locally). The code at sync-actions.ts line 256-283 handles this:
|
||||
* it confirms the create (so the server has a documentId), then immediately
|
||||
* marks it as deleted-locally so the delete can be sent to the server.
|
||||
*
|
||||
* This test verifies that:
|
||||
* 1. The file is properly deleted on both clients
|
||||
* 2. No orphaned documents exist on the server
|
||||
* 3. No duplicate documentIds in the VFS
|
||||
*/
|
||||
function verifyNoFiles(state: ClientState): void {
|
||||
assert(
|
||||
state.files.size === 0,
|
||||
`Expected 0 files, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
export const deleteDuringPendingCreateTest: TestDefinition = {
|
||||
name: "Delete During Pending Create (Server Paused)",
|
||||
description:
|
||||
"Client creates a file, server is paused so the create request stalls. " +
|
||||
"Client then deletes the file while the create is in-flight. When the " +
|
||||
"server resumes, the create succeeds but the file should still end up " +
|
||||
"deleted on both clients.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Pause server so the create request stalls
|
||||
{ type: "pause-server" },
|
||||
|
||||
// Client 0 creates a file (HTTP request will stall)
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "ephemeral.md",
|
||||
content: "this will be deleted"
|
||||
},
|
||||
|
||||
// Wait a bit to ensure the create is queued
|
||||
|
||||
// Client 0 deletes the file while create is pending
|
||||
{ type: "delete", client: 0, path: "ephemeral.md" },
|
||||
|
||||
// Resume server — the create request completes, then delete follows
|
||||
{ type: "resume-server" },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// File should be gone on both clients
|
||||
{ type: "assert-not-exists", client: 0, path: "ephemeral.md" },
|
||||
{ type: "assert-not-exists", client: 1, path: "ephemeral.md" },
|
||||
{ type: "assert-consistent", verify: verifyNoFiles }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const deleteNonexistentFileTest: TestDefinition = {
|
||||
name: "Delete Propagation",
|
||||
description:
|
||||
"Both clients have A.md. Client 0 deletes it and syncs. Client 1 receives " +
|
||||
"the delete via broadcast. Both clients should converge on an empty state.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: create and sync
|
||||
{ type: "create", client: 0, path: "A.md", content: "ephemeral" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Client 0 deletes and syncs
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both should agree A.md is gone
|
||||
{ type: "assert-not-exists", client: 0, path: "A.md" },
|
||||
{ type: "assert-not-exists", client: 1, path: "A.md" },
|
||||
{ type: "assert-consistent" }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyConvergence(state: ClientState): void {
|
||||
const files = Array.from(state.files.keys()).sort();
|
||||
|
||||
// A.md should exist — the recreate creates a new document
|
||||
assert(
|
||||
state.files.has("A.md"),
|
||||
`Expected A.md to exist. Files: ${files.join(", ")}`
|
||||
);
|
||||
|
||||
const content = state.files.get("A.md") ?? "";
|
||||
|
||||
// The recreated content must be present. Client 1's update targeted
|
||||
// the old (deleted) document, so it may also appear if the server
|
||||
// merged both — but at minimum the recreated content must survive.
|
||||
assert(
|
||||
content.includes("recreated"),
|
||||
`Expected A.md to contain "recreated" from client 0's recreate, got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const deleteRecreateConcurrentUpdateTest: TestDefinition = {
|
||||
name: "Delete + Recreate with Concurrent Remote Update",
|
||||
description:
|
||||
"Client 0 deletes A.md and recreates it with new content while offline. " +
|
||||
"Client 1 (online) updates A.md with different content. When Client 0 " +
|
||||
"reconnects, the system must reconcile the delete-recreate with the " +
|
||||
"concurrent update. Both clients must converge.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup
|
||||
{ type: "create", client: 0, path: "A.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Client 0 goes offline, deletes and recreates
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
{ type: "create", client: 0, path: "A.md", content: "recreated by client 0" },
|
||||
|
||||
// Client 1 updates the same file concurrently
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "A.md",
|
||||
content: "updated by client 1"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Client 0 reconnects
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both clients must converge
|
||||
{ type: "assert-consistent", verify: verifyConvergence }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* EDGE CASE: Delete and immediately recreate at the same path with
|
||||
* different content, while the other client is editing.
|
||||
*
|
||||
* This exercises the coalescing path: delete + create = create.
|
||||
* But the tricky part is that the ORIGINAL document at this path
|
||||
* was tracked (had a documentId). The delete marks it as deleted-locally.
|
||||
* The subsequent create makes a NEW pending document at the same path.
|
||||
*
|
||||
* Meanwhile, Client 1 has been editing the same file. When both sync:
|
||||
* - Client 0's delete should go through first
|
||||
* - Client 0's create creates a NEW document on the server
|
||||
* - Client 1's edit to the OLD document may conflict
|
||||
*
|
||||
* The coalescing turns delete+create into just "create". But the executor
|
||||
* for "create" at sync-actions.ts line 247 checks the VFS: if a tracked
|
||||
* doc exists at the path, it treats the create as an update instead.
|
||||
* Since the delete was coalesced away, the tracked doc STILL exists
|
||||
* in the VFS at the time of execution → the "create" is treated as an
|
||||
* update to the existing document, not a new document.
|
||||
*
|
||||
* This might be correct (updates the existing doc with new content) or
|
||||
* might be a bug (should create a new documentId). The test verifies
|
||||
* convergence either way.
|
||||
*/
|
||||
function verifyFinalState(state: ClientState): void {
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
assert(state.files.has("A.md"), "Expected A.md to exist");
|
||||
const content = state.files.get("A.md") ?? "";
|
||||
// Both client contents should be merged (empty-parent 3-way merge)
|
||||
assert(
|
||||
content.includes("brand new content") &&
|
||||
content.includes("edit from client 1"),
|
||||
`Expected merged content with both edits, got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const deleteRecreateDifferentContentTest: TestDefinition = {
|
||||
name: "Delete + Recreate Same Path While Other Client Edits",
|
||||
description:
|
||||
"Client 0 deletes and recreates A.md with new content while " +
|
||||
"Client 1 edits A.md. The coalesced delete+create should produce " +
|
||||
"correct behavior and both clients should converge.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: create A.md
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "original content here"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both go offline
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
// Client 0: delete and recreate with new content
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "brand new content"
|
||||
},
|
||||
|
||||
// Client 1: edit the same file
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "A.md",
|
||||
content: "edit from client 1"
|
||||
},
|
||||
|
||||
// Reconnect both
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "assert-consistent", verify: verifyFinalState }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const deleteRecreateSamePathTest: TestDefinition = {
|
||||
name: "Delete Then Recreate at Same Path",
|
||||
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: [
|
||||
// Setup: create and sync A.md
|
||||
{ type: "create", client: 0, path: "A.md", content: "version 1" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
{ type: "assert-content", client: 1, path: "A.md", content: "version 1" },
|
||||
|
||||
// Client 0 deletes then recreates A.md with new content
|
||||
{ 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: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both clients should have the new content
|
||||
{ type: "assert-exists", client: 0, path: "A.md" },
|
||||
{ type: "assert-exists", client: 1, path: "A.md" },
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "version 2"
|
||||
},
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "A.md",
|
||||
content: "version 2"
|
||||
},
|
||||
{ type: "assert-consistent" }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyConflictResolution(state: ClientState): void {
|
||||
const files = Array.from(state.files.keys());
|
||||
|
||||
// B.md must exist (unaffected by the conflict)
|
||||
assert(
|
||||
state.files.has("B.md"),
|
||||
`Expected B.md to exist, got: ${files.join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.get("B.md") === "content-b",
|
||||
`Expected B.md to have "content-b", got: "${state.files.get("B.md")}"`
|
||||
);
|
||||
|
||||
// A.md should not exist (either deleted or renamed away)
|
||||
assert(
|
||||
!state.files.has("A.md"),
|
||||
`A.md should not exist after conflict resolution, got: ${files.join(", ")}`
|
||||
);
|
||||
|
||||
// If C.md exists (rename won over delete), it should have content-a
|
||||
if (state.files.has("C.md")) {
|
||||
assert(
|
||||
state.files.get("C.md") === "content-a",
|
||||
`If C.md exists, it should have "content-a", got: "${state.files.get("C.md")}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const deleteRenameConflictTest: TestDefinition = {
|
||||
name: "Delete vs Rename Conflict",
|
||||
description:
|
||||
"Client 0 deletes A.md while Client 1 (offline) renames A.md to C.md. " +
|
||||
"When Client 1 reconnects, the system must reconcile the conflicting " +
|
||||
"operations. Both clients should converge to the same state.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: create A.md and B.md, sync to both clients
|
||||
{ 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: "barrier" },
|
||||
{ type: "assert-exists", client: 1, path: "A.md" },
|
||||
{ type: "assert-exists", client: 1, path: "B.md" },
|
||||
|
||||
// Client 1 goes offline
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
// Client 0 deletes A.md and syncs
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
// Client 1 (offline) renames A.md to C.md
|
||||
{ type: "rename", client: 1, oldPath: "A.md", newPath: "C.md" },
|
||||
|
||||
// Client 1 reconnects
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both clients must converge — the key invariant is consistency.
|
||||
// B.md should still exist on both (unaffected by the conflict).
|
||||
{ type: "assert-exists", client: 0, path: "B.md" },
|
||||
{ type: "assert-exists", client: 1, path: "B.md" },
|
||||
{ type: "assert-consistent", verify: verifyConflictResolution }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyAllEdits(state: ClientState): void {
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected 1 file, got ${state.files.size}`
|
||||
);
|
||||
assert(
|
||||
state.files.has("doc.md"),
|
||||
`Expected doc.md to exist`
|
||||
);
|
||||
const content = state.files.get("doc.md") ?? "";
|
||||
assert(
|
||||
content === "third edit",
|
||||
`Expected doc.md to contain "third edit", got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests two consecutive offline→online cycles. Client 0 goes offline,
|
||||
* edits, comes online (first cycle). Then goes offline again, edits
|
||||
* more, comes online (second cycle). All edits should propagate to
|
||||
* Client 1.
|
||||
*
|
||||
* This exercises the runningReconciliation lifecycle: it must be
|
||||
* cleared after the first cycle so the second reconnect triggers a
|
||||
* fresh filesystem scan.
|
||||
*/
|
||||
export const doubleOfflineCycleTest: TestDefinition = {
|
||||
name: "Double Offline Cycle",
|
||||
description:
|
||||
"Client 0 goes offline, edits, comes online, syncs. Then goes " +
|
||||
"offline again, edits more, comes online again. Both offline edits " +
|
||||
"must propagate to Client 1. Tests that runningReconciliation is " +
|
||||
"properly cleared between cycles.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: create and sync
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "initial"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "doc.md",
|
||||
content: "initial"
|
||||
},
|
||||
|
||||
// First offline cycle: edit
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "first edit"
|
||||
},
|
||||
|
||||
// Come online, sync first edit
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "doc.md",
|
||||
content: "first edit"
|
||||
},
|
||||
|
||||
// Second offline cycle: edit again
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "second edit"
|
||||
},
|
||||
|
||||
// Come online, sync second edit
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "doc.md",
|
||||
content: "second edit"
|
||||
},
|
||||
|
||||
// Third offline cycle: edit once more
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "third edit"
|
||||
},
|
||||
|
||||
// Come online, sync third edit
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
{ type: "assert-consistent", verify: verifyAllEdits }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyBothFilesExist(state: ClientState): void {
|
||||
assert(
|
||||
state.files.size === 2,
|
||||
`Expected 2 files, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
assert(state.files.has("original.md"), "Expected original.md to exist");
|
||||
assert(state.files.has("copy.md"), "Expected copy.md to exist");
|
||||
assert(
|
||||
state.files.get("original.md") === "same content",
|
||||
`original.md has wrong content: "${state.files.get("original.md")}"`
|
||||
);
|
||||
assert(
|
||||
state.files.get("copy.md") === "same content",
|
||||
`copy.md has wrong content: "${state.files.get("copy.md")}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const duplicateContentFilesTest: TestDefinition = {
|
||||
name: "Duplicate Content Files Preserved",
|
||||
description:
|
||||
"Client 0 creates two files with identical content. Both should sync " +
|
||||
"to Client 1 without the duplicate detection deleting one of them.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Create two files with identical content while offline
|
||||
{ type: "create", client: 0, path: "original.md", content: "same content" },
|
||||
{ type: "create", client: 0, path: "copy.md", content: "same content" },
|
||||
|
||||
// Enable sync
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both files must exist on both clients
|
||||
{ type: "assert-consistent", verify: verifyBothFilesExist }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyEmptyFile(state: ClientState): void {
|
||||
assert(state.files.has("empty.md"), "Expected empty.md to exist");
|
||||
assert(
|
||||
state.files.get("empty.md") === "",
|
||||
`Expected empty.md to be empty, got: "${state.files.get("empty.md")}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const emptyFileSyncTest: TestDefinition = {
|
||||
name: "Empty File Sync",
|
||||
description:
|
||||
"Client 0 creates an empty file. It should sync to Client 1 as empty. " +
|
||||
"Then Client 0 adds content. The update should propagate correctly.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Create empty file
|
||||
{ type: "create", client: 0, path: "empty.md", content: "" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Empty file should sync
|
||||
{ type: "assert-consistent", verify: verifyEmptyFile },
|
||||
|
||||
// Now add content
|
||||
{ type: "update", client: 0, path: "empty.md", content: "no longer empty" },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Updated content should propagate
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 0,
|
||||
path: "empty.md",
|
||||
content: "no longer empty"
|
||||
},
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "empty.md",
|
||||
content: "no longer empty"
|
||||
},
|
||||
{ type: "assert-consistent" }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* Tests rename-overwrite behavior: when file A is renamed to file B's
|
||||
* path (overwriting B), both clients should converge on a single file
|
||||
* at the target path with A's content.
|
||||
*/
|
||||
function verifyOneFile(state: ClientState): void {
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected 1 file, got ${state.files.size}: ${[...state.files.keys()].join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.has("B.md"),
|
||||
`Expected B.md to exist, got: ${[...state.files.keys()].join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.get("B.md") === "content A",
|
||||
`Expected B.md to have A's content, got: "${state.files.get("B.md")}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const failedVfsMoveFallsBackTest: TestDefinition = {
|
||||
name: "Rename Overwrite — A.md Renamed to Occupied B.md",
|
||||
description:
|
||||
"File A is renamed to B's path (overwriting B). Both clients " +
|
||||
"should converge on a single file at B.md with A's content.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: create two files
|
||||
{ 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: "barrier" },
|
||||
|
||||
// Client 0 renames A.md to B.md (overwrite)
|
||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both clients should have only B.md
|
||||
{ type: "assert-consistent", verify: verifyOneFile }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyNoDuplicates(state: ClientState): void {
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected exactly 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.has("doc.md"),
|
||||
`Expected doc.md to exist, got: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
const content = state.files.get("doc.md") ?? "";
|
||||
assert(
|
||||
content === "important data",
|
||||
`Expected doc.md content to be "important data", got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const idempotencyAfterServerPauseTest: TestDefinition = {
|
||||
name: "Idempotency Key Prevents Duplicates After Server Pause",
|
||||
description:
|
||||
"Client 0 creates a file. The server is paused mid-response (SIGSTOP), " +
|
||||
"so the client's HTTP request stalls. When the server resumes, the " +
|
||||
"idempotency key should prevent duplicate documents from being created. " +
|
||||
"Both clients must converge to a single copy of the file.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Both clients online
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Client 0 creates a file, then immediately pause the server so the
|
||||
// response is stalled (the server may or may not have committed the
|
||||
// create — either way the idempotency key protects us).
|
||||
{ type: "create", client: 0, path: "doc.md", content: "important data" },
|
||||
{ type: "pause-server" },
|
||||
|
||||
// Wait with server frozen — client's in-flight create request is stuck.
|
||||
|
||||
// Resume the server. The stalled request completes (or the client
|
||||
// retries with the same idempotency key).
|
||||
{ type: "resume-server" },
|
||||
|
||||
// Sync and converge
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// There must be exactly one doc.md with the correct content — no
|
||||
// duplicates like "doc (1).md".
|
||||
{ type: "assert-consistent", verify: verifyNoDuplicates }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const interleavedOperationsTest: TestDefinition = {
|
||||
name: "Interleaved Create-Update-Delete Across Clients",
|
||||
description:
|
||||
"Client 0 creates files A, B, C. Client 1 syncs. Then Client 0 deletes A, " +
|
||||
"Client 1 updates B, Client 0 renames C to D — all interleaved. " +
|
||||
"Both should converge to the same final state.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: create 3 files
|
||||
{ type: "create", client: 0, path: "A.md", content: "aaa" },
|
||||
{ type: "create", client: 0, path: "B.md", content: "bbb" },
|
||||
{ type: "create", client: 0, path: "C.md", content: "ccc" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Interleaved operations (both clients online)
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
{ type: "update", client: 1, path: "B.md", content: "bbb-updated" },
|
||||
{ type: "rename", client: 0, oldPath: "C.md", newPath: "D.md" },
|
||||
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// A.md deleted, B.md updated, C.md renamed to D.md
|
||||
{ type: "assert-not-exists", client: 0, path: "A.md" },
|
||||
{ type: "assert-not-exists", client: 1, path: "A.md" },
|
||||
{ type: "assert-exists", client: 0, path: "B.md" },
|
||||
{ type: "assert-exists", client: 1, path: "B.md" },
|
||||
{ type: "assert-not-exists", client: 0, path: "C.md" },
|
||||
{ type: "assert-not-exists", client: 1, path: "C.md" },
|
||||
{ type: "assert-exists", client: 0, path: "D.md" },
|
||||
{ type: "assert-exists", client: 1, path: "D.md" },
|
||||
{ type: "assert-consistent" }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* BUG FIX TEST: Interrupted deletes must be retried after reconnect.
|
||||
*
|
||||
* Scenario:
|
||||
* 1. Client 0 creates a file, syncs to both clients.
|
||||
* 2. Client 0 deletes the file.
|
||||
* 3. Server is paused BEFORE the delete HTTP request completes.
|
||||
* The doc transitions to deleted-locally but the server never receives the delete.
|
||||
* 4. Server resumes. Client reconnects and runs reconciliation.
|
||||
* 5. The interrupted delete should be retried and succeed.
|
||||
* 6. Both clients should converge on 0 files.
|
||||
*/
|
||||
function verifyNoFiles(state: ClientState): void {
|
||||
assert(state.files.size === 0, `Expected 0 files, got ${state.files.size}: ${[...state.files.keys()].join(", ")}`);
|
||||
}
|
||||
|
||||
export const interruptedDeleteRetryTest: TestDefinition = {
|
||||
name: "Interrupted Delete Is Retried After Reconnect",
|
||||
description:
|
||||
"A delete that was interrupted by a server pause/disconnect " +
|
||||
"should be retried when the connection is restored.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: create file, sync both
|
||||
{ type: "create", client: 0, path: "doc.md", content: "to be deleted" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Client 0 deletes the file
|
||||
{ type: "delete", client: 0, path: "doc.md" },
|
||||
|
||||
// Pause server to interrupt the delete request
|
||||
{ type: "pause-server" },
|
||||
|
||||
// Resume server - the interrupted delete should be retried
|
||||
{ type: "resume-server" },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both clients should have 0 files
|
||||
{ type: "assert-consistent", verify: verifyNoFiles },
|
||||
],
|
||||
};
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* BUG: Queue key migration can drop events when the new key already has events.
|
||||
*
|
||||
* In sync-event-queue.ts line 94-98, migrateKey() silently drops events
|
||||
* from the old key if the new key (documentId) already has queued events.
|
||||
* The comment says "Keep the existing state at the new key (it's more
|
||||
* recent)" — but the old key's state may contain unsynced local changes.
|
||||
*
|
||||
* Scenario:
|
||||
* 1. Client creates file A.md (pending, key = "path:A.md")
|
||||
* 2. Server assigns documentId via resolveIdempotencyKeys
|
||||
* 3. BEFORE the key migration, a local-update event for A.md arrives
|
||||
* and gets queued under "path:A.md" (because the doc is still pending
|
||||
* at that point in the resolveKey lookup)
|
||||
* 4. Meanwhile, a remote-update broadcast arrives for the same documentId
|
||||
* and gets queued under the documentId key
|
||||
* 5. migrateKey runs: old key has "update", new key has "remote-update"
|
||||
* 6. The old key's "update" is DROPPED — the local edit is lost
|
||||
*
|
||||
* This test simulates a similar scenario: Client 0 creates a file and
|
||||
* immediately updates it. While the create is being resolved, the update
|
||||
* should not be lost.
|
||||
*/
|
||||
function verifyUpdatedContent(state: ClientState): void {
|
||||
assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`);
|
||||
assert(state.files.has("A.md"), "Expected A.md to exist");
|
||||
const content = state.files.get("A.md") ?? "";
|
||||
assert(
|
||||
content === "updated content",
|
||||
`Expected "updated content", got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const keyMigrationEventDropTest: TestDefinition = {
|
||||
name: "Key Migration Does Not Drop Local Updates",
|
||||
description:
|
||||
"Client creates a file and immediately updates it before the create " +
|
||||
"is acknowledged. The queue key migrates from path-based to documentId. " +
|
||||
"The local update should not be lost during key migration.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Pause server so create request stalls
|
||||
{ type: "pause-server" },
|
||||
|
||||
// Client 0 creates file, then immediately updates it
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "initial content"
|
||||
},
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "updated content"
|
||||
},
|
||||
|
||||
// Resume server — create completes, update should follow
|
||||
{ type: "resume-server" },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// The updated content should be on both clients, not the initial
|
||||
{ type: "assert-consistent", verify: verifyUpdatedContent }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import type { ClientState, TestDefinition, TestStep } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
const FILE_COUNT = 20;
|
||||
|
||||
function buildSteps(): TestStep[] {
|
||||
const steps: TestStep[] = [];
|
||||
|
||||
// Create N files offline on client 0
|
||||
for (let i = 0; i < FILE_COUNT; i++) {
|
||||
steps.push({
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: `file-${String(i).padStart(3, "0")}.md`,
|
||||
content: `content-${i}`
|
||||
});
|
||||
}
|
||||
|
||||
// Enable sync and converge
|
||||
steps.push({ type: "enable-sync", client: 0 });
|
||||
steps.push({ type: "enable-sync", client: 1 });
|
||||
steps.push({ type: "sync" });
|
||||
steps.push({ type: "barrier" });
|
||||
|
||||
// Verify all files
|
||||
steps.push({
|
||||
type: "assert-consistent",
|
||||
verify: (state: ClientState) => {
|
||||
assert(
|
||||
state.files.size === FILE_COUNT,
|
||||
`Expected ${FILE_COUNT} files, got ${state.files.size}`
|
||||
);
|
||||
for (let i = 0; i < FILE_COUNT; i++) {
|
||||
const path = `file-${String(i).padStart(3, "0")}.md`;
|
||||
assert(state.files.has(path), `Missing file: ${path}`);
|
||||
assert(
|
||||
state.files.get(path) === `content-${i}`,
|
||||
`Wrong content for ${path}`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
export const largeFileCountTest: TestDefinition = {
|
||||
name: "Large File Count Sync",
|
||||
description:
|
||||
`Client 0 creates ${FILE_COUNT} files offline. All should sync ` +
|
||||
"to Client 1 with correct content.",
|
||||
clients: 2,
|
||||
steps: buildSteps()
|
||||
};
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* BUG: Local edit lost when create returns MergingUpdate.
|
||||
*
|
||||
* Scenario:
|
||||
* 1. Client 1 creates doc.md and syncs it to the server
|
||||
* 2. Client 0 (offline) creates doc.md with different content
|
||||
* 3. Server is paused, client 0 goes online — create request stalls
|
||||
* 4. Client 0 updates the file locally while the create is in-flight
|
||||
* 5. Server resumes → create returns MergingUpdate with merged content
|
||||
* 6. applyServerResponse reads currentDisk (the local update) and calls
|
||||
* write(path, currentDisk, responseBytes). The 3-way merge sees
|
||||
* parent == ours (currentDisk == currentDisk) → "no local changes" →
|
||||
* overwrites with server content. The local update is permanently lost.
|
||||
*
|
||||
* Expected: the local edit made during the in-flight create must survive.
|
||||
*/
|
||||
function verifyLocalEditPreserved(state: ClientState): void {
|
||||
assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`);
|
||||
assert(state.files.has("doc.md"), "Expected doc.md to exist");
|
||||
const content = state.files.get("doc.md") ?? "";
|
||||
assert(
|
||||
content.includes("from-client-1"),
|
||||
`Expected "from-client-1" in content, got: "${content}"`
|
||||
);
|
||||
// The critical assertion: the local edit made while the create was
|
||||
// in-flight must survive the MergingUpdate 3-way merge.
|
||||
assert(
|
||||
content.includes("local-edit-during-create"),
|
||||
`Expected "local-edit-during-create" in content (lost during merge), got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const localEditLostDuringCreateMergeTest: TestDefinition = {
|
||||
name: "Local Edit Lost During Create-Merge Response",
|
||||
description:
|
||||
"When a create returns a MergingUpdate and the file was locally " +
|
||||
"edited between the request and response, the local edit must " +
|
||||
"not be lost by the 3-way merge.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Client 1 creates doc.md while client 0 is offline
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync", client: 1 },
|
||||
{ type: "create", client: 1, path: "doc.md", content: "from-client-1" },
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Client 0 creates the same file offline (doesn't know about client 1's version)
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "from-client-0"
|
||||
},
|
||||
|
||||
// Pause server so client 0's create stalls mid-flight
|
||||
{ type: "pause-server" },
|
||||
|
||||
// Bring client 0 online — its create request will stall
|
||||
{ type: "enable-sync", client: 0 },
|
||||
|
||||
// Client 0 updates the file WHILE the create is in-flight
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "local-edit-during-create"
|
||||
},
|
||||
|
||||
// Resume server — create completes with MergingUpdate
|
||||
{ type: "resume-server" },
|
||||
|
||||
// Give time for: create response → 3-way merge → follow-up
|
||||
// update (detects local edit) → propagation to client 1
|
||||
{ type: "sync" },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// The local edit must be preserved
|
||||
{ type: "assert-consistent", verify: verifyLocalEditPreserved }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* Edge case: Both clients create files at DIFFERENT paths, then both rename
|
||||
* their respective files to the SAME target path.
|
||||
*
|
||||
* Timeline:
|
||||
* 1. Client 0 creates X.md, Client 1 creates Y.md (both offline).
|
||||
* 2. Both enable sync, converge (X.md and Y.md exist on both).
|
||||
* 3. Client 1 goes offline.
|
||||
* 4. Client 0 renames X.md -> Z.md, syncs.
|
||||
* 5. Client 1 (offline) renames Y.md -> Z.md.
|
||||
* 6. Client 1 reconnects.
|
||||
*
|
||||
* The tricky part: Both renames target Z.md. Client 0's rename completes first
|
||||
* on the server. When Client 1 reconnects and tries to rename Y.md -> Z.md,
|
||||
* the server already has a document at Z.md (formerly X.md). The system must
|
||||
* use path deconfliction (e.g., Z (1).md) to preserve both documents' content.
|
||||
*
|
||||
* This differs from the existing concurrent-rename-same-target test because
|
||||
* the files START at different paths (not A.md/B.md created by the same client)
|
||||
* and the creates themselves are concurrent, exercising the interaction between
|
||||
* concurrent create-merge and rename-deconfliction.
|
||||
*/
|
||||
|
||||
function verifyBothContentsPreserved(state: ClientState): void {
|
||||
const allContent = Array.from(state.files.values()).join("\n");
|
||||
assert(
|
||||
allContent.includes("content-x"),
|
||||
`Expected "content-x" to be preserved somewhere. ` +
|
||||
`Files: ${JSON.stringify(Object.fromEntries(state.files))}`
|
||||
);
|
||||
assert(
|
||||
allContent.includes("content-y"),
|
||||
`Expected "content-y" to be preserved somewhere. ` +
|
||||
`Files: ${JSON.stringify(Object.fromEntries(state.files))}`
|
||||
);
|
||||
|
||||
// Neither X.md nor Y.md should exist (both were renamed away)
|
||||
assert(
|
||||
!state.files.has("X.md"),
|
||||
`Expected X.md to not exist (was renamed). ` +
|
||||
`Files: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
assert(
|
||||
!state.files.has("Y.md"),
|
||||
`Expected Y.md to not exist (was renamed). ` +
|
||||
`Files: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
|
||||
// At least one file should be at Z.md
|
||||
assert(
|
||||
state.files.has("Z.md"),
|
||||
`Expected Z.md to exist. ` +
|
||||
`Files: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
|
||||
// There must be exactly 2 files (both contents preserved, possibly deconflicted)
|
||||
assert(
|
||||
state.files.size === 2,
|
||||
`Expected exactly 2 files, got ${state.files.size}: ` +
|
||||
Array.from(state.files.keys()).join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
export const mcCrossCreateRenameSameTargetTest: TestDefinition = {
|
||||
name: "MC: Cross-Create then Rename to Same Target",
|
||||
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: [
|
||||
// Phase 1: Both create files offline at different paths
|
||||
{ type: "create", client: 0, path: "X.md", content: "content-x" },
|
||||
{ type: "create", client: 1, path: "Y.md", content: "content-y" },
|
||||
|
||||
// Both enable sync — creates race to server
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Verify both files exist on both clients
|
||||
{ type: "assert-exists", client: 0, path: "X.md" },
|
||||
{ type: "assert-exists", client: 0, path: "Y.md" },
|
||||
{ type: "assert-exists", client: 1, path: "X.md" },
|
||||
{ type: "assert-exists", client: 1, path: "Y.md" },
|
||||
|
||||
// Phase 2: Client 1 goes offline
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
// Phase 3: Client 0 renames X.md -> Z.md and syncs
|
||||
{ type: "rename", client: 0, oldPath: "X.md", newPath: "Z.md" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
// Phase 4: Client 1 (offline) renames Y.md -> Z.md
|
||||
{ type: "rename", client: 1, oldPath: "Y.md", newPath: "Z.md" },
|
||||
|
||||
// Phase 5: Client 1 reconnects
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both contents must be preserved, both clients consistent
|
||||
{ type: "assert-consistent", verify: verifyBothContentsPreserved }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* Edge case: Client 0 creates a file, syncs. Client 1 receives it. Then Client
|
||||
* 0 deletes the file and syncs. Meanwhile Client 1 goes offline and renames it.
|
||||
*
|
||||
* Timeline:
|
||||
* 1. Client 0 creates A.md, both sync.
|
||||
* 2. Client 1 goes offline.
|
||||
* 3. Client 0 deletes A.md, syncs (server marks document as deleted).
|
||||
* 4. Client 1 (offline) renames A.md -> B.md.
|
||||
* 5. Client 1 reconnects.
|
||||
*
|
||||
* The tricky part: Client 1's rename targets a document that was deleted on the
|
||||
* server between Client 1's disconnect and reconnect. The offline rename is a
|
||||
* sync-update with oldPath=A.md, relativePath=B.md. On reconnect, the offline
|
||||
* reconciliation detects B.md as a local file with a documentId pointing to a
|
||||
* deleted server document. The system must decide: honor the rename (creating a
|
||||
* new document at B.md) or propagate the delete.
|
||||
*
|
||||
* This test verifies that both clients converge regardless of which resolution
|
||||
* strategy the system uses, and that no data is silently lost without the other
|
||||
* client also seeing the same result.
|
||||
*
|
||||
* We also add a second file C.md that remains untouched to verify unrelated
|
||||
* documents are not affected by the conflict resolution.
|
||||
*/
|
||||
|
||||
function verifyState(state: ClientState): void {
|
||||
// C.md must always survive (unrelated to the conflict)
|
||||
assert(
|
||||
state.files.has("C.md"),
|
||||
`Expected C.md to exist (untouched). ` +
|
||||
`Files: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.get("C.md") === "unrelated",
|
||||
`Expected C.md content to be "unrelated", got: "${state.files.get("C.md")}"`
|
||||
);
|
||||
|
||||
// A.md should NOT exist (it was either renamed or deleted)
|
||||
assert(
|
||||
!state.files.has("A.md"),
|
||||
`Expected A.md to NOT exist. ` +
|
||||
`Files: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
|
||||
// Either B.md exists (rename won) or no extra files exist (delete won).
|
||||
// The key invariant is convergence, which assert-consistent already checks.
|
||||
// But let's also verify that the content is correct if B.md exists.
|
||||
if (state.files.has("B.md")) {
|
||||
const content = state.files.get("B.md") ?? "";
|
||||
assert(
|
||||
content === "original",
|
||||
`If B.md exists (rename won), it should have the original content. Got: "${content}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const mcDeleteThenOfflineRenameTest: TestDefinition = {
|
||||
name: "MC: Delete Synced Then Offline Rename",
|
||||
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: [
|
||||
// Phase 1: Client 0 creates A.md and C.md, both sync
|
||||
{ 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: "sync" },
|
||||
{ type: "barrier" },
|
||||
{ type: "assert-content", client: 1, path: "A.md", content: "original" },
|
||||
{ type: "assert-content", client: 1, path: "C.md", content: "unrelated" },
|
||||
|
||||
// Phase 2: Client 1 goes offline
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
// Phase 3: Client 0 deletes A.md and syncs
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
{ type: "sync", client: 0 },
|
||||
{ type: "assert-not-exists", client: 0, path: "A.md" },
|
||||
|
||||
// Phase 4: Client 1 (offline) renames A.md -> B.md
|
||||
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
|
||||
|
||||
// Phase 5: Client 1 reconnects
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both must converge — key assertions
|
||||
{ type: "assert-consistent", verify: verifyState }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyState(state: ClientState): void {
|
||||
const files = Array.from(state.files.keys());
|
||||
|
||||
// file-1.md, file-3.md, file-5.md must survive (unaffected by conflict)
|
||||
for (const path of ["file-1.md", "file-3.md", "file-5.md"]) {
|
||||
assert(
|
||||
state.files.has(path),
|
||||
`Expected ${path} to exist. Files: ${files.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
// file-2.md was deleted on server by Client 1, and renamed to
|
||||
// renamed.md by Client 0 offline. The delete should win.
|
||||
assert(
|
||||
!state.files.has("file-2.md"),
|
||||
`Expected file-2.md to be deleted. Files: ${files.join(", ")}`
|
||||
);
|
||||
|
||||
// file-4.md was also deleted by Client 1.
|
||||
assert(
|
||||
!state.files.has("file-4.md"),
|
||||
`Expected file-4.md to be deleted. Files: ${files.join(", ")}`
|
||||
);
|
||||
|
||||
// renamed.md: Client 0's offline rename of deleted file-2.md.
|
||||
// The delete is authoritative, so renamed.md may or may not exist
|
||||
// depending on conflict resolution. If it exists, verify its content.
|
||||
if (state.files.has("renamed.md")) {
|
||||
assert(
|
||||
state.files.get("renamed.md") === "content-2",
|
||||
`If renamed.md exists, it should have "content-2", got: "${state.files.get("renamed.md")}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const mcMultiDeleteOfflineRenameTest: TestDefinition = {
|
||||
name: "MC: Multi-File Delete + Offline Rename",
|
||||
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: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Client 0 goes offline
|
||||
{ type: "disable-sync", client: 0 },
|
||||
|
||||
// Client 1 deletes file-2 and file-4
|
||||
{ type: "delete", client: 1, path: "file-2.md" },
|
||||
{ type: "delete", client: 1, path: "file-4.md" },
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Client 0 (offline) renames file-2
|
||||
{ type: "rename", client: 0, oldPath: "file-2.md", newPath: "renamed.md" },
|
||||
|
||||
// Client 0 reconnects
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both must converge
|
||||
{ type: "assert-consistent", verify: verifyState }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyState(state: ClientState): void {
|
||||
// A.md should not exist (it was renamed to B.md by Client 1)
|
||||
assert(
|
||||
!state.files.has("A.md"),
|
||||
`A.md should not exist after rename. Files: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
|
||||
// Exactly 1 file should exist (B.md with merged content)
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected exactly 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
|
||||
// B.md must exist with Client 2's updated content merged in
|
||||
assert(
|
||||
state.files.has("B.md"),
|
||||
`Expected B.md to exist. Files: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
const content = state.files.get("B.md") ?? "";
|
||||
assert(
|
||||
content.includes("updated-by-client-2"),
|
||||
`Expected B.md to contain "updated-by-client-2", got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const mcThreeClientRenameOfflineUpdateTest: TestDefinition = {
|
||||
name: "MC: Three-Client Rename + Offline Update",
|
||||
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: [
|
||||
// Phase 1: Client 0 creates A.md, everyone syncs
|
||||
{ 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: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Phase 2: Client 2 goes offline
|
||||
{ type: "disable-sync", client: 2 },
|
||||
|
||||
// Phase 3: Client 1 renames A.md -> B.md, clients 0 and 1 sync
|
||||
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
|
||||
{ type: "sync", client: 1 },
|
||||
{ type: "sync", client: 0 },
|
||||
// Don't use barrier here — Client 2 is offline and can't converge
|
||||
{ type: "assert-not-exists", client: 0, path: "A.md" },
|
||||
{ type: "assert-exists", client: 0, path: "B.md" },
|
||||
|
||||
// Phase 4: Client 2 updates its local A.md while offline
|
||||
{ type: "update", client: 2, path: "A.md", content: "updated-by-client-2" },
|
||||
|
||||
// Phase 5: Client 2 reconnects
|
||||
{ type: "enable-sync", client: 2 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// All three must converge
|
||||
{ type: "assert-consistent", verify: verifyState }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* BUG FIX: migrateKey must not overwrite existing state at the new key.
|
||||
*
|
||||
* Scenario:
|
||||
* 1. Client 0 creates file A.md, then immediately updates it
|
||||
* 2. Server is paused so the create stalls (idempotency key unresolved)
|
||||
* 3. Client 1 is online and also creates at A.md (different content)
|
||||
* 4. Server resumes — both creates merge
|
||||
* 5. Client 0's update should not be lost during key migration
|
||||
*
|
||||
* The test verifies that after convergence, the file exists with
|
||||
* content from both clients' edits.
|
||||
*/
|
||||
function verifyContent(state: ClientState): void {
|
||||
assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`);
|
||||
assert(state.files.has("A.md"), "Expected A.md to exist");
|
||||
const content = state.files.get("A.md") ?? "";
|
||||
// Client 0's update should be present
|
||||
assert(
|
||||
content.includes("updated by client 0"),
|
||||
`Expected content to include "updated by client 0", got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const migrateKeyPreservesExistingTest: TestDefinition = {
|
||||
name: "Key Migration Preserves Existing Queue State",
|
||||
description:
|
||||
"When migrateKey is called and the new key already has queued " +
|
||||
"events, the existing events must not be silently dropped.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Pause server so create stalls
|
||||
{ type: "pause-server" },
|
||||
|
||||
// Client 0 creates and immediately updates
|
||||
{ type: "create", client: 0, path: "A.md", content: "initial" },
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "updated by client 0"
|
||||
},
|
||||
|
||||
// Resume server
|
||||
{ type: "resume-server" },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "assert-consistent", verify: verifyContent }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyBothContentAndPath(state: ClientState): void {
|
||||
// The file should be at B.md (Client 0 renamed it)
|
||||
// AND should contain Client 1's updated content (merged with original)
|
||||
const files = Array.from(state.files.keys());
|
||||
assert(
|
||||
state.files.has("B.md"),
|
||||
`Expected B.md to exist, got: ${files.join(", ")}`
|
||||
);
|
||||
assert(
|
||||
!state.files.has("A.md"),
|
||||
`A.md should not exist after rename, got: ${files.join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected exactly 1 file, got ${state.files.size}: ${files.join(", ")}`
|
||||
);
|
||||
|
||||
const content = state.files.get("B.md") ?? "";
|
||||
// Client 1 updated the content to include "updated by client 1"
|
||||
// The 3-way merge should preserve this update at the renamed path
|
||||
assert(
|
||||
content.includes("updated by client 1"),
|
||||
`Expected B.md to contain "updated by client 1" from the remote update, got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* BUG: Coalescing table says `move + remote-update = move`, which drops
|
||||
* the remote update content. The local client only sends the rename
|
||||
* to the server. If the server has no concurrent version to merge with,
|
||||
* the remote client's update is lost on this client until a forced
|
||||
* re-sync (runFinalConsistencyCheck).
|
||||
*
|
||||
* This test verifies that when Client 0 renames A.md → B.md while
|
||||
* Client 1 simultaneously updates A.md, BOTH the rename and the
|
||||
* content update are reflected on both clients.
|
||||
*/
|
||||
export const moveAndConcurrentRemoteUpdateTest: TestDefinition = {
|
||||
name: "Move and Concurrent Remote Update",
|
||||
description:
|
||||
"Client 0 renames A.md to B.md while Client 1 updates A.md content. " +
|
||||
"The coalescing table merges move + remote-update into just 'move', " +
|
||||
"potentially dropping the remote content update. Both clients should " +
|
||||
"converge to B.md with Client 1's updated content.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: both clients share A.md
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "original content"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "A.md",
|
||||
content: "original content"
|
||||
},
|
||||
|
||||
// Client 0 goes offline and renames A.md → B.md
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
||||
|
||||
// Client 1 updates A.md while Client 0 is offline
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "A.md",
|
||||
content: "updated by client 1"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Client 0 comes online — will receive remote-update for A.md
|
||||
// The move event (A→B) and remote-update should both apply
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "assert-consistent", verify: verifyBothContentAndPath }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* EDGE CASE: Three-file circular rotation while offline.
|
||||
*
|
||||
* Files A, B, C get rotated: A→B, B→C, C→A. Since the DeterministicAgent
|
||||
* works on an in-memory filesystem, we can simulate this by:
|
||||
* 1. Delete all three files
|
||||
* 2. Recreate them with rotated content
|
||||
*
|
||||
* On reconnect, the reconciliation algorithm must detect that:
|
||||
* - A.md has C's old content (move from C→A)
|
||||
* - B.md has A's old content (move from A→B)
|
||||
* - C.md has B's old content (move from B→C)
|
||||
*
|
||||
* Since each file has unique content, the hash-based move detection should
|
||||
* work. But this creates THREE simultaneous move detections, which is a
|
||||
* stress test of the algorithm: each match removes from missingTracked,
|
||||
* and the order of processing matters.
|
||||
*/
|
||||
function verifyFinalState(state: ClientState): void {
|
||||
assert(
|
||||
state.files.size === 3,
|
||||
`Expected 3 files, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.get("A.md") === "was C",
|
||||
`Expected A.md = "was C", got: "${state.files.get("A.md")}"`
|
||||
);
|
||||
assert(
|
||||
state.files.get("B.md") === "was A",
|
||||
`Expected B.md = "was A", got: "${state.files.get("B.md")}"`
|
||||
);
|
||||
assert(
|
||||
state.files.get("C.md") === "was B",
|
||||
`Expected C.md = "was B", got: "${state.files.get("C.md")}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const moveChainThreeFilesTest: TestDefinition = {
|
||||
name: "Three-File Circular Rotation Offline",
|
||||
description:
|
||||
"Three files are rotated (A→B, B→C, C→A) while offline by " +
|
||||
"deleting all and recreating with swapped content. The reconciliation " +
|
||||
"should detect the moves via hash matching and sync correctly.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: create three files with unique content
|
||||
{ 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: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Client 0 goes offline
|
||||
{ type: "disable-sync", client: 0 },
|
||||
|
||||
// Delete all three
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
{ type: "delete", client: 0, path: "B.md" },
|
||||
{ type: "delete", client: 0, path: "C.md" },
|
||||
|
||||
// Recreate with rotated content: C→A, A→B, B→C
|
||||
{ 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" },
|
||||
|
||||
// Reconnect
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "assert-consistent", verify: verifyFinalState }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* BUG: Move detection fails when two files have identical content.
|
||||
*
|
||||
* reconcileWithDisk() detects moves by matching content hashes of new files
|
||||
* against missing tracked docs. If there are TWO missing tracked docs with
|
||||
* the same hash, neither will match (matches.length !== 1), and the move
|
||||
* is treated as a "new file + delete" instead of a rename.
|
||||
*
|
||||
* Scenario:
|
||||
* 1. Client 0 creates two files with identical content: A.md and B.md
|
||||
* 2. Both sync to Client 1
|
||||
* 3. Client 1 goes offline
|
||||
* 4. Client 1 deletes A.md and renames B.md to C.md (same content)
|
||||
* 5. Client 1 reconnects
|
||||
*
|
||||
* Expected: A.md deleted on server, B.md renamed to C.md (preserving documentId)
|
||||
* Bug: reconcileWithDisk sees B.md missing + C.md new, but content hash
|
||||
* matches BOTH A.md and B.md (since they had identical content). So the
|
||||
* move from B→C is not detected. Instead, B.md is treated as a delete
|
||||
* and C.md as a new create, losing B.md's documentId.
|
||||
*
|
||||
* The test verifies convergence still works (the system recovers via
|
||||
* server-side merge), but documents may get new documentIds unnecessarily.
|
||||
*/
|
||||
function verifyFinalState(state: ClientState): void {
|
||||
// A.md should not exist (deleted)
|
||||
assert(!state.files.has("A.md"), "A.md should not exist");
|
||||
|
||||
// B.md should not exist (renamed to C.md)
|
||||
assert(!state.files.has("B.md"), "B.md should not exist");
|
||||
|
||||
// C.md should exist with the shared content
|
||||
assert(state.files.has("C.md"), "C.md should exist");
|
||||
const content = state.files.get("C.md") ?? "";
|
||||
assert(
|
||||
content === "identical content",
|
||||
`Expected C.md to contain "identical content", got: "${content}"`
|
||||
);
|
||||
|
||||
// Only C.md should exist
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
export const moveIdenticalContentAmbiguityTest: TestDefinition = {
|
||||
name: "Move Detection Ambiguity With Identical Content",
|
||||
description:
|
||||
"Two files with identical content exist. One is deleted and the other " +
|
||||
"renamed while offline. On reconnect, the move detection algorithm sees " +
|
||||
"two matching hashes and cannot determine which missing doc was moved. " +
|
||||
"The system should still converge correctly.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: create two files with identical content
|
||||
{
|
||||
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: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Verify both clients have both files
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "A.md",
|
||||
content: "identical content"
|
||||
},
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "B.md",
|
||||
content: "identical content"
|
||||
},
|
||||
|
||||
// Client 1 goes offline, deletes A.md and renames B.md → C.md
|
||||
{ type: "disable-sync", client: 1 },
|
||||
{ type: "delete", client: 1, path: "A.md" },
|
||||
{ type: "rename", client: 1, oldPath: "B.md", newPath: "C.md" },
|
||||
|
||||
// Client 1 reconnects
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both clients should converge
|
||||
{ type: "assert-consistent", verify: verifyFinalState }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* BUG FIX: Local rename must not drop a concurrent remote content update.
|
||||
*
|
||||
* Scenario:
|
||||
* 1. Both clients have doc.md = "line 1\nline 2"
|
||||
* 2. Client 0 renames doc.md to renamed.md
|
||||
* 3. Client 1 edits doc.md content
|
||||
* 4. Both sync
|
||||
* 5. The file should exist (at some path) with both the rename and content update applied
|
||||
*/
|
||||
function verifyContentPreserved(state: ClientState): void {
|
||||
assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`);
|
||||
// The file should be at the renamed path
|
||||
assert(
|
||||
state.files.has("renamed.md") || state.files.has("doc.md"),
|
||||
`Expected file at renamed.md or doc.md, got: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
// Content from client 1's edit should be present
|
||||
const [content] = [...state.files.values()];
|
||||
assert(
|
||||
content.includes("client 1 edit"),
|
||||
`Expected merged content to include "client 1 edit", got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const movePreservesRemoteUpdateTest: TestDefinition = {
|
||||
name: "Local Move Preserves Remote Content Update",
|
||||
description:
|
||||
"When a user renames a file and another client edits it concurrently, " +
|
||||
"the content update should not be lost.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup
|
||||
{ type: "create", client: 0, path: "doc.md", content: "line 1\nline 2" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both go offline
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
// Client 0 renames, client 1 edits content
|
||||
{ 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" },
|
||||
|
||||
// Both come online
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "assert-consistent", verify: verifyContentPreserved },
|
||||
],
|
||||
};
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* BUG: remote-update + local-move = remote-update loses the rename.
|
||||
*
|
||||
* In sync-events.ts coalesceFromRemoteUpdate (line 271-272):
|
||||
* case "local-move":
|
||||
* return current; // remote-update absorbs the local-move
|
||||
*
|
||||
* When a remote-update broadcast arrives and then the user renames the
|
||||
* file, the coalescing discards the move info. The executor only sees
|
||||
* "remote-update" and calls executeSyncUpdateFull(force=true).
|
||||
*
|
||||
* In the force path (no local content changes), the server responds
|
||||
* with the old path. The client moves the file BACK to the old path,
|
||||
* reverting the user's rename.
|
||||
*
|
||||
* If there ARE content changes, the update sends doc.relativePath (the
|
||||
* new path) to the server, which may preserve the rename. But the
|
||||
* behavior is inconsistent.
|
||||
*
|
||||
* This test verifies that when a remote-update and a local-rename race,
|
||||
* the rename is preserved (or at least both clients converge).
|
||||
*/
|
||||
function verifyState(state: ClientState): void {
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
// The file should exist at the renamed path or original — either is OK
|
||||
// as long as both clients converge. But ideally the rename survives.
|
||||
const content = Array.from(state.files.values())[0];
|
||||
assert(
|
||||
content === "updated by client 1",
|
||||
`Expected "updated by client 1", got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const moveRemoteUpdateRevertsRenameTest: TestDefinition = {
|
||||
name: "Remote Update + Local Move Coalescing May Revert Rename",
|
||||
description:
|
||||
"When a remote-update broadcast arrives and the user renames the " +
|
||||
"file, the coalescing (remote-update + local-move = remote-update) " +
|
||||
"discards the rename info. The force path may revert the rename " +
|
||||
"by moving the file back to the server's path.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: both clients have doc.md
|
||||
{ type: "create", client: 0, path: "doc.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Client 1 updates the file content (broadcasts to client 0)
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "update", client: 1, path: "doc.md", content: "updated by client 1" },
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Client 0 comes online and renames the file while the remote-update
|
||||
// is arriving on the WebSocket
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "rename", client: 0, oldPath: "doc.md", newPath: "renamed.md" },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both should converge
|
||||
{ type: "assert-consistent", verify: verifyState }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyDeleted(state: ClientState): void {
|
||||
const files = Array.from(state.files.keys());
|
||||
assert(
|
||||
state.files.size === 0,
|
||||
`Expected 0 files after move+delete, got ${state.files.size}: ${files.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the stale-path bug in the delete executor.
|
||||
*
|
||||
* When a file is renamed (A→B) and then deleted, the event coalescing
|
||||
* produces `move(A→B) + delete = delete(path: A)`. The VFS.move in
|
||||
* syncLocallyUpdatedFile has already moved the doc to B. The executor's
|
||||
* delete action looks up the doc: getByPath("A") returns undefined
|
||||
* (doc moved to B), so it falls back to getByDocumentId. It finds the
|
||||
* doc at B. Then it calls deleteLocally().
|
||||
*
|
||||
* Before the fix: deleteLocally(action.path) used "A" — the stale
|
||||
* path from when the event was enqueued. The pathIndex lookup at "A"
|
||||
* fails (doc is at "B"), so the delete is silently dropped. The doc
|
||||
* stays tracked at B, and the file is gone from disk but VFS thinks
|
||||
* it still exists.
|
||||
*
|
||||
* After the fix: deleteLocally(doc.relativePath) uses "B" — the
|
||||
* current VFS path. The delete succeeds.
|
||||
*/
|
||||
export const moveThenDeleteStalePathTest: TestDefinition = {
|
||||
name: "Move Then Delete (Stale Path Fix)",
|
||||
description:
|
||||
"Client 0 creates A.md, syncs. Then renames A.md to B.md and " +
|
||||
"immediately deletes B.md. The coalesced delete action has the " +
|
||||
"old path 'A', but the doc is at 'B' in VFS. The delete executor " +
|
||||
"must use the current VFS path, not the stale action path.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: create and sync
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "content to delete"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "A.md",
|
||||
content: "content to delete"
|
||||
},
|
||||
|
||||
// Rename A→B then delete B (with sync enabled so VFS.move fires)
|
||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
||||
{ type: "delete", client: 0, path: "B.md" },
|
||||
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both clients should have 0 files
|
||||
{ type: "assert-not-exists", client: 0, path: "A.md" },
|
||||
{ type: "assert-not-exists", client: 0, path: "B.md" },
|
||||
{ type: "assert-not-exists", client: 1, path: "A.md" },
|
||||
{ type: "assert-not-exists", client: 1, path: "B.md" },
|
||||
{ type: "assert-consistent", verify: verifyDeleted }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyState(state: ClientState): void {
|
||||
const files = Array.from(state.files.keys());
|
||||
|
||||
// B.md must exist with updated content from client 1
|
||||
assert(
|
||||
state.files.has("B.md"),
|
||||
`Expected B.md to exist, got: ${files.join(", ")}`
|
||||
);
|
||||
const bContent = state.files.get("B.md") ?? "";
|
||||
assert(
|
||||
bContent.includes("updated"),
|
||||
`Expected B.md to contain "updated", got: "${bContent}"`
|
||||
);
|
||||
|
||||
// C.md must exist (created independently, unaffected)
|
||||
assert(
|
||||
state.files.has("C.md"),
|
||||
`Expected C.md to exist, got: ${files.join(", ")}`
|
||||
);
|
||||
|
||||
// A.md should not exist (deleted by client 0 or renamed by client 1)
|
||||
assert(
|
||||
!state.files.has("A.md"),
|
||||
`A.md should not exist, got: ${files.join(", ")}`
|
||||
);
|
||||
|
||||
// D.md: Client 1 renamed the server-deleted A.md to D.md offline.
|
||||
// The system may keep D.md (rename wins) or drop it (delete wins).
|
||||
// If D.md exists, it should have the original content.
|
||||
if (state.files.has("D.md")) {
|
||||
assert(
|
||||
state.files.get("D.md") === "content-a",
|
||||
`If D.md exists, it should have "content-a", got: "${state.files.get("D.md")}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const multiFileOperationsTest: TestDefinition = {
|
||||
name: "Multi-File Operations",
|
||||
description:
|
||||
"Client 0 creates A.md, B.md, C.md. Both clients sync. Client 1 goes offline. " +
|
||||
"Client 0 deletes A.md. Client 1 (offline) updates B.md and renames A.md to D.md. " +
|
||||
"When Client 1 reconnects, the system must reconcile: A.md deleted on server, " +
|
||||
"renamed on client 1; B.md updated on client 1. Both must converge.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: create three files and sync
|
||||
{ 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: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Client 1 goes offline
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
// Client 0 deletes A.md and syncs
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
// Client 1 (offline) updates B.md and renames A.md to D.md
|
||||
{ type: "update", client: 1, path: "B.md", content: "updated by client 1" },
|
||||
{ type: "rename", client: 1, oldPath: "A.md", newPath: "D.md" },
|
||||
|
||||
// Client 1 reconnects
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Verify convergence: B.md and C.md must exist. B.md must have update.
|
||||
{ type: "assert-consistent", verify: verifyState }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const multipleUpdatesCoalesceTest: TestDefinition = {
|
||||
name: "Multiple Rapid Updates Converge to Final Version",
|
||||
description:
|
||||
"Client 0 rapidly updates a file multiple times while online. " +
|
||||
"Both clients must converge to the final content.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: create file and sync
|
||||
{ type: "create", client: 0, path: "rapid.md", content: "v0" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
{ type: "assert-content", client: 1, path: "rapid.md", content: "v0" },
|
||||
|
||||
// Client 0 rapidly updates (sync is enabled, so events are enqueued)
|
||||
{ type: "update", client: 0, path: "rapid.md", content: "v1" },
|
||||
{ type: "update", client: 0, path: "rapid.md", content: "v2" },
|
||||
{ type: "update", client: 0, path: "rapid.md", content: "v3" },
|
||||
{ type: "update", client: 0, path: "rapid.md", content: "v4-final" },
|
||||
|
||||
// Sync and converge
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both should have the final version
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 0,
|
||||
path: "rapid.md",
|
||||
content: "v4-final"
|
||||
},
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "rapid.md",
|
||||
content: "v4-final"
|
||||
},
|
||||
{ type: "assert-consistent" }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyConvergence(state: ClientState): void {
|
||||
const files = Array.from(state.files.keys()).sort();
|
||||
|
||||
// The original file A.md should not exist (both clients renamed it away)
|
||||
assert(
|
||||
!state.files.has("A.md"),
|
||||
`A.md should not exist after both renames. Files: ${files.join(", ")}`
|
||||
);
|
||||
|
||||
// Both clients renamed the same document. The server picks one rename
|
||||
// as the winner. Exactly one file should exist (the document at its
|
||||
// final path) since there was only one document to begin with.
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected exactly 1 file (same document renamed), got ${state.files.size}: ${files.join(", ")}`
|
||||
);
|
||||
|
||||
// The rename target should be B.md or C.md
|
||||
const hasB = state.files.has("B.md");
|
||||
const hasC = state.files.has("C.md");
|
||||
assert(
|
||||
hasB || hasC,
|
||||
`Expected B.md or C.md to exist. Files: ${files.join(", ")}`
|
||||
);
|
||||
|
||||
// The content must be preserved regardless of which rename won
|
||||
const [content] = Array.from(state.files.values());
|
||||
assert(
|
||||
content === "shared-content",
|
||||
`Expected content "shared-content", got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const offlineConcurrentRenamesTest: TestDefinition = {
|
||||
name: "Offline Concurrent Renames of Same File",
|
||||
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: [
|
||||
// Setup: create A.md and sync to both clients
|
||||
{ type: "create", client: 0, path: "A.md", content: "shared-content" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "A.md",
|
||||
content: "shared-content"
|
||||
},
|
||||
|
||||
// Both clients go offline
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
// Client 0 renames A.md -> B.md
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "A.md",
|
||||
newPath: "B.md"
|
||||
},
|
||||
|
||||
// Client 1 renames A.md -> C.md
|
||||
{
|
||||
type: "rename",
|
||||
client: 1,
|
||||
oldPath: "A.md",
|
||||
newPath: "C.md"
|
||||
},
|
||||
|
||||
// Both reconnect
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// A.md must be gone from both
|
||||
{ type: "assert-not-exists", client: 0, path: "A.md" },
|
||||
{ type: "assert-not-exists", client: 1, path: "A.md" },
|
||||
|
||||
// Both must converge to the same state with content preserved
|
||||
{ type: "assert-consistent", verify: verifyConvergence }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyBothFilesExist(state: ClientState): void {
|
||||
const files = Array.from(state.files.keys()).sort();
|
||||
|
||||
// B.md should exist with the original content (renamed from A.md)
|
||||
assert(
|
||||
state.files.has("B.md"),
|
||||
`B.md should exist (renamed from A.md). Files: ${files.join(", ")}`
|
||||
);
|
||||
const bContent = state.files.get("B.md") ?? "";
|
||||
assert(
|
||||
bContent === "first-content",
|
||||
`B.md should have "first-content" (original file), got: "${bContent}"`
|
||||
);
|
||||
|
||||
// A.md should exist with the new content (recreated after rename)
|
||||
assert(
|
||||
state.files.has("A.md"),
|
||||
`A.md should exist (recreated after rename). Files: ${files.join(", ")}`
|
||||
);
|
||||
const aContent = state.files.get("A.md") ?? "";
|
||||
assert(
|
||||
aContent === "second-content",
|
||||
`A.md should have "second-content" (new file), got: "${aContent}"`
|
||||
);
|
||||
|
||||
// Exactly 2 files
|
||||
assert(
|
||||
state.files.size === 2,
|
||||
`Expected 2 files, got ${state.files.size}: ${files.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
export const offlineCreateRenameCreateTest: TestDefinition = {
|
||||
name: "Offline Create, Rename, Recreate Same Path",
|
||||
description:
|
||||
"Client 0 goes offline. Creates file A with content X, renames A to B, " +
|
||||
"then creates a new file A with content Y. When Client 0 reconnects, " +
|
||||
"Client 1 should see both A.md (content Y) and B.md (content X) -- " +
|
||||
"the rename and the new create are independent documents.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Client 1 starts syncing immediately to receive updates
|
||||
{ type: "enable-sync", client: 1 },
|
||||
|
||||
// Client 0 is offline and performs create -> rename -> create
|
||||
{ type: "create", client: 0, path: "A.md", content: "first-content" },
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "A.md",
|
||||
newPath: "B.md"
|
||||
},
|
||||
{ type: "create", client: 0, path: "A.md", content: "second-content" },
|
||||
|
||||
// Client 0 enables sync -- offline reconciliation should detect
|
||||
// B.md and A.md as two separate new files
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both files should exist on both clients
|
||||
{ type: "assert-exists", client: 0, path: "A.md" },
|
||||
{ type: "assert-exists", client: 0, path: "B.md" },
|
||||
{ type: "assert-exists", client: 1, path: "A.md" },
|
||||
{ type: "assert-exists", client: 1, path: "B.md" },
|
||||
{ type: "assert-consistent", verify: verifyBothFilesExist }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* EDGE CASE: Two clients create at the same path while offline — mergeable text files.
|
||||
*
|
||||
* When a remote-update arrives for a path where a local pending create
|
||||
* exists, the code at sync-actions.ts line 1161 skips the remote download
|
||||
* ONLY for mergeable file types. For mergeable files, the idempotency
|
||||
* key resolution will handle the merge correctly.
|
||||
*
|
||||
* This test verifies that when both clients create at the same path with
|
||||
* different text content while offline, the server merges correctly and
|
||||
* both clients converge.
|
||||
*
|
||||
* The interesting edge case is: Client 0 creates and syncs first, then
|
||||
* Client 1 creates at the same path. The server's smart create should
|
||||
* merge the content (3-way merge with empty parent), and both clients
|
||||
* should see both pieces of content.
|
||||
*/
|
||||
function verifyMergedContent(state: ClientState): void {
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.has("notes.md"),
|
||||
`Expected notes.md to exist, got: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
const content = state.files.get("notes.md") ?? "";
|
||||
assert(
|
||||
content.includes("alpha wrote this line"),
|
||||
`Expected content to include "alpha wrote this line", got: "${content}"`
|
||||
);
|
||||
assert(
|
||||
content.includes("beta wrote this different line"),
|
||||
`Expected content to include "beta wrote this different line", got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const offlineCreateSamePathMergeableTest: TestDefinition = {
|
||||
name: "Offline Create Same Path — Mergeable Text",
|
||||
description:
|
||||
"Both clients create a file at the same path while offline with " +
|
||||
"different text content. When both sync, the server should 3-way " +
|
||||
"merge the content and both clients should converge to the merged result.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Both clients create at same path while offline
|
||||
{
|
||||
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"
|
||||
},
|
||||
|
||||
// Enable sync — Client 0 syncs first, then Client 1's create
|
||||
// triggers a smart merge on the server
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "assert-consistent", verify: verifyMergedContent }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyConvergence(state: ClientState): void {
|
||||
const files = Array.from(state.files.keys()).sort();
|
||||
|
||||
// A.md should not exist (it was renamed/deleted)
|
||||
assert(
|
||||
!state.files.has("A.md"),
|
||||
`A.md should not exist. Files: ${files.join(", ")}`
|
||||
);
|
||||
|
||||
// B.md should still exist unaffected
|
||||
assert(
|
||||
state.files.has("B.md"),
|
||||
`B.md should exist (untouched). Files: ${files.join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.get("B.md") === "content-b",
|
||||
`B.md should have "content-b", got: "${state.files.get("B.md")}"`
|
||||
);
|
||||
|
||||
// Clients must converge. If delete wins, A_renamed.md shouldn't exist.
|
||||
// If rename wins, A_renamed.md should exist with content-a.
|
||||
// Either way, both clients must agree.
|
||||
if (state.files.has("A_renamed.md")) {
|
||||
assert(
|
||||
state.files.get("A_renamed.md") === "content-a",
|
||||
`If A_renamed.md exists, it should have "content-a", got: "${state.files.get("A_renamed.md")}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const offlineDeleteRemoteRenameTest: TestDefinition = {
|
||||
name: "Offline Delete + Concurrent Remote Rename",
|
||||
description:
|
||||
"Client 0 goes offline and deletes A.md locally. Meanwhile Client 1 " +
|
||||
"renames A.md to A_renamed.md and syncs. When Client 0 reconnects, " +
|
||||
"the offline reconciliation discovers A.md is missing locally but the " +
|
||||
"server has it renamed. The system must converge consistently.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup
|
||||
{ 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: "barrier" },
|
||||
|
||||
// Client 0 goes offline and deletes A.md
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
|
||||
// Client 1 renames A.md -> A_renamed.md
|
||||
{
|
||||
type: "rename",
|
||||
client: 1,
|
||||
oldPath: "A.md",
|
||||
newPath: "A_renamed.md"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Client 0 reconnects
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both clients must converge
|
||||
{ type: "assert-consistent", verify: verifyConvergence }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyConsistentState(state: ClientState): void {
|
||||
// After Client 0 deletes and Client 1 updates the same file,
|
||||
// both clients must agree. The delete intent should win (user
|
||||
// explicitly deleted the file) and both clients should converge
|
||||
// to having no files OR the file re-created.
|
||||
//
|
||||
// The coalescing path is: local-update enqueued for Client 1's
|
||||
// remote broadcast → local-delete arrives → coalesces.
|
||||
//
|
||||
// Key assertion: both clients must be consistent, regardless
|
||||
// of which intent wins.
|
||||
const files = Array.from(state.files.keys());
|
||||
// File should NOT exist (delete wins in current implementation)
|
||||
assert(
|
||||
state.files.size === 0,
|
||||
`Expected 0 files after delete-wins resolution, got ${state.files.size}: ${files.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the coalescing path: `remote-update + local-delete → delete`.
|
||||
*
|
||||
* When Client 0 comes online after deleting A.md, it receives a
|
||||
* remote-update broadcast for A.md from Client 1's edit. The
|
||||
* coalescing must produce a `delete` action (not `remote-delete`
|
||||
* with isDeleted=false) so the executor properly marks the doc as
|
||||
* deleted-locally and sends DELETE to the server.
|
||||
*
|
||||
* Before the fix: the coalescing produced `remote-delete` with the
|
||||
* remote-update version (isDeleted=false). The executor treated this
|
||||
* as a tracked doc update, downloaded the remote content, and
|
||||
* silently resurrected the file — overriding the user's delete.
|
||||
*/
|
||||
export const offlineDeleteVsRemoteUpdateTest: TestDefinition = {
|
||||
name: "Offline Delete vs Remote Update",
|
||||
description:
|
||||
"Client 0 deletes A.md while Client 1 updates A.md. Tests the " +
|
||||
"coalescing of remote-update + local-delete and whether both " +
|
||||
"clients converge to a consistent state.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: both clients share A.md
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "original content"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "A.md",
|
||||
content: "original content"
|
||||
},
|
||||
|
||||
// Client 0 goes offline and deletes A.md
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
|
||||
// Client 1 updates A.md while Client 0 is offline
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "A.md",
|
||||
content: "important update by client 1"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Client 0 comes online — receives remote-update for A.md
|
||||
// but has already deleted it locally
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "assert-consistent", verify: verifyConsistentState }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyEditPreservedAtNewPath(state: ClientState): void {
|
||||
const files = Array.from(state.files.keys()).sort();
|
||||
|
||||
// A.md should not exist (it was renamed to B.md)
|
||||
assert(
|
||||
!state.files.has("A.md"),
|
||||
`A.md should not exist after rename. Files: ${files.join(", ")}`
|
||||
);
|
||||
|
||||
// B.md should exist with Client 0's edit merged in
|
||||
assert(
|
||||
state.files.has("B.md"),
|
||||
`Expected B.md to exist. Files: ${files.join(", ")}`
|
||||
);
|
||||
|
||||
const content = state.files.get("B.md") ?? "";
|
||||
assert(
|
||||
content.includes("edited by client 0"),
|
||||
`Expected B.md to contain Client 0's edit "edited by client 0", got: "${content}"`
|
||||
);
|
||||
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected exactly 1 file, got ${state.files.size}: ${files.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
export const offlineEditRemoteRenameTest: TestDefinition = {
|
||||
name: "Offline Edit + Remote Rename",
|
||||
description:
|
||||
"Client 0 goes offline and edits A.md. Meanwhile Client 1 renames " +
|
||||
"A.md to B.md. When Client 0 reconnects, its edit should be applied " +
|
||||
"to B.md (the renamed path). The edit must not be lost and A.md must " +
|
||||
"not exist.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: create and sync
|
||||
{ type: "create", client: 0, path: "A.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "A.md",
|
||||
content: "original"
|
||||
},
|
||||
|
||||
// Client 0 goes offline and edits
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "edited by client 0"
|
||||
},
|
||||
|
||||
// Client 1 renames A.md -> B.md while Client 0 is offline
|
||||
{
|
||||
type: "rename",
|
||||
client: 1,
|
||||
oldPath: "A.md",
|
||||
newPath: "B.md"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Client 0 reconnects — edit must be preserved at new path
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "assert-not-exists", client: 0, path: "A.md" },
|
||||
{ type: "assert-not-exists", client: 1, path: "A.md" },
|
||||
{ type: "assert-consistent", verify: verifyEditPreservedAtNewPath }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* EDGE CASE: File moved AND edited to have the same hash as another file.
|
||||
*
|
||||
* reconcileWithDisk detects moves by matching content hashes. But if a
|
||||
* file is moved AND edited such that its new content matches a different
|
||||
* missing file's hash, the move detection assigns it to the WRONG document.
|
||||
*
|
||||
* Scenario:
|
||||
* 1. Two files exist: A.md ("content A") and B.md ("content B")
|
||||
* 2. Client goes offline
|
||||
* 3. A.md is deleted, B.md is renamed to C.md and edited to "content A"
|
||||
* 4. On reconnect, reconcileWithDisk sees:
|
||||
* - Missing: A.md (hash="content A"), B.md (hash="content B")
|
||||
* - New: C.md (hash="content A")
|
||||
* - C.md's hash matches A.md's hash → wrong move detection!
|
||||
* - B.md is treated as deleted instead of renamed
|
||||
*
|
||||
* The system should still converge correctly despite the false match.
|
||||
*/
|
||||
function verifyFinalState(state: ClientState): void {
|
||||
assert(!state.files.has("A.md"), "A.md should not exist");
|
||||
assert(!state.files.has("B.md"), "B.md should not exist");
|
||||
assert(state.files.has("C.md"), "C.md should exist");
|
||||
const content = state.files.get("C.md") ?? "";
|
||||
assert(
|
||||
content === "content A",
|
||||
`Expected C.md to contain "content A", got: "${content}"`
|
||||
);
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
export const offlineEditThenMoveSameContentTest: TestDefinition = {
|
||||
name: "Offline Move + Edit Creates False Hash Match",
|
||||
description:
|
||||
"A file is renamed and edited to have the same content as a deleted " +
|
||||
"file. Move detection may match against the wrong document. The " +
|
||||
"system should still converge.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: create two files with different content
|
||||
{
|
||||
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: "barrier" },
|
||||
|
||||
// Client 0 goes offline
|
||||
{ type: "disable-sync", client: 0 },
|
||||
|
||||
// Delete A.md
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
|
||||
// Rename B.md → C.md
|
||||
{ type: "rename", client: 0, oldPath: "B.md", newPath: "C.md" },
|
||||
|
||||
// Edit C.md to have the same content as the now-deleted A.md
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "C.md",
|
||||
content: "content A"
|
||||
},
|
||||
|
||||
// Reconnect
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "assert-consistent", verify: verifyFinalState }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyFinalState(state: ClientState): void {
|
||||
const files = Array.from(state.files.keys()).sort();
|
||||
|
||||
// file1.md was deleted -- must not exist
|
||||
assert(
|
||||
!state.files.has("file1.md"),
|
||||
`file1.md should have been deleted but exists. Files: ${files.join(", ")}`
|
||||
);
|
||||
|
||||
// file2.md was renamed to moved.md
|
||||
assert(
|
||||
!state.files.has("file2.md"),
|
||||
`file2.md should have been renamed but still exists. Files: ${files.join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.has("moved.md"),
|
||||
`moved.md should exist after rename. Files: ${files.join(", ")}`
|
||||
);
|
||||
const movedContent = state.files.get("moved.md") ?? "";
|
||||
assert(
|
||||
movedContent === "content-2",
|
||||
`moved.md should have original content "content-2", got: "${movedContent}"`
|
||||
);
|
||||
|
||||
// file3.md was updated
|
||||
assert(
|
||||
state.files.has("file3.md"),
|
||||
`file3.md should exist. Files: ${files.join(", ")}`
|
||||
);
|
||||
const file3Content = state.files.get("file3.md") ?? "";
|
||||
assert(
|
||||
file3Content === "updated-content-3",
|
||||
`file3.md should have "updated-content-3", got: "${file3Content}"`
|
||||
);
|
||||
|
||||
// Exactly 2 files should remain
|
||||
assert(
|
||||
state.files.size === 2,
|
||||
`Expected 2 files, got ${state.files.size}: ${files.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
export const offlineMixedOperationsTest: TestDefinition = {
|
||||
name: "Offline Mixed Operations (Delete + Rename + Edit)",
|
||||
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: [
|
||||
// Setup: Client 0 creates 3 files and syncs
|
||||
{ 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: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Verify initial sync
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "file1.md",
|
||||
content: "content-1"
|
||||
},
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "file2.md",
|
||||
content: "content-2"
|
||||
},
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "file3.md",
|
||||
content: "content-3"
|
||||
},
|
||||
|
||||
// Client 0 goes offline
|
||||
{ type: "disable-sync", client: 0 },
|
||||
|
||||
// Client 0 performs three different offline operations
|
||||
{ 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"
|
||||
},
|
||||
|
||||
// Client 0 reconnects
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// All operations should have propagated
|
||||
{ type: "assert-not-exists", client: 1, path: "file1.md" },
|
||||
{ type: "assert-not-exists", client: 1, path: "file2.md" },
|
||||
{ type: "assert-exists", client: 1, path: "moved.md" },
|
||||
{ type: "assert-exists", client: 1, path: "file3.md" },
|
||||
{ type: "assert-consistent", verify: verifyFinalState }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* BUG: Move + remote-delete coalescing uses stale source path.
|
||||
*
|
||||
* Found by: multi-client convergence agent (#10)
|
||||
*
|
||||
* When a local move and a remote-delete are coalesced for the same document:
|
||||
* move(A→B) + remote-delete = delete(path: A)
|
||||
* (sync-events.ts line 210-211)
|
||||
*
|
||||
* But the VFS has already moved the document from A to B (syncer.ts
|
||||
* line 152 runs vfs.move() immediately on the local-move event).
|
||||
* When the executor tries to find the document at path A (line 302
|
||||
* in syncer.ts), it returns undefined because D1 is now at path B.
|
||||
* The delete is silently skipped.
|
||||
*
|
||||
* The system should recover via runFinalConsistencyCheck() or the next
|
||||
* reconciliation cycle, which will detect that B.md exists on disk
|
||||
* but the server says D1 is deleted.
|
||||
*
|
||||
* This test verifies that both clients converge — the file should end
|
||||
* up deleted on both clients.
|
||||
*/
|
||||
function verifyNoFiles(state: ClientState): void {
|
||||
assert(
|
||||
state.files.size === 0,
|
||||
`Expected 0 files, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
export const offlineMoveThenRemoteDeleteTest: TestDefinition = {
|
||||
name: "Offline Move + Remote Delete Convergence",
|
||||
description:
|
||||
"Client 0 renames A→B offline while Client 1 deletes A. " +
|
||||
"The move+delete coalescing may use a stale path. " +
|
||||
"Both clients should converge to having no files.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: both have A.md
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "content to delete"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Client 0 goes offline, renames A→B
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
||||
|
||||
// Client 1 deletes A.md (broadcasts to server)
|
||||
{ type: "delete", client: 1, path: "A.md" },
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Client 0 reconnects — receives remote-delete while move is pending
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both should converge to no files
|
||||
{ type: "assert-not-exists", client: 0, path: "A.md" },
|
||||
{ type: "assert-not-exists", client: 1, path: "A.md" },
|
||||
{ type: "assert-not-exists", client: 0, path: "B.md" },
|
||||
{ type: "assert-not-exists", client: 1, path: "B.md" },
|
||||
{ type: "assert-consistent", verify: verifyNoFiles }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyLatestVersion(state: ClientState): void {
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.has("evolving.md"),
|
||||
`Expected evolving.md to exist, got: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
const content = state.files.get("evolving.md") ?? "";
|
||||
assert(
|
||||
content === "version-5-final",
|
||||
`Expected evolving.md to have "version-5-final", got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const offlineMultiUpdateCatchupTest: TestDefinition = {
|
||||
name: "Offline Client Catches Up After Multiple Updates",
|
||||
description:
|
||||
"Client 0 creates a file and both clients sync. Client 1 goes " +
|
||||
"offline. Client 0 updates the file 5 times. Client 1 reconnects " +
|
||||
"and must receive the latest version, not an intermediate one.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: create file and sync both clients
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "evolving.md",
|
||||
content: "version-0-initial"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "evolving.md",
|
||||
content: "version-0-initial"
|
||||
},
|
||||
|
||||
// Client 1 goes offline
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
// Client 0 makes several updates while client 1 is offline
|
||||
{ type: "update", client: 0, path: "evolving.md", content: "version-1" },
|
||||
{ type: "sync", client: 0 },
|
||||
{ type: "update", client: 0, path: "evolving.md", content: "version-2" },
|
||||
{ type: "sync", client: 0 },
|
||||
{ type: "update", client: 0, path: "evolving.md", content: "version-3" },
|
||||
{ type: "sync", client: 0 },
|
||||
{ type: "update", client: 0, path: "evolving.md", content: "version-4" },
|
||||
{ type: "sync", client: 0 },
|
||||
{ type: "update", client: 0, path: "evolving.md", content: "version-5-final" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
// Client 1 reconnects — should catch up to latest
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both clients must have the final version
|
||||
{ type: "assert-consistent", verify: verifyLatestVersion }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyOnlyLatestVersion(state: ClientState): void {
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.has("doc.md"),
|
||||
`Expected doc.md to exist, got: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
const content = state.files.get("doc.md") ?? "";
|
||||
assert(
|
||||
content === "edit-5-final",
|
||||
`Expected doc.md to have "edit-5-final" (latest edit), got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const offlineMultipleEditsTest: TestDefinition = {
|
||||
name: "Offline Multiple Edits Converge to Latest",
|
||||
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: [
|
||||
// Setup: create file and sync to both clients
|
||||
{ type: "create", client: 0, path: "doc.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "doc.md",
|
||||
content: "original"
|
||||
},
|
||||
|
||||
// Client 0 goes offline
|
||||
{ type: "disable-sync", client: 0 },
|
||||
|
||||
// Client 0 makes 5 sequential edits while offline
|
||||
{ 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" },
|
||||
|
||||
// Client 0 reconnects -- offline reconciliation should detect the
|
||||
// changed hash and sync the current on-disk content (edit-5-final)
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both clients should have the final version
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "edit-5-final"
|
||||
},
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "doc.md",
|
||||
content: "edit-5-final"
|
||||
},
|
||||
{ type: "assert-consistent", verify: verifyOnlyLatestVersion }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyAllPresent(state: ClientState): void {
|
||||
assert(
|
||||
state.files.size === 2,
|
||||
`Expected 2 files, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.get("A.md") === "from-client-0",
|
||||
`Expected A.md = "from-client-0", got: "${state.files.get("A.md")}"`
|
||||
);
|
||||
assert(
|
||||
state.files.get("B.md") === "from-client-1",
|
||||
`Expected B.md = "from-client-1", got: "${state.files.get("B.md")}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const offlineOperationsBothClientsTest: TestDefinition = {
|
||||
name: "Both Clients Offline Then Sync",
|
||||
description:
|
||||
"Both clients start offline. Client 0 creates A.md, Client 1 creates B.md. " +
|
||||
"Both enable sync simultaneously. Both files should appear on both clients.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Both clients create files while offline
|
||||
{ type: "create", client: 0, path: "A.md", content: "from-client-0" },
|
||||
{ type: "create", client: 1, path: "B.md", content: "from-client-1" },
|
||||
|
||||
// Both enable sync at the same time
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both should have both files
|
||||
{ type: "assert-exists", client: 0, path: "A.md" },
|
||||
{ type: "assert-exists", client: 0, path: "B.md" },
|
||||
{ type: "assert-exists", client: 1, path: "A.md" },
|
||||
{ type: "assert-exists", client: 1, path: "B.md" },
|
||||
{ type: "assert-consistent", verify: verifyAllPresent }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyContent(state: ClientState): void {
|
||||
// The file should be at B.md with the exact edited content
|
||||
assert(
|
||||
state.files.has("B.md"),
|
||||
`Expected B.md to exist, got: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
const content = state.files.get("B.md") ?? "";
|
||||
assert(
|
||||
content === "edited after rename",
|
||||
`Expected B.md to be "edited after rename", got: "${content}"`
|
||||
);
|
||||
|
||||
// A.md should not exist (renamed away)
|
||||
assert(
|
||||
!state.files.has("A.md"),
|
||||
`A.md should not exist after rename, got: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
|
||||
// Only B.md should exist
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected exactly 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
export const offlineRenameAndEditTest: TestDefinition = {
|
||||
name: "Offline Rename and Edit",
|
||||
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: [
|
||||
// Setup: create and sync
|
||||
{ type: "create", client: 0, path: "A.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
{ type: "assert-content", client: 1, path: "A.md", content: "original" },
|
||||
|
||||
// Client 0 goes offline, renames and edits
|
||||
{ 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" },
|
||||
|
||||
// Client 0 reconnects
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// A.md should be gone, B.md should have edited content
|
||||
{ type: "assert-not-exists", client: 0, path: "A.md" },
|
||||
{ type: "assert-not-exists", client: 1, path: "A.md" },
|
||||
{ type: "assert-consistent", verify: verifyContent }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* BUG/EDGE CASE: Both clients rename the same file to different targets.
|
||||
*
|
||||
* Client 0 renames X→Y, Client 1 renames X→Z. Both happen offline.
|
||||
* When they reconnect:
|
||||
*
|
||||
* - Client 0's rename (X→Y) goes through first → server has doc at Y
|
||||
* - Client 1's rename (X→Z): Client 1 still has the old metadata
|
||||
* pointing to X.md. But the server moved it to Y.md.
|
||||
*
|
||||
* The conflict: Client 1 will try to update with relativePath=Z.md
|
||||
* and parentVersionId pointing to the old state. The server sees the
|
||||
* path changed and processes it as a rename from Y→Z.
|
||||
*
|
||||
* Expected: The file ends up at one path (last rename wins), and both
|
||||
* clients converge. Content should be preserved.
|
||||
*/
|
||||
function verifyFinalState(state: ClientState): void {
|
||||
// X should not exist (renamed by both)
|
||||
assert(
|
||||
!state.files.has("X.md"),
|
||||
`X.md should not exist, files: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
|
||||
// Exactly one file should exist (either Y.md or Z.md)
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
|
||||
// Content should be preserved
|
||||
const content = Array.from(state.files.values())[0];
|
||||
assert(
|
||||
content === "original content",
|
||||
`Expected "original content", got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const offlineRenameBothClientsSameSourceTest: TestDefinition = {
|
||||
name: "Both Clients Rename Same File to Different Targets (Offline)",
|
||||
description:
|
||||
"Client 0 renames X→Y, Client 1 renames X→Z, both offline. " +
|
||||
"On reconnect, the conflicting renames should resolve and " +
|
||||
"both clients should converge to the same final path.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: create X.md
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "X.md",
|
||||
content: "original content"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both go offline
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
// Client 0: rename X→Y
|
||||
{ type: "rename", client: 0, oldPath: "X.md", newPath: "Y.md" },
|
||||
|
||||
// Client 1: rename X→Z
|
||||
{ type: "rename", client: 1, oldPath: "X.md", newPath: "Z.md" },
|
||||
|
||||
// Client 0 reconnects first
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
// Client 1 reconnects
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both clients should converge
|
||||
{ type: "assert-consistent", verify: verifyFinalState }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyRenamedFile(state: ClientState): void {
|
||||
const files = Array.from(state.files.keys()).sort();
|
||||
|
||||
// original.md should not exist (it was renamed)
|
||||
assert(
|
||||
!state.files.has("original.md"),
|
||||
`original.md should not exist. Files: ${files.join(", ")}`
|
||||
);
|
||||
|
||||
// renamed.md should exist with the content
|
||||
assert(
|
||||
state.files.has("renamed.md"),
|
||||
`Expected renamed.md to exist. Files: ${files.join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.get("renamed.md") === "pending content",
|
||||
`Expected "pending content", got: "${state.files.get("renamed.md")}"`
|
||||
);
|
||||
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected 1 file, got ${state.files.size}: ${files.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
export const offlineRenamePendingCreateTest: TestDefinition = {
|
||||
name: "Offline Rename of Pending Create Before Key Resolution",
|
||||
description:
|
||||
"Client 0 creates a file (pending, not yet synced). Sync is disabled " +
|
||||
"immediately. Client 0 renames the file locally. Sync is re-enabled. " +
|
||||
"The idempotency key system must handle the pending create at the new " +
|
||||
"path. The file should appear at the renamed path on both clients.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
|
||||
// Create file, then immediately disable sync
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "original.md",
|
||||
content: "pending content"
|
||||
},
|
||||
|
||||
// Rename while still offline (pending create not yet confirmed)
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "original.md",
|
||||
newPath: "renamed.md"
|
||||
},
|
||||
|
||||
// Re-enable sync — triggers key resolution + offline reconciliation
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both clients should have renamed.md with the content
|
||||
{ type: "assert-not-exists", client: 0, path: "original.md" },
|
||||
{ type: "assert-not-exists", client: 1, path: "original.md" },
|
||||
{ type: "assert-consistent", verify: verifyRenamedFile }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyResult(state: ClientState): void {
|
||||
const files = Array.from(state.files.keys()).sort();
|
||||
|
||||
// Y.md should exist — the renamed original document with
|
||||
// Client 1's updated content merged in.
|
||||
assert(
|
||||
state.files.has("Y.md"),
|
||||
`Expected Y.md to exist. Files: ${files.join(", ")}`
|
||||
);
|
||||
const content = state.files.get("Y.md") ?? "";
|
||||
assert(
|
||||
content.includes("updated-by-client-1"),
|
||||
`Expected Y.md to contain "updated-by-client-1", got: "${content}"`
|
||||
);
|
||||
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected exactly 1 file, got ${state.files.size}: ${files.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
export const offlineRenameRemoteCreateOldPathTest: TestDefinition = {
|
||||
name: "Offline Rename + Remote Create at Old Path",
|
||||
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: [
|
||||
// Setup: create X.md and sync
|
||||
{ type: "create", client: 0, path: "X.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "X.md",
|
||||
content: "original"
|
||||
},
|
||||
|
||||
// Client 0 goes offline and renames
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "X.md",
|
||||
newPath: "Y.md"
|
||||
},
|
||||
|
||||
// Client 1 updates the same document at X.md
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "X.md",
|
||||
content: "updated-by-client-1"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Client 0 reconnects — must detect move AND merge with update
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both clients should converge: Y.md with Client 1's content
|
||||
{ type: "assert-consistent", verify: verifyResult }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyFinalState(state: ClientState): void {
|
||||
const files = Array.from(state.files.keys());
|
||||
// Client 0 updated both files, then deleted B.md.
|
||||
// Client 1 updated B.md while Client 0 was offline.
|
||||
//
|
||||
// After reconnect:
|
||||
// - A.md should have Client 0's update
|
||||
// - B.md: Client 0 deleted it (local intent), Client 1 updated it
|
||||
// (remote update). The coalescing path determines which wins.
|
||||
// Current behavior: delete wins (local-delete + remote-update
|
||||
// coalesces differently depending on ordering).
|
||||
assert(
|
||||
state.files.has("A.md"),
|
||||
`Expected A.md to exist, got: ${files.join(", ")}`
|
||||
);
|
||||
const aContent = state.files.get("A.md") ?? "";
|
||||
assert(
|
||||
aContent === "A updated by client 0",
|
||||
`Expected A.md to have Client 0's update, got: "${aContent}"`
|
||||
);
|
||||
|
||||
// B.md should be gone (Client 0 deleted it)
|
||||
assert(
|
||||
!state.files.has("B.md"),
|
||||
`Expected B.md to be deleted, got: ${files.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests a complex offline scenario: Client 0 goes offline, updates
|
||||
* two files, then deletes one of them. Meanwhile Client 1 updates
|
||||
* the file that Client 0 will delete. When Client 0 comes online,
|
||||
* the reconciliation must handle:
|
||||
* 1. A.md: local update (straightforward)
|
||||
* 2. B.md: deleted locally + updated remotely (conflict)
|
||||
*
|
||||
* This exercises the offline reconciliation ordering:
|
||||
* updates are enqueued before deletes, and coalescing with
|
||||
* remote updates received during reconnect.
|
||||
*/
|
||||
export const offlineUpdateBothThenDeleteOneTest: TestDefinition = {
|
||||
name: "Offline Update Both Files Then Delete One",
|
||||
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: [
|
||||
// Setup: create two files
|
||||
{
|
||||
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: "sync" },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "A.md",
|
||||
content: "A original"
|
||||
},
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "B.md",
|
||||
content: "B original"
|
||||
},
|
||||
|
||||
// Client 0 goes offline
|
||||
{ type: "disable-sync", client: 0 },
|
||||
|
||||
// Client 0 updates both files
|
||||
{
|
||||
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"
|
||||
},
|
||||
|
||||
// Client 0 deletes B.md
|
||||
{ type: "delete", client: 0, path: "B.md" },
|
||||
|
||||
// Meanwhile Client 1 updates B.md
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "B.md",
|
||||
content: "B updated by client 1"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Client 0 comes online
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "assert-consistent", verify: verifyFinalState }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyMergedEdits(state: ClientState): void {
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected 1 file, got ${state.files.size}`
|
||||
);
|
||||
assert(
|
||||
state.files.has("doc.md"),
|
||||
`Expected doc.md to exist`
|
||||
);
|
||||
const content = state.files.get("doc.md") ?? "";
|
||||
|
||||
// Both edits should be present in the merged result.
|
||||
// Client 0 added "alpha addition" and Client 1 added "beta addition".
|
||||
// The shared heading and footer should be preserved.
|
||||
assert(
|
||||
content.includes("# Title"),
|
||||
`Expected "# Title" to be preserved, got: "${content}"`
|
||||
);
|
||||
assert(
|
||||
content.includes("alpha addition"),
|
||||
`Expected Client 0's edit "alpha addition" to be present, got: "${content}"`
|
||||
);
|
||||
assert(
|
||||
content.includes("beta addition"),
|
||||
`Expected Client 1's edit "beta addition" to be present, got: "${content}"`
|
||||
);
|
||||
assert(
|
||||
content.includes("footer"),
|
||||
`Expected "footer" to be preserved, got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const overlappingEditsSameSectionTest: TestDefinition = {
|
||||
name: "Overlapping Edits in Same Section",
|
||||
description:
|
||||
"Both clients edit the same document by adding content to different " +
|
||||
"parts of the same section. Client 0 adds a line after the heading, " +
|
||||
"Client 1 adds a line before the footer. The 3-way merge should " +
|
||||
"preserve both edits without data loss.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: create a multi-line document
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "# Title\n\nfooter"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both clients go offline and edit the same document
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
// Client 0: add line after heading
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "# Title\nalpha addition\n\nfooter"
|
||||
},
|
||||
|
||||
// Client 1: add line before footer
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "doc.md",
|
||||
content: "# Title\n\nbeta addition\nfooter"
|
||||
},
|
||||
|
||||
// Both reconnect
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both edits should be merged
|
||||
{ type: "assert-consistent", verify: verifyMergedEdits }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* BUG: Queue reset discards local events embedded in remote action types.
|
||||
*
|
||||
* In sync-event-queue.ts reset() (line 172-179):
|
||||
* for (const [key, state] of this.documentStates.entries()) {
|
||||
* if (state.action === "remote-update" || state.action === "remote-delete") {
|
||||
* this.documentStates.delete(key);
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* This removes all actions with type "remote-update" or "remote-delete".
|
||||
* But coalescing can embed local events INTO remote actions:
|
||||
*
|
||||
* remote-update + local-update = remote-update (line 262-264)
|
||||
* remote-delete + local-update = remote-delete (line 295-297)
|
||||
* remote-delete + local-move = remote-delete (line 301-303)
|
||||
*
|
||||
* When the queue resets (WebSocket disconnect), these coalesced actions
|
||||
* are removed — silently discarding the local-update/move intent.
|
||||
*
|
||||
* The local edit IS recovered on the next reconnect via
|
||||
* scheduleSyncForOfflineChanges() (which scans the filesystem and
|
||||
* detects hash mismatches). But there is a narrow window where the
|
||||
* edit could be lost if metadata was partially updated.
|
||||
*
|
||||
* This test verifies that local edits survive a disconnect that happens
|
||||
* while the edit is coalesced with a remote event.
|
||||
*/
|
||||
function verifyEditSurvived(state: ClientState): void {
|
||||
assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`);
|
||||
assert(state.files.has("doc.md"), "Expected doc.md to exist");
|
||||
const content = state.files.get("doc.md")!;
|
||||
// Both edits should survive — the filesystem scan on reconnect must recover the local edit
|
||||
assert(
|
||||
content.includes("from client 0") && content.includes("from client 1"),
|
||||
`Expected merged content with both edits, got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const queueResetLosesCoalescedLocalEditTest: TestDefinition = {
|
||||
name: "Queue Reset Preserves Coalesced Local Edits",
|
||||
description:
|
||||
"When a local-update is coalesced into a remote-update action " +
|
||||
"and then the WebSocket disconnects, the queue reset removes " +
|
||||
"the remote-update — potentially losing the local edit. " +
|
||||
"The filesystem scan on reconnect should recover it.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: both clients have doc.md
|
||||
{ type: "create", client: 0, path: "doc.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Client 1 edits — this will broadcast a remote-update to client 0
|
||||
{ type: "update", client: 1, path: "doc.md", content: "from client 1" },
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Client 0 edits (local-update) — may coalesce with the pending
|
||||
// remote-update in the queue as: remote-update + local-update = remote-update
|
||||
{ type: "update", client: 0, path: "doc.md", content: "from client 0" },
|
||||
|
||||
// Immediately disconnect client 0 — queue.reset() removes remote events
|
||||
{ type: "disable-sync", client: 0 },
|
||||
|
||||
// Reconnect — scheduleSyncForOfflineChanges should detect the
|
||||
// local edit via hash mismatch and re-queue it
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both must converge with the local edit preserved
|
||||
{ type: "assert-consistent", verify: verifyEditSurvived }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* EDGE CASE: Rapid create-update-delete cycle tests coalescing correctness.
|
||||
*
|
||||
* When events arrive faster than the queue can process them, coalescing
|
||||
* determines the final action. This tests the full cycle:
|
||||
*
|
||||
* create + update = create (content read at sync time)
|
||||
* create + delete = noop
|
||||
*
|
||||
* So a create-update-delete sequence should coalesce to noop and never
|
||||
* reach the server at all.
|
||||
*
|
||||
* But then a new create follows:
|
||||
* noop + create = create
|
||||
*
|
||||
* The final file should be synced correctly.
|
||||
*/
|
||||
function verifyFinalState(state: ClientState): void {
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
assert(state.files.has("cycle.md"), "Expected cycle.md to exist");
|
||||
const content = state.files.get("cycle.md") ?? "";
|
||||
assert(
|
||||
content === "final creation",
|
||||
`Expected "final creation", got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const rapidCreateUpdateDeleteCycleTest: TestDefinition = {
|
||||
name: "Rapid Create-Update-Delete-Create Cycle",
|
||||
description:
|
||||
"Client 0 rapidly creates, updates, deletes, then re-creates a file. " +
|
||||
"The event coalescing should correctly reduce this to a single create " +
|
||||
"of the final content. Client 1 should see only the final file.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Pause server so all operations coalesce before being processed
|
||||
{ type: "pause-server" },
|
||||
|
||||
// Rapid cycle: create → update → delete
|
||||
{
|
||||
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" },
|
||||
|
||||
// Re-create with final content
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "cycle.md",
|
||||
content: "final creation"
|
||||
},
|
||||
|
||||
// Resume server
|
||||
{ type: "resume-server" },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "assert-consistent", verify: verifyFinalState }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const rapidSyncToggleTest: TestDefinition = {
|
||||
name: "Rapid Sync Toggle",
|
||||
description:
|
||||
"Client 0 creates a file, then toggles sync off and on multiple times. " +
|
||||
"The file should eventually sync to Client 1 without deadlocks or data loss.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 1 },
|
||||
|
||||
// Create a file while offline
|
||||
{ type: "create", client: 0, path: "stable.md", content: "must survive toggles" },
|
||||
|
||||
// Toggle sync on client 0 multiple times
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 0 },
|
||||
|
||||
// Final enable — this one must succeed
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "assert-exists", client: 0, path: "stable.md" },
|
||||
{ type: "assert-exists", client: 1, path: "stable.md" },
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "stable.md",
|
||||
content: "must survive toggles"
|
||||
},
|
||||
{ type: "assert-consistent" }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyFinalState(state: ClientState): void {
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected 1 file, got ${state.files.size}`
|
||||
);
|
||||
assert(
|
||||
state.files.has("doc.md"),
|
||||
`Expected doc.md to exist`
|
||||
);
|
||||
const content = state.files.get("doc.md") ?? "";
|
||||
|
||||
// After the merge and three rapid updates, "update 3" should be present.
|
||||
// Earlier updates may be coalesced, but the final state must include the
|
||||
// last update's content.
|
||||
assert(
|
||||
content.includes("update 3"),
|
||||
`Expected final content to include "update 3", got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const rapidUpdatesAfterMergeTest: TestDefinition = {
|
||||
name: "Rapid Sequential Updates After Concurrent Merge",
|
||||
description:
|
||||
"Both clients create the same file (triggering a merge). After merge " +
|
||||
"completes, Client 0 rapidly sends three updates in succession. Each " +
|
||||
"update must correctly use the content cache to compute diffs against " +
|
||||
"the right parent version. Tests that the cache stores server content " +
|
||||
"(not local content) after MergingUpdate.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Both create at same path (triggers merge)
|
||||
{ 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: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// After merge, Client 0 sends rapid sequential updates
|
||||
{
|
||||
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: "sync", client: 0 },
|
||||
|
||||
// Wait for propagation
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both clients must converge with update 3
|
||||
{ type: "assert-consistent", verify: verifyFinalState }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* BUG FIX: recentlyDeletedIds must be cleared on reconnect.
|
||||
*
|
||||
* Scenario:
|
||||
* 1. Client 0 creates and syncs doc.md
|
||||
* 2. Client 0 deletes doc.md (adds to recentlyDeletedIds)
|
||||
* 3. Client 0 goes offline
|
||||
* 4. Client 1 creates a NEW doc.md (different documentId)
|
||||
* 5. Client 0 comes online
|
||||
* 6. Client 0 should receive the new doc.md from client 1
|
||||
* (recentlyDeletedIds should have been cleared on reconnect so
|
||||
* the new documentId is not blocked)
|
||||
*/
|
||||
function verifyFileExists(state: ClientState): void {
|
||||
assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`);
|
||||
assert(state.files.has("doc.md"), "Expected doc.md to exist");
|
||||
const content = state.files.get("doc.md") ?? "";
|
||||
assert(
|
||||
content === "new content from client 1",
|
||||
`Expected "new content from client 1", got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const recentlyDeletedClearedOnReconnectTest: TestDefinition = {
|
||||
name: "Recently Deleted IDs Cleared On Reconnect",
|
||||
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: [
|
||||
// Setup: both online
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Client 0 creates and syncs a file
|
||||
{ type: "create", client: 0, path: "doc.md", content: "original" },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Client 0 deletes the file
|
||||
{ type: "delete", client: 0, path: "doc.md" },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Client 0 goes offline
|
||||
{ type: "disable-sync", client: 0 },
|
||||
|
||||
// Client 1 creates a new file at the same path
|
||||
{ type: "create", client: 1, path: "doc.md", content: "new content from client 1" },
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Client 0 comes back online - should receive the new file
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "assert-consistent", verify: verifyFileExists },
|
||||
],
|
||||
};
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* BUG: Smart create merge with empty parent can lose content.
|
||||
*
|
||||
* When the server merges a create with an existing document, it uses a
|
||||
* 3-way merge with empty parent: reconcile("", existingContent, newContent).
|
||||
*
|
||||
* This is correct when both sides are independent additions. But when the
|
||||
* existing content was an UPDATE (replacing previous content), the merge
|
||||
* treats the update as an addition and produces garbled output.
|
||||
*
|
||||
* Specifically: if existingContent = "updated by client 1" (which replaced
|
||||
* "original"), the merge sees it as an addition of "updated by client 1"
|
||||
* from nothing. The new content "created by client 0" is also an addition
|
||||
* from nothing. The merge concatenates both — but the word fragments from
|
||||
* "created" can bleed into "updated", producing garbage like
|
||||
* "createdupdated by client 0 offline".
|
||||
*
|
||||
* This test verifies that the system produces a VALID merge where at least
|
||||
* both clients' content fragments appear, even if the merge isn't perfect.
|
||||
*
|
||||
* Root cause: The empty parent in merge_with_stored_version (CLAUDE.md
|
||||
* invariant #15) is necessary to prevent last-write-wins, but it can
|
||||
* produce suboptimal merges when one side is a replacement of previous
|
||||
* content (not a pure addition).
|
||||
*/
|
||||
function verifyMergedContent(state: ClientState): void {
|
||||
assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`);
|
||||
assert(state.files.has("notes.md"), "Expected notes.md to exist");
|
||||
const content = state.files.get("notes.md") ?? "";
|
||||
// Both pieces of content should appear in the merge
|
||||
assert(
|
||||
content.includes("client 1 update") && content.includes("client 0 offline"),
|
||||
`Expected merged content to contain fragments from both clients, got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const reconcilePendingAtOccupiedPathTest: TestDefinition = {
|
||||
name: "Offline Create at Path Updated by Other Client",
|
||||
description:
|
||||
"Client 1 creates and updates a file. Client 0 goes offline and " +
|
||||
"creates a file at the same path. On reconnect, the server merges " +
|
||||
"with empty parent. Both clients should converge.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Client 1 creates and updates
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "notes.md",
|
||||
content: "client 1 original"
|
||||
},
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Enable Client 0, sync, then go offline
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Client 1 updates the file
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "notes.md",
|
||||
content: "client 1 update replaces everything"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Client 0 goes offline and creates at same path
|
||||
{ type: "disable-sync", client: 0 },
|
||||
|
||||
// Delete the synced copy and create new content
|
||||
{ type: "delete", client: 0, path: "notes.md" },
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "notes.md",
|
||||
content: "client 0 offline creates new content"
|
||||
},
|
||||
|
||||
// Reconnect
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Should converge (possibly with suboptimal merge)
|
||||
{ type: "assert-consistent", verify: verifyMergedContent }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* BUG: remote-delete + local-update = remote-delete silently discards user edit.
|
||||
*
|
||||
* In sync-events.ts coalesceFromRemoteDelete (line 295-297):
|
||||
* case "local-update":
|
||||
* return current; // remote-delete absorbs the local-update
|
||||
*
|
||||
* This means if a remote-delete broadcast arrives and then the user edits
|
||||
* the file before the event is processed, the local edit is discarded at
|
||||
* the coalescing level. The executor only sees "remote-delete" and deletes
|
||||
* the file, permanently losing the user's work.
|
||||
*
|
||||
* Compare with coalesceFromUpdate (line 148-152) where:
|
||||
* update + remote-delete = update (user edit takes precedence)
|
||||
*
|
||||
* The semantics should be the same: the user has unsaved local changes that
|
||||
* should survive. But the ordering of events (remote-delete arrives FIRST)
|
||||
* causes the user's intent to be silently discarded.
|
||||
*
|
||||
* This test verifies that when a remote-delete and a local-update race,
|
||||
* both clients converge. The current behavior is that the file gets deleted
|
||||
* (user's edit is lost). This test documents this data-loss scenario.
|
||||
*/
|
||||
function verifyState(state: ClientState): void {
|
||||
// Current behavior: the file is deleted (remote-delete wins).
|
||||
// Ideal behavior: the user's edit should survive.
|
||||
// We test for convergence — both clients must agree.
|
||||
//
|
||||
// If the file exists, it should contain the user's edit.
|
||||
// If it doesn't exist, both must agree on deletion.
|
||||
if (state.files.size > 0) {
|
||||
assert(
|
||||
state.files.has("doc.md"),
|
||||
`Unexpected files: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
const content = state.files.get("doc.md")!;
|
||||
assert(
|
||||
content === "edited by local user",
|
||||
`Expected local edit content, got: "${content}"`
|
||||
);
|
||||
}
|
||||
// Either outcome is acceptable as long as both clients converge
|
||||
}
|
||||
|
||||
export const remoteDeleteCoalesceLosesLocalUpdateTest: TestDefinition = {
|
||||
name: "Remote Delete + Local Update Coalescing Race",
|
||||
description:
|
||||
"When a remote-delete broadcast arrives and the user then edits the " +
|
||||
"same file, the coalescing (remote-delete + local-update = remote-delete) " +
|
||||
"discards the user's edit. Both clients should converge.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: both clients have doc.md
|
||||
{ type: "create", client: 0, path: "doc.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both go offline
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
// Client 1 deletes the file
|
||||
{ type: "delete", client: 1, path: "doc.md" },
|
||||
|
||||
// Client 0 edits the file
|
||||
{ type: "update", client: 0, path: "doc.md", content: "edited by local user" },
|
||||
|
||||
// Client 1 comes online first — delete is sent to server
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Client 0 comes online — receives remote-delete, then its
|
||||
// local-update coalesces with it
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both must converge
|
||||
{ type: "assert-consistent", verify: verifyState }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyAllDeleted(state: ClientState): void {
|
||||
const files = Array.from(state.files.keys());
|
||||
assert(
|
||||
state.files.size === 0,
|
||||
`Expected no files (document was deleted after rename chain), got ${state.files.size}: ${files.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
export const renameChainThenDeleteTest: TestDefinition = {
|
||||
name: "Rename Chain Then Delete (Offline Catchup)",
|
||||
description:
|
||||
"Client 0 creates X.md and syncs. Client 1 goes offline. Client 0 " +
|
||||
"renames X.md -> Y.md -> Z.md, then deletes Z.md. Client 1 reconnects " +
|
||||
"with X.md still on disk. The offline reconciliation must detect that " +
|
||||
"the document was deleted (despite the rename chain) and remove X.md.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: create and sync
|
||||
{ type: "create", client: 0, path: "X.md", content: "chain-content" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "X.md",
|
||||
content: "chain-content"
|
||||
},
|
||||
|
||||
// Client 1 goes offline
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
// Client 0: rename chain X -> Y -> Z, then delete Z
|
||||
{
|
||||
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 },
|
||||
|
||||
// Client 1 reconnects — should detect X.md's document is deleted
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both clients must agree: no files
|
||||
{ type: "assert-consistent", verify: verifyAllDeleted }
|
||||
]
|
||||
};
|
||||
36
frontend/deterministic-tests/src/tests/rename-chain.test.ts
Normal file
36
frontend/deterministic-tests/src/tests/rename-chain.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const renameChainTest: TestDefinition = {
|
||||
name: "Rename Chain",
|
||||
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 },
|
||||
|
||||
// Client 0 creates and renames while offline
|
||||
{ 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" },
|
||||
|
||||
// Enable sync — reconciliation discovers C.md as a new file
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Only C.md should exist on both clients
|
||||
{ type: "assert-not-exists", client: 0, path: "A.md" },
|
||||
{ type: "assert-not-exists", client: 0, path: "B.md" },
|
||||
{ type: "assert-exists", client: 0, path: "C.md" },
|
||||
{ type: "assert-content", client: 0, path: "C.md", content: "important content" },
|
||||
|
||||
{ type: "assert-not-exists", client: 1, path: "A.md" },
|
||||
{ type: "assert-not-exists", client: 1, path: "B.md" },
|
||||
{ type: "assert-exists", client: 1, path: "C.md" },
|
||||
{ type: "assert-content", client: 1, path: "C.md", content: "important content" },
|
||||
|
||||
{ type: "assert-consistent" }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyCircularRotation(state: ClientState): void {
|
||||
// Temp file must not survive the rotation
|
||||
assert(
|
||||
!state.files.has("temp-a.md"),
|
||||
`temp-a.md should not exist after rotation, got: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
|
||||
// Exactly 3 files should exist
|
||||
assert(
|
||||
state.files.size === 3,
|
||||
`Expected exactly 3 files after rotation, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
|
||||
assert(
|
||||
state.files.has("A.md"),
|
||||
`Expected A.md to exist, got: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.has("B.md"),
|
||||
`Expected B.md to exist, got: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.has("C.md"),
|
||||
`Expected C.md to exist, got: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
|
||||
// After circular rename A->B, B->C, C->A:
|
||||
// A.md should have C's original content
|
||||
// B.md should have A's original content
|
||||
// C.md should have B's original content
|
||||
assert(
|
||||
state.files.get("A.md") === "content-c",
|
||||
`Expected A.md to have "content-c" after rotation, got: "${state.files.get("A.md")}"`
|
||||
);
|
||||
assert(
|
||||
state.files.get("B.md") === "content-a",
|
||||
`Expected B.md to have "content-a" after rotation, got: "${state.files.get("B.md")}"`
|
||||
);
|
||||
assert(
|
||||
state.files.get("C.md") === "content-b",
|
||||
`Expected C.md to have "content-b" after rotation, got: "${state.files.get("C.md")}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const renameCircularTest: TestDefinition = {
|
||||
name: "Circular Rename Chain (3-Way Swap)",
|
||||
description:
|
||||
"Client 0 has A.md, B.md, C.md synced. Goes offline and performs a " +
|
||||
"circular rename: A->B, B->C, C->A. This requires temp files to avoid " +
|
||||
"overwriting. When Client 0 reconnects, all three files should have " +
|
||||
"rotated content on both clients.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: create three files and sync to both clients
|
||||
{ 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: "sync" },
|
||||
{ type: "barrier" },
|
||||
{ type: "assert-content", client: 1, path: "A.md", content: "content-a" },
|
||||
{ type: "assert-content", client: 1, path: "B.md", content: "content-b" },
|
||||
{ type: "assert-content", client: 1, path: "C.md", content: "content-c" },
|
||||
|
||||
// Client 0 goes offline and performs the 3-way circular rename
|
||||
// To avoid overwriting, we use temp files:
|
||||
// 1. A.md -> temp-a.md (save A's content)
|
||||
// 2. C.md -> A.md (A now has C's content)
|
||||
// 3. B.md -> C.md (C now has B's content)
|
||||
// 4. temp-a.md -> B.md (B now has A's content)
|
||||
{ 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" },
|
||||
|
||||
// Client 0 reconnects
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Temp file should not exist on either client
|
||||
{ type: "assert-not-exists", client: 0, path: "temp-a.md" },
|
||||
{ type: "assert-not-exists", client: 1, path: "temp-a.md" },
|
||||
|
||||
// All three files should exist with rotated content
|
||||
{ type: "assert-consistent", verify: verifyCircularRotation }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyConflictResolution(state: ClientState): void {
|
||||
const files = Array.from(state.files.keys());
|
||||
|
||||
// B.md should exist (client 1 renamed A.md to B.md, and client 0
|
||||
// created B.md with same content — the server merges them)
|
||||
assert(
|
||||
state.files.has("B.md"),
|
||||
`Expected B.md to exist, got: ${files.join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.get("B.md") === "hi",
|
||||
`Expected B.md to have "hi", got: "${state.files.get("B.md")}"`
|
||||
);
|
||||
|
||||
// A.md should not exist (it was renamed to B.md)
|
||||
assert(
|
||||
!state.files.has("A.md"),
|
||||
`A.md should not exist after rename, got: ${files.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
export const renameCreateConflictTest: TestDefinition = {
|
||||
name: "Rename-Create Conflict",
|
||||
description:
|
||||
"Client 0 creates file A, Client 1 renames A to B, then Client 0 (without syncing) creates B. " +
|
||||
"The system must resolve the conflict deterministically.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "create", client: 0, path: "A.md", content: "hi" },
|
||||
{ type: "sync", client: 0 },
|
||||
{ type: "sync", client: 1 },
|
||||
{ type: "assert-exists", client: 1, path: "A.md" },
|
||||
{ type: "assert-content", client: 1, path: "A.md", content: "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: "sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
{ type: "assert-consistent", verify: verifyConflictResolution }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* BUG: Renaming an empty file offline causes delete+create instead of move.
|
||||
*
|
||||
* In vfs.ts reconcileWithDisk (line 802-805):
|
||||
* if (fileHash === undefined || fileHash === EMPTY_HASH) {
|
||||
* remainingNew.push(path);
|
||||
* continue;
|
||||
* }
|
||||
*
|
||||
* Empty files (hash === EMPTY_HASH) are excluded from hash-based move
|
||||
* detection. When an empty file is renamed offline, the reconciliation
|
||||
* treats it as:
|
||||
* - Old path: missing file → delete
|
||||
* - New path: new file → create
|
||||
*
|
||||
* This loses the document's identity (gets a new documentId on the server).
|
||||
* The observable consequence is that the file appears as deleted+created
|
||||
* rather than renamed, and version history is lost.
|
||||
*
|
||||
* This test verifies that both clients converge after an empty file
|
||||
* rename. The file should exist at the new path on both clients.
|
||||
*/
|
||||
function verifyRenamedFile(state: ClientState): void {
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
assert(
|
||||
!state.files.has("empty.md"),
|
||||
"empty.md should not exist (was renamed)"
|
||||
);
|
||||
assert(
|
||||
state.files.has("renamed.md"),
|
||||
"renamed.md should exist (renamed from empty.md)"
|
||||
);
|
||||
assert(
|
||||
state.files.get("renamed.md") === "",
|
||||
`Expected empty content, got: "${state.files.get("renamed.md")}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const renameEmptyFileLosesIdentityTest: TestDefinition = {
|
||||
name: "Rename Empty File Loses Document Identity",
|
||||
description:
|
||||
"When an empty file is renamed offline, the reconciliation cannot " +
|
||||
"detect it as a move (empty files are excluded from hash-based " +
|
||||
"move detection). This causes delete+create instead of move, " +
|
||||
"losing the document's server-side identity/history.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Create and sync an empty file
|
||||
{ type: "create", client: 0, path: "empty.md", content: "" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
{ type: "assert-exists", client: 1, path: "empty.md" },
|
||||
|
||||
// Client 0 goes offline and renames
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "rename", client: 0, oldPath: "empty.md", newPath: "renamed.md" },
|
||||
|
||||
// Reconnect
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both should have only renamed.md
|
||||
{ type: "assert-not-exists", client: 0, path: "empty.md" },
|
||||
{ type: "assert-not-exists", client: 1, path: "empty.md" },
|
||||
{ type: "assert-exists", client: 0, path: "renamed.md" },
|
||||
{ type: "assert-exists", client: 1, path: "renamed.md" },
|
||||
{ type: "assert-consistent", verify: verifyRenamedFile }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyNestedPath(state: ClientState): void {
|
||||
const files = Array.from(state.files.keys());
|
||||
assert(
|
||||
!files.includes("a.md"),
|
||||
`a.md should not exist after rename to nested path, got: ${files.join(", ")}`
|
||||
);
|
||||
assert(
|
||||
files.includes("folder/subfolder/a.md"),
|
||||
`Expected folder/subfolder/a.md to exist, got: ${files.join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.get("folder/subfolder/a.md") === "nested content",
|
||||
`Expected nested file to have "nested content", got: "${state.files.get("folder/subfolder/a.md")}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const renameNestedPathTest: TestDefinition = {
|
||||
name: "Rename to Deeply Nested Path",
|
||||
description:
|
||||
"Client 0 creates a.md at the root, then renames it to folder/subfolder/a.md " +
|
||||
"while offline. When Client 0 reconnects, the file should appear at the " +
|
||||
"nested path on both clients. Tests that the system handles directory " +
|
||||
"creation for deeply nested rename targets.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: create file at root and sync
|
||||
{ type: "create", client: 0, path: "a.md", content: "nested content" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
{ type: "assert-content", client: 1, path: "a.md", content: "nested content" },
|
||||
|
||||
// Client 0 goes offline and renames to nested path
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "rename", client: 0, oldPath: "a.md", newPath: "folder/subfolder/a.md" },
|
||||
|
||||
// Client 0 reconnects
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Original path gone, nested path exists
|
||||
{ type: "assert-not-exists", client: 0, path: "a.md" },
|
||||
{ type: "assert-not-exists", client: 1, path: "a.md" },
|
||||
{ type: "assert-exists", client: 0, path: "folder/subfolder/a.md" },
|
||||
{ type: "assert-exists", client: 1, path: "folder/subfolder/a.md" },
|
||||
{ type: "assert-consistent", verify: verifyNestedPath }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* BUG: Renaming a file while its create request is in-flight orphans the document.
|
||||
*
|
||||
* Scenario:
|
||||
* 1. Client 0 creates `doc.md` (pending create, HTTP request in-flight)
|
||||
* 2. Server is paused so the create stalls
|
||||
* 3. Client 0 renames `doc.md` → `renamed.md` before the response
|
||||
* 4. VFS.move() updates the pending document's path to `renamed.md`
|
||||
* 5. Server resumes, create response confirms document at `doc.md`
|
||||
* 6. The sync executor may fail to reconcile because the VFS no longer
|
||||
* has a document at `doc.md` — it was moved to `renamed.md`
|
||||
*
|
||||
* Expected: the file should end up at `renamed.md` on both clients.
|
||||
* The server document at `doc.md` should be renamed to `renamed.md`
|
||||
* via a follow-up sync operation.
|
||||
*/
|
||||
function verifyFileAtRenamedPath(state: ClientState): void {
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.has("renamed.md"),
|
||||
`Expected renamed.md to exist, got: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
const content = state.files.get("renamed.md") ?? "";
|
||||
assert(
|
||||
content === "original-content",
|
||||
`Expected "original-content", got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const renamePendingCreateBeforeResponseTest: TestDefinition = {
|
||||
name: "Rename Pending Create Before Server Response",
|
||||
description:
|
||||
"When a file is renamed while its create request is in-flight, " +
|
||||
"the document must not become orphaned. Both clients should " +
|
||||
"converge with the file at the renamed path.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Both clients online
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Pause server so the create stalls
|
||||
{ type: "pause-server" },
|
||||
|
||||
// Client 0 creates doc.md (request stalls at server)
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "original-content"
|
||||
},
|
||||
|
||||
// Wait for the create to enter the executor
|
||||
|
||||
// Client 0 renames the file WHILE create is in-flight
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "doc.md",
|
||||
newPath: "renamed.md"
|
||||
},
|
||||
|
||||
// Resume server — create response arrives for "doc.md"
|
||||
{ type: "resume-server" },
|
||||
|
||||
// Give time for create response + follow-up rename sync
|
||||
{ type: "sync" },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// File should be at renamed.md on both clients
|
||||
{ type: "assert-consistent", verify: verifyFileAtRenamedPath }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyRoundtrip(state: ClientState): void {
|
||||
const files = Array.from(state.files.keys());
|
||||
assert(
|
||||
files.includes("A.md"),
|
||||
`Expected A.md to exist after round-trip rename, got: ${files.join(", ")}`
|
||||
);
|
||||
assert(
|
||||
!files.includes("B.md"),
|
||||
`B.md should not exist after round-trip rename, got: ${files.join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.get("A.md") === "original",
|
||||
`Expected A.md to have "original" content, got: "${state.files.get("A.md")}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const renameRoundtripTest: TestDefinition = {
|
||||
name: "Rename Round-Trip (A->B->A)",
|
||||
description:
|
||||
"Client 0 creates A.md and syncs. Then renames A.md to B.md and syncs. " +
|
||||
"Then renames B.md back to A.md and syncs. Both clients should end with " +
|
||||
"A.md at the original path with the original content. B.md should not exist. " +
|
||||
"Tests that the system correctly handles a rename that returns to the " +
|
||||
"original path, especially regarding document identity tracking.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: create and sync
|
||||
{ type: "create", client: 0, path: "A.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
{ type: "assert-content", client: 1, path: "A.md", content: "original" },
|
||||
|
||||
// First rename: A.md -> B.md
|
||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Verify intermediate state: only B.md exists
|
||||
{ type: "assert-not-exists", client: 0, path: "A.md" },
|
||||
{ type: "assert-not-exists", client: 1, path: "A.md" },
|
||||
{ type: "assert-exists", client: 0, path: "B.md" },
|
||||
{ type: "assert-exists", client: 1, path: "B.md" },
|
||||
{ type: "assert-content", client: 0, path: "B.md", content: "original" },
|
||||
{ type: "assert-content", client: 1, path: "B.md", content: "original" },
|
||||
|
||||
// Second rename: B.md -> A.md (back to original path)
|
||||
{ type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Final state: back to A.md with original content
|
||||
{ type: "assert-not-exists", client: 0, path: "B.md" },
|
||||
{ type: "assert-not-exists", client: 1, path: "B.md" },
|
||||
{ type: "assert-consistent", verify: verifyRoundtrip }
|
||||
]
|
||||
};
|
||||
61
frontend/deterministic-tests/src/tests/rename-swap.test.ts
Normal file
61
frontend/deterministic-tests/src/tests/rename-swap.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifySwap(state: ClientState): void {
|
||||
assert(
|
||||
state.files.has("A.md"),
|
||||
`Expected A.md to exist, got: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.has("B.md"),
|
||||
`Expected B.md to exist, got: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
// After the swap, A.md should have B's original content and vice versa
|
||||
assert(
|
||||
state.files.get("A.md") === "content-b",
|
||||
`Expected A.md to have "content-b" after swap, got: "${state.files.get("A.md")}"`
|
||||
);
|
||||
assert(
|
||||
state.files.get("B.md") === "content-a",
|
||||
`Expected B.md to have "content-a" after swap, got: "${state.files.get("B.md")}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const renameSwapTest: TestDefinition = {
|
||||
name: "Offline Swap via Temp File",
|
||||
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 clients should have swapped content. " +
|
||||
"The temp file should not exist on either client.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: create both files and sync to both clients
|
||||
{ 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: "barrier" },
|
||||
{ type: "assert-content", client: 1, path: "A.md", content: "content-a" },
|
||||
{ type: "assert-content", client: 1, path: "B.md", content: "content-b" },
|
||||
|
||||
// Client 0 goes offline and performs the swap
|
||||
{ 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" },
|
||||
|
||||
// Client 0 reconnects
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// temp.md should not exist on either client
|
||||
{ type: "assert-not-exists", client: 0, path: "temp.md" },
|
||||
{ type: "assert-not-exists", client: 1, path: "temp.md" },
|
||||
|
||||
// Both clients should have the swapped content
|
||||
{ type: "assert-consistent", verify: verifySwap }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyFinalState(state: ClientState): void {
|
||||
// A.md should not exist (it was renamed)
|
||||
assert(!state.files.has("A.md"), "A.md should not exist after rename");
|
||||
// B.md should exist with the alpha content (from the renamed A.md)
|
||||
assert(state.files.has("B.md"), "B.md should exist");
|
||||
assert(
|
||||
state.files.get("B.md") === "alpha",
|
||||
`B.md should have "alpha" content, got: "${state.files.get("B.md")}"`
|
||||
);
|
||||
// The original B.md content ("beta") should be overwritten — only the
|
||||
// renamed content should survive. Verify no other files contain "beta".
|
||||
const allContent = Array.from(state.files.values()).join("\n");
|
||||
assert(
|
||||
!allContent.includes("beta"),
|
||||
`Expected "beta" to be gone after overwrite, but found it in: ${JSON.stringify(Object.fromEntries(state.files))}`
|
||||
);
|
||||
}
|
||||
|
||||
export const renameToExistingPathTest: TestDefinition = {
|
||||
name: "Rename to Existing Path",
|
||||
description:
|
||||
"Client 0 has A.md and B.md. Client 0 renames A.md to B.md (overwriting B.md). " +
|
||||
"Both clients should converge: A.md gone, B.md has A.md's content.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: create two files and sync
|
||||
{ type: "create", client: 0, path: "A.md", content: "alpha" },
|
||||
{ type: "create", client: 0, path: "B.md", content: "beta" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Client 0 renames A.md to B.md (overwrites B.md)
|
||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both should converge
|
||||
{ type: "assert-not-exists", client: 0, path: "A.md" },
|
||||
{ type: "assert-not-exists", client: 1, path: "A.md" },
|
||||
{ type: "assert-consistent", verify: verifyFinalState }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* EDGE CASE: Rename to the path of a document whose delete hasn't been
|
||||
* confirmed on the server yet.
|
||||
*
|
||||
* The VFS move() method (vfs.ts line 494-497) silently removes any existing
|
||||
* document at the target path from the pathIndex. If the target path holds
|
||||
* a tracked document that is about to be deleted (but the delete hasn't
|
||||
* been sent to the server yet), the move will remove it from pathIndex,
|
||||
* potentially causing a deleted-locally document to lose its path reference.
|
||||
*
|
||||
* Scenario:
|
||||
* 1. Both clients have A.md and B.md
|
||||
* 2. Client 0 goes offline, deletes A.md, renames B.md → A.md
|
||||
* 3. On reconnect:
|
||||
* - The delete of A.md is queued
|
||||
* - The rename of B.md → A.md needs VFS.move(B.md, A.md)
|
||||
* - But A.md is still in pathIndex (tracked, not yet deleted)
|
||||
* - VFS.move removes A.md from pathIndex before the delete is confirmed
|
||||
*
|
||||
* Expected: A.md's documentId is deleted on server, B.md's document
|
||||
* is renamed to A.md, both clients converge.
|
||||
*/
|
||||
function verifyFinalState(state: ClientState): void {
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
assert(state.files.has("A.md"), "Expected A.md to exist");
|
||||
const content = state.files.get("A.md") ?? "";
|
||||
assert(
|
||||
content === "content B",
|
||||
`Expected "content B", got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const renameToPathOfUnconfirmedDeleteTest: TestDefinition = {
|
||||
name: "Rename to Path of Unconfirmed Delete",
|
||||
description:
|
||||
"Client deletes A.md and renames B.md to A.md while offline. " +
|
||||
"On reconnect, the VFS must handle the path conflict between " +
|
||||
"the tracked A.md (pending delete) and the rename destination.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: both clients have A.md and B.md
|
||||
{
|
||||
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: "barrier" },
|
||||
|
||||
// Client 0 goes offline
|
||||
{ type: "disable-sync", client: 0 },
|
||||
|
||||
// Delete A.md, then rename B.md → A.md
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
{ type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" },
|
||||
|
||||
// Reconnect
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Should converge: A.md exists with B's content, B.md gone
|
||||
{ type: "assert-not-exists", client: 0, path: "B.md" },
|
||||
{ type: "assert-not-exists", client: 1, path: "B.md" },
|
||||
{ type: "assert-consistent", verify: verifyFinalState }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* BUG: syncLocallyUpdatedFile does not handle pending doc at target path.
|
||||
*
|
||||
* In syncer.ts syncLocallyUpdatedFile (lines 146-195), the if/else chain:
|
||||
* if (existingAtNew === undefined || existingAtNew.state === "deleted-locally")
|
||||
* else if (existingAtNew.state === "tracked")
|
||||
*
|
||||
* There is NO branch for existingAtNew.state === "pending". When a tracked
|
||||
* doc is renamed to a path occupied by a pending create:
|
||||
*
|
||||
* 1. No branch matches → vfsMoveSucceeded stays false
|
||||
* 2. Falls back to local-update at oldPath
|
||||
* 3. File is on disk at newPath (user renamed it)
|
||||
* 4. Executor reads from oldPath → FileNotFoundError
|
||||
* 5. Operation is silently dropped
|
||||
* 6. Tracked doc at oldPath becomes orphaned (VFS entry, no file)
|
||||
* 7. On next reconciliation, recovers via filesystem scan
|
||||
*
|
||||
* This test verifies that the rename eventually converges, even though
|
||||
* the initial sync attempt fails. The pending doc at the target path
|
||||
* should be handled properly.
|
||||
*/
|
||||
function verifyFinalState(state: ClientState): void {
|
||||
// After convergence, A.md should exist with B's content (B was
|
||||
// renamed to A, overwriting the pending A). B.md should not exist.
|
||||
assert(
|
||||
state.files.has("A.md"),
|
||||
`Expected A.md to exist, got: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
assert(
|
||||
!state.files.has("B.md"),
|
||||
`Expected B.md to not exist (was renamed to A.md), got: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
const content = state.files.get("A.md") ?? "";
|
||||
assert(
|
||||
content.includes("tracked B content"),
|
||||
`Expected A.md to have B's content, got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const renameToPendingPathFallbackTest: TestDefinition = {
|
||||
name: "Rename Tracked File to Path With Pending Create",
|
||||
description:
|
||||
"When a tracked document is renamed to a path occupied by a " +
|
||||
"pending create, the VFS move is skipped (no branch for pending " +
|
||||
"state). The fallback update fails with FileNotFoundError. " +
|
||||
"Reconciliation should eventually recover.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: B.md tracked and synced on both clients
|
||||
{ type: "create", client: 0, path: "B.md", content: "tracked B content" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Client 0 goes offline
|
||||
{ type: "disable-sync", client: 0 },
|
||||
|
||||
// Client 0 creates A.md (pending, never synced)
|
||||
{ type: "create", client: 0, path: "A.md", content: "pending A content" },
|
||||
|
||||
// Client 0 renames B.md → A.md (overwrites the pending A)
|
||||
// This triggers the missing-branch bug
|
||||
{ type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" },
|
||||
|
||||
// Re-enable sync
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Verify B.md is gone and A.md exists with B's content
|
||||
{ type: "assert-not-exists", client: 0, path: "B.md" },
|
||||
{ type: "assert-not-exists", client: 1, path: "B.md" },
|
||||
{ type: "assert-consistent", verify: verifyFinalState }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyConvergence(state: ClientState): void {
|
||||
const files = Array.from(state.files.keys()).sort();
|
||||
|
||||
// A.md should not exist (it was renamed away by Client 1)
|
||||
assert(
|
||||
!state.files.has("A.md"),
|
||||
`A.md should not exist after rename. Files: ${files.join(", ")}`
|
||||
);
|
||||
|
||||
// B.md should exist — Client 1 renamed A.md to B.md, reclaiming the
|
||||
// path that Client 0 had just deleted. Content should be "content-a".
|
||||
assert(
|
||||
state.files.has("B.md"),
|
||||
`Expected B.md to exist (renamed from A.md). Files: ${files.join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.get("B.md") === "content-a",
|
||||
`Expected B.md to have "content-a", got: "${state.files.get("B.md")}"`
|
||||
);
|
||||
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected exactly 1 file, got ${state.files.size}: ${files.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
export const renameToRecentlyDeletedPathTest: TestDefinition = {
|
||||
name: "Rename to a Path That Was Recently Deleted",
|
||||
description:
|
||||
"Client 0 deletes B.md and syncs. Client 1 (offline) renames A.md " +
|
||||
"to B.md — claiming the path that was just vacated. When Client 1 " +
|
||||
"reconnects, the rename should succeed at B.md without collision.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: create both files
|
||||
{ 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: "barrier" },
|
||||
|
||||
// Client 1 goes offline
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
// Client 0 deletes B.md
|
||||
{ type: "delete", client: 0, path: "B.md" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
// Client 1 (offline) renames A.md to B.md
|
||||
{
|
||||
type: "rename",
|
||||
client: 1,
|
||||
oldPath: "A.md",
|
||||
newPath: "B.md"
|
||||
},
|
||||
|
||||
// Client 1 reconnects
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both clients should converge: only B.md with content-a
|
||||
{ type: "assert-consistent", verify: verifyConvergence }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyResult(state: ClientState): void {
|
||||
const files = Array.from(state.files.keys()).sort();
|
||||
// The rename of B.md to A.md overwrites A.md on disk. The pending
|
||||
// create's content ("first file at A") is lost because the user
|
||||
// chose to overwrite it. VFS.move fails (A.md occupied by pending
|
||||
// create), so the fallback enqueues an update for B.md which fails
|
||||
// (FileNotFoundError — B.md no longer exists on disk).
|
||||
//
|
||||
// After reconciliation: A.md's pending create reads the overwritten
|
||||
// content ("tracked file B") from disk, and B.md is deleted
|
||||
// (missing from disk).
|
||||
//
|
||||
// Result: A.md with "tracked file B" content.
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected 1 file, got ${state.files.size}: ${files.join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.has("A.md"),
|
||||
`Expected A.md to exist. Files: ${files.join(", ")}`
|
||||
);
|
||||
const content = state.files.get("A.md") ?? "";
|
||||
assert(
|
||||
content === "tracked file B",
|
||||
`Expected A.md to have "tracked file B", got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* BUG: Tests VFS.move failure when renaming a tracked file to a path
|
||||
* occupied by a pending create. In syncer.ts, VFS.move is attempted
|
||||
* but fails if the target path is occupied by a non-deleted-locally
|
||||
* document. The move event falls back to an update at oldPath.
|
||||
*
|
||||
* When the user renames B.md to A.md, the filesystem overwrites A.md.
|
||||
* The pending create's original content is lost from disk. After sync,
|
||||
* only A.md survives with B.md's content.
|
||||
*/
|
||||
export const renameTrackedToOccupiedPendingPathTest: TestDefinition = {
|
||||
name: "Rename Tracked File to Path Occupied by Pending Create",
|
||||
description:
|
||||
"Client creates A.md (pending, sync disabled) then renames B.md " +
|
||||
"(tracked) to A.md. VFS.move should fail because A.md is occupied " +
|
||||
"by the pending create. The rename overwrites A.md on disk, so " +
|
||||
"only A.md survives with B.md's content.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: create B.md and sync it (becomes tracked)
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "B.md",
|
||||
content: "tracked file B"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "B.md",
|
||||
content: "tracked file B"
|
||||
},
|
||||
|
||||
// Disable sync on Client 0
|
||||
{ type: "disable-sync", client: 0 },
|
||||
|
||||
// Create A.md (pending — sync disabled, not yet synced)
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "first file at A"
|
||||
},
|
||||
|
||||
// Try to rename tracked B.md to A.md (occupied by pending)
|
||||
{ type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" },
|
||||
|
||||
// Re-enable sync — after reconciliation, A.md survives
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// A.md exists with B.md's content (rename overwrite)
|
||||
{ type: "assert-consistent", verify: verifyResult }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyConvergence(state: ClientState): void {
|
||||
const files = Array.from(state.files.keys());
|
||||
// A.md should not exist (it was renamed to B.md by client 0)
|
||||
assert(
|
||||
!files.includes("A.md"),
|
||||
`Expected A.md to not exist after rename, but found files: ${files.join(", ")}`
|
||||
);
|
||||
// B.md should exist (the rename target)
|
||||
assert(
|
||||
files.includes("B.md"),
|
||||
`Expected B.md to exist after rename, but found files: ${files.join(", ")}`
|
||||
);
|
||||
// B.md should contain client 1's update (merged with the rename)
|
||||
const content = state.files.get("B.md") ?? "";
|
||||
assert(
|
||||
content.includes("updated"),
|
||||
`Expected B.md to contain "updated" from client 1's edit, got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const renameUpdateConflictTest: TestDefinition = {
|
||||
name: "Rename vs Update Conflict",
|
||||
description:
|
||||
"Client 0 renames A.md to B.md while Client 1 (offline) updates A.md. " +
|
||||
"When Client 1 reconnects, the update should be applied to B.md (the " +
|
||||
"renamed file) via 3-way merge. Both clients should converge.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: create A.md and sync to both clients
|
||||
{ type: "create", client: 0, path: "A.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
{ type: "assert-content", client: 1, path: "A.md", content: "original" },
|
||||
|
||||
// Client 1 goes offline
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
// Client 0 renames A.md to B.md and syncs
|
||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
// Client 1 (offline) updates A.md
|
||||
{ type: "update", client: 1, path: "A.md", content: "updated by client 1" },
|
||||
|
||||
// Client 1 reconnects — must reconcile rename with update
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Verify convergence
|
||||
{ type: "assert-consistent", verify: verifyConvergence }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* BUG: recentlyDeletedIds cleared on sync reset can allow document resurrection.
|
||||
*
|
||||
* Found by: multi-client convergence agent (#10)
|
||||
*
|
||||
* When the VFS is reset (syncer.ts line 225-229, on WebSocket disconnect),
|
||||
* the recentlyDeletedIds set is NOT cleared by syncer.reset() (which only
|
||||
* calls queue.reset()). The VFS.reset() DOES clear it (line 646), but
|
||||
* syncer.reset() doesn't call vfs.reset().
|
||||
*
|
||||
* However, there's a related edge case: if sync is toggled off and on
|
||||
* (which calls pause/resume), the recentlyDeletedIds persists correctly.
|
||||
* But if the client deletes a document and then loses connection, the
|
||||
* lastSeenUpdateId watermark may not have advanced past the delete.
|
||||
* On reconnect, the server replays the delete broadcast, and the client
|
||||
* should handle it correctly.
|
||||
*
|
||||
* This test verifies that after Client 0 deletes a file and Client 1
|
||||
* toggles sync off and on, the delete is properly applied and no
|
||||
* resurrection occurs.
|
||||
*/
|
||||
function verifyNoFiles(state: ClientState): void {
|
||||
assert(
|
||||
state.files.size === 0,
|
||||
`Expected 0 files, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
export const resetClearsRecentlyDeletedResurrectionTest: TestDefinition = {
|
||||
name: "Sync Reset Does Not Resurrect Deleted Documents",
|
||||
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: [
|
||||
// Setup
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "ghost.md",
|
||||
content: "should be deleted"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Client 0 deletes the file
|
||||
{ type: "delete", client: 0, path: "ghost.md" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
// Wait for broadcast to propagate
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Client 1 should NOT have the file
|
||||
{ type: "assert-not-exists", client: 1, path: "ghost.md" },
|
||||
|
||||
// Client 1 toggles sync (simulating disconnect/reconnect)
|
||||
{ type: "disable-sync", client: 1 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// File should STILL be gone — no resurrection
|
||||
{ type: "assert-not-exists", client: 0, path: "ghost.md" },
|
||||
{ type: "assert-not-exists", client: 1, path: "ghost.md" },
|
||||
{ type: "assert-consistent", verify: verifyNoFiles }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyBothFilesPreserved(state: ClientState): void {
|
||||
assert(
|
||||
state.files.size === 2,
|
||||
`Expected 2 files, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.has("A.md"),
|
||||
`Expected A.md to exist, got: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.has("B.md"),
|
||||
`Expected B.md to exist, got: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
|
||||
const contentA = state.files.get("A.md") ?? "";
|
||||
const contentB = state.files.get("B.md") ?? "";
|
||||
assert(
|
||||
contentA === "identical content here",
|
||||
`A.md has wrong content: "${contentA}"`
|
||||
);
|
||||
assert(
|
||||
contentB === "identical content here",
|
||||
`B.md has wrong content: "${contentB}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const sequentialCreateDuplicateContentTest: TestDefinition = {
|
||||
name: "Sequential Creates With Identical Content Preserved",
|
||||
description:
|
||||
"Client 0 creates A.md and syncs it. Then Client 0 creates B.md with " +
|
||||
"the exact same content as A.md and syncs again. Both files must be " +
|
||||
"preserved as separate documents — the duplicate content detection " +
|
||||
"must not collapse them into one file or delete B.md.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Create A.md and sync it fully
|
||||
{ type: "create", client: 0, path: "A.md", content: "identical content here" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Verify A.md arrived on client 1
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "A.md",
|
||||
content: "identical content here"
|
||||
},
|
||||
|
||||
// Now create B.md with identical content on client 0
|
||||
{ type: "create", client: 0, path: "B.md", content: "identical content here" },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both files must exist on both clients with correct content.
|
||||
// This catches bugs where duplicate detection (content hash matching
|
||||
// during offline reconciliation) accidentally treats B.md as a
|
||||
// "move" of A.md, or where the server merges B.md into A.md's
|
||||
// document because of identical content at a different path.
|
||||
{ type: "assert-consistent", verify: verifyBothFilesPreserved }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyBothFiles(state: ClientState): void {
|
||||
assert(
|
||||
state.files.has("alpha.md"),
|
||||
`Expected alpha.md to exist, got: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.has("beta.md"),
|
||||
`Expected beta.md to exist, got: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
const alphaContent = state.files.get("alpha.md") ?? "";
|
||||
const betaContent = state.files.get("beta.md") ?? "";
|
||||
assert(
|
||||
alphaContent.includes("from client 0"),
|
||||
`Expected alpha.md to contain "from client 0", got: "${alphaContent}"`
|
||||
);
|
||||
assert(
|
||||
betaContent.includes("from client 1"),
|
||||
`Expected beta.md to contain "from client 1", got: "${betaContent}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const serverPauseBothClientsCreateTest: TestDefinition = {
|
||||
name: "Server Pause While Both Clients Create",
|
||||
description:
|
||||
"Both clients are synced. Client 0 creates alpha.md. The server is immediately " +
|
||||
"paused (SIGSTOP), stalling in-flight requests and WebSocket broadcasts. " +
|
||||
"While the server is paused, Client 1 creates beta.md (its request will also stall). " +
|
||||
"After the server resumes, both files should propagate to both clients. " +
|
||||
"This tests that the retry logic on both clients correctly recovers stalled " +
|
||||
"HTTP creates and that WebSocket reconnection delivers the missed broadcasts.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Client 0 creates a file, then immediately pause the server
|
||||
// so the create response (or broadcast to client 1) may be stalled
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "alpha.md",
|
||||
content: "from client 0"
|
||||
},
|
||||
{ type: "pause-server" },
|
||||
|
||||
// While server is paused, client 1 creates a different file.
|
||||
// This HTTP request will stall until the server is resumed.
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "beta.md",
|
||||
content: "from client 1"
|
||||
},
|
||||
|
||||
// Resume the server — both stalled requests should complete
|
||||
{ type: "resume-server" },
|
||||
|
||||
// Let both clients finish all pending sync work
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both files must exist on both clients
|
||||
{ type: "assert-exists", client: 0, path: "alpha.md" },
|
||||
{ type: "assert-exists", client: 0, path: "beta.md" },
|
||||
{ type: "assert-exists", client: 1, path: "alpha.md" },
|
||||
{ type: "assert-exists", client: 1, path: "beta.md" },
|
||||
{ type: "assert-consistent", verify: verifyBothFiles }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* EDGE CASE: Both clients edit the same file while server is paused.
|
||||
*
|
||||
* When the server is paused (SIGSTOP), both clients' HTTP requests stall.
|
||||
* When the server resumes, both updates arrive nearly simultaneously.
|
||||
* The server processes them sequentially (SQLite), so one will be a
|
||||
* FastForwardUpdate and the other will trigger a 3-way merge.
|
||||
*
|
||||
* This test verifies:
|
||||
* 1. Both edits are preserved in the merged result
|
||||
* 2. Both clients converge to the same content
|
||||
* 3. The content cache on both clients is correct after the merge
|
||||
* (subsequent edits use the right diff base)
|
||||
*
|
||||
* After the initial merge converges, Client 0 makes another edit to
|
||||
* verify the content cache is correct — if the cache has wrong content,
|
||||
* the diff will be computed incorrectly and the update will fail.
|
||||
*/
|
||||
function verifyBothConcurrentEdits(state: ClientState): void {
|
||||
assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`);
|
||||
assert(state.files.has("shared.md"), "Expected shared.md to exist");
|
||||
const content = state.files.get("shared.md") ?? "";
|
||||
assert(
|
||||
content.includes("edited by client 0"),
|
||||
`Expected content to include client 0's edit, got: "${content}"`
|
||||
);
|
||||
assert(
|
||||
content.includes("edited by client 1"),
|
||||
`Expected content to include client 1's edit, got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
function verifyPostMergeEdit(state: ClientState): void {
|
||||
assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`);
|
||||
assert(state.files.has("shared.md"), "Expected shared.md to exist");
|
||||
const content = state.files.get("shared.md") ?? "";
|
||||
assert(
|
||||
content.includes("post-merge edit from client 0"),
|
||||
`Expected content to include post-merge edit, got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const serverPauseBothEditSameFileTest: TestDefinition = {
|
||||
name: "Server Pause — Both Clients Edit Same File + Post-Merge Edit",
|
||||
description:
|
||||
"Both clients edit the same file while the server is paused. " +
|
||||
"After resume and convergence, Client 0 makes another edit to " +
|
||||
"verify the content cache is consistent (correct diff base).",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup
|
||||
{
|
||||
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: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Pause server
|
||||
{ type: "pause-server" },
|
||||
|
||||
// Both clients edit different sections
|
||||
{
|
||||
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"
|
||||
},
|
||||
|
||||
// Resume — both updates hit server nearly simultaneously
|
||||
{ type: "resume-server" },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Verify both concurrent edits are preserved in the merge
|
||||
{ type: "assert-consistent", verify: verifyBothConcurrentEdits },
|
||||
|
||||
// Now Client 0 makes another edit (verifies content cache is correct)
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "shared.md",
|
||||
content: "post-merge edit from client 0"
|
||||
},
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "assert-consistent", verify: verifyPostMergeEdit }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyBothFilesPresent(state: ClientState): void {
|
||||
const allContent = Array.from(state.files.values()).join("\n");
|
||||
assert(
|
||||
allContent.includes("offline-alpha"),
|
||||
`Missing content "offline-alpha". Files: ${JSON.stringify(Object.fromEntries(state.files))}`
|
||||
);
|
||||
assert(
|
||||
allContent.includes("offline-beta"),
|
||||
`Missing content "offline-beta". Files: ${JSON.stringify(Object.fromEntries(state.files))}`
|
||||
);
|
||||
}
|
||||
|
||||
export const serverPauseConcurrentCreatesTest: TestDefinition = {
|
||||
name: "Server Pause — Concurrent Creates From Both Clients",
|
||||
description:
|
||||
"The server is paused BEFORE either client creates anything. " +
|
||||
"Client 0 creates fileA.md and Client 1 creates fileB.md — both HTTP " +
|
||||
"requests stall because the server is frozen. After the server resumes, " +
|
||||
"both creates should complete and both files should appear on both clients. " +
|
||||
"This is a harder variant than the existing create-while-server-paused test " +
|
||||
"because BOTH clients have stalled pending creates simultaneously, testing " +
|
||||
"that the server correctly handles a burst of requests after SIGCONT and " +
|
||||
"that idempotency keys prevent duplicate documents if retries occur.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Pause the server FIRST — no requests can succeed
|
||||
{ type: "pause-server" },
|
||||
|
||||
// Both clients create different files while the server is frozen
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "fileA.md",
|
||||
content: "offline-alpha"
|
||||
},
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "fileB.md",
|
||||
content: "offline-beta"
|
||||
},
|
||||
|
||||
// Resume the server — both pending creates should complete
|
||||
{ type: "resume-server" },
|
||||
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both files must exist on both clients
|
||||
{ type: "assert-exists", client: 0, path: "fileA.md" },
|
||||
{ type: "assert-exists", client: 0, path: "fileB.md" },
|
||||
{ type: "assert-exists", client: 1, path: "fileA.md" },
|
||||
{ type: "assert-exists", client: 1, path: "fileB.md" },
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 0,
|
||||
path: "fileA.md",
|
||||
content: "offline-alpha"
|
||||
},
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "fileA.md",
|
||||
content: "offline-alpha"
|
||||
},
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 0,
|
||||
path: "fileB.md",
|
||||
content: "offline-beta"
|
||||
},
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "fileB.md",
|
||||
content: "offline-beta"
|
||||
},
|
||||
{ type: "assert-consistent", verify: verifyBothFilesPresent }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyRenamedAndEdited(state: ClientState): void {
|
||||
const files = Array.from(state.files.keys());
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected 1 file, got ${state.files.size}: ${files.join(", ")}`
|
||||
);
|
||||
assert(
|
||||
!state.files.has("A.md"),
|
||||
`A.md should not exist after rename`
|
||||
);
|
||||
assert(
|
||||
state.files.has("B.md"),
|
||||
`Expected B.md to exist, got: ${files.join(", ")}`
|
||||
);
|
||||
const content = state.files.get("B.md") ?? "";
|
||||
assert(
|
||||
content === "edited after rename during pause",
|
||||
`Expected B.md content to be "edited after rename during pause", got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that a rename + edit while the server is paused both propagate
|
||||
* correctly after resume. The event coalescing should produce a
|
||||
* move-and-update action. When the server resumes and processes the
|
||||
* stalled request, both the path change and content change should
|
||||
* apply atomically.
|
||||
*
|
||||
* This exercises the coalescing path: move + update = move-and-update.
|
||||
*/
|
||||
export const serverPauseRenameEditResumeTest: TestDefinition = {
|
||||
name: "Server Pause: Rename + Edit Then Resume",
|
||||
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: [
|
||||
// Setup: create and sync
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "original content"
|
||||
},
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "A.md",
|
||||
content: "original content"
|
||||
},
|
||||
|
||||
// Pause server
|
||||
{ type: "pause-server" },
|
||||
|
||||
// Rename and edit while server is paused
|
||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "B.md",
|
||||
content: "edited after rename during pause"
|
||||
},
|
||||
|
||||
// Resume server
|
||||
{ type: "resume-server" },
|
||||
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both clients should have B.md with edited content
|
||||
{ type: "assert-not-exists", client: 0, path: "A.md" },
|
||||
{ type: "assert-not-exists", client: 1, path: "A.md" },
|
||||
{ type: "assert-consistent", verify: verifyRenamedAndEdited }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyRename(state: ClientState): void {
|
||||
const files = Array.from(state.files.keys());
|
||||
assert(
|
||||
!state.files.has("original.md"),
|
||||
`Expected original.md to NOT exist after rename, got files: ${files.join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.has("renamed.md"),
|
||||
`Expected renamed.md to exist after rename, got files: ${files.join(", ")}`
|
||||
);
|
||||
const content = state.files.get("renamed.md") ?? "";
|
||||
assert(
|
||||
content === "important data",
|
||||
`Expected renamed.md content to be "important data", got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const serverPauseRenameTest: TestDefinition = {
|
||||
name: "Server Pause Then Rename Propagation",
|
||||
description:
|
||||
"Client 0 creates original.md and both clients sync. The server is paused. " +
|
||||
"Client 0 renames original.md to renamed.md while the server is frozen. " +
|
||||
"After the server resumes, the rename should propagate to Client 1: " +
|
||||
"original.md disappears and renamed.md appears with the same content. " +
|
||||
"This tests that rename operations (which are update-with-oldPath on the " +
|
||||
"HTTP layer) survive server outages and that Client 1 correctly applies " +
|
||||
"the path change from the WebSocket broadcast.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: create file and sync both clients
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "original.md",
|
||||
content: "important data"
|
||||
},
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "original.md",
|
||||
content: "important data"
|
||||
},
|
||||
|
||||
// Pause the server, then rename on client 0
|
||||
{ type: "pause-server" },
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "original.md",
|
||||
newPath: "renamed.md"
|
||||
},
|
||||
|
||||
// Resume the server — the stalled rename request should complete
|
||||
{ type: "resume-server" },
|
||||
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// original.md should be gone, renamed.md should exist on both
|
||||
{ type: "assert-not-exists", client: 0, path: "original.md" },
|
||||
{ type: "assert-not-exists", client: 1, path: "original.md" },
|
||||
{ type: "assert-exists", client: 0, path: "renamed.md" },
|
||||
{ type: "assert-exists", client: 1, path: "renamed.md" },
|
||||
{ type: "assert-consistent", verify: verifyRename }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const serverPauseResumeTest: TestDefinition = {
|
||||
name: "Server Pause and Resume",
|
||||
description:
|
||||
"Client 0 creates a file and syncs it to the server. The server is then " +
|
||||
"paused (SIGSTOP), which may stall WebSocket broadcasts to Client 1. " +
|
||||
"After the server resumes, both clients should converge.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
|
||||
// Create a file, then immediately pause the server
|
||||
{ type: "create", client: 0, path: "resilient.md", content: "survives pause" },
|
||||
{ type: "pause-server" },
|
||||
{ type: "resume-server" },
|
||||
|
||||
// After resume, sync should eventually succeed
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "assert-exists", client: 0, path: "resilient.md" },
|
||||
{ type: "assert-exists", client: 1, path: "resilient.md" },
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 0,
|
||||
path: "resilient.md",
|
||||
content: "survives pause"
|
||||
},
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "resilient.md",
|
||||
content: "survives pause"
|
||||
},
|
||||
{ type: "assert-consistent" }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyFinalState(state: ClientState): void {
|
||||
// The updated file must exist with the new content
|
||||
assert(
|
||||
state.files.has("shared.md"),
|
||||
`Expected shared.md to exist, got: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
const sharedContent = state.files.get("shared.md") ?? "";
|
||||
assert(
|
||||
sharedContent === "updated during pause",
|
||||
`Expected shared.md to be "updated during pause", got: "${sharedContent}"`
|
||||
);
|
||||
|
||||
// The new file created by client 1 during the pause must also exist
|
||||
assert(
|
||||
state.files.has("new-file.md"),
|
||||
`Expected new-file.md to exist, got: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
const newContent = state.files.get("new-file.md") ?? "";
|
||||
assert(
|
||||
newContent === "created by client 1",
|
||||
`Expected new-file.md to be "created by client 1", got: "${newContent}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const serverPauseUpdateAndCreateTest: TestDefinition = {
|
||||
name: "Server Pause — Update and Create Simultaneously",
|
||||
description:
|
||||
"Client 0 creates shared.md and both clients sync. The server is paused. " +
|
||||
"Client 0 updates shared.md to new content. Client 1 creates an entirely " +
|
||||
"new file new-file.md. Both HTTP requests stall. After the server resumes, " +
|
||||
"the update and the create should both complete. Client 1 should see the " +
|
||||
"updated content in shared.md, and Client 0 should see new-file.md. " +
|
||||
"This tests that mixed operation types (update + create) from different " +
|
||||
"clients both survive a server outage and that the WebSocket reconnection " +
|
||||
"delivers all missed broadcasts.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: create shared.md and sync
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "shared.md",
|
||||
content: "initial content"
|
||||
},
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-content",
|
||||
client: 1,
|
||||
path: "shared.md",
|
||||
content: "initial content"
|
||||
},
|
||||
|
||||
// Pause the server
|
||||
{ type: "pause-server" },
|
||||
|
||||
// Client 0 updates the existing file (stalls)
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "shared.md",
|
||||
content: "updated during pause"
|
||||
},
|
||||
// Client 1 creates a brand-new file (stalls)
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "new-file.md",
|
||||
content: "created by client 1"
|
||||
},
|
||||
|
||||
// Resume server — both operations should complete
|
||||
{ type: "resume-server" },
|
||||
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Verify final state
|
||||
{ type: "assert-exists", client: 0, path: "shared.md" },
|
||||
{ type: "assert-exists", client: 0, path: "new-file.md" },
|
||||
{ type: "assert-exists", client: 1, path: "shared.md" },
|
||||
{ type: "assert-exists", client: 1, path: "new-file.md" },
|
||||
{ type: "assert-consistent", verify: verifyFinalState }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyConflictResolution(state: ClientState): void {
|
||||
// The delete and offline update conflict on the same document.
|
||||
// Either outcome is acceptable — the key invariant is convergence
|
||||
// (checked by assert-consistent). But we verify content correctness
|
||||
// for whichever outcome the system chose.
|
||||
if (state.files.has("A.md")) {
|
||||
// Update won: A.md should have the offline-modified content
|
||||
assert(
|
||||
state.files.get("A.md") === "modified by 1 while offline",
|
||||
`If A.md survived, it should have "modified by 1 while offline", got: "${state.files.get("A.md")}"`
|
||||
);
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected exactly 1 file if update won, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
} else {
|
||||
// Delete won: no files should exist
|
||||
assert(
|
||||
state.files.size === 0,
|
||||
`Expected 0 files if delete won, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const simultaneousCreateDeleteSamePathTest: TestDefinition = {
|
||||
name: "Simultaneous Create and Delete at Same Path",
|
||||
description:
|
||||
"Client 0 creates A.md and syncs to both clients. Client 0 deletes A.md while " +
|
||||
"Client 1 (offline) updates A.md with different content. When Client 1 reconnects, " +
|
||||
"the update and delete must be reconciled. Both clients must converge.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Setup: Client 0 creates and syncs A.md
|
||||
{ type: "create", client: 0, path: "A.md", content: "original from 0" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Client 1 goes offline
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
// Client 0 deletes A.md
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
// Client 1 updates A.md while offline (it still has it)
|
||||
{ type: "update", client: 1, path: "A.md", content: "modified by 1 while offline" },
|
||||
|
||||
// Client 1 reconnects
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Both must agree — key invariant is convergence
|
||||
{ type: "assert-consistent", verify: verifyConflictResolution }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
/**
|
||||
* BUG: Stale doc kept on disk creates duplicate content after create-merge.
|
||||
*
|
||||
* Found by: E2E test log analysis (log.log, process 672773)
|
||||
*
|
||||
* Root cause sequence:
|
||||
* 1. Client 1 has document D1 tracked at path "target.md"
|
||||
* 2. Client 0 renames D1 to "moved.md" on the server
|
||||
* 3. Client 1 (offline) creates a new file at "moved.md"
|
||||
* 4. Client 1 reconnects — the create is sent to the server
|
||||
* 5. Server merges the create with D1 (at "moved.md") → MergingUpdate with D1
|
||||
* 6. ensureUniqueDocumentId finds D1 at "target.md" → stale doc
|
||||
* 7. "target.md" was locally modified during the create's HTTP request
|
||||
* → hasLocalChanges = true → file kept on disk, VFS record removed
|
||||
* 8. On the next reconciliation, orphaned "target.md" is re-synced
|
||||
* as a new document. Now BOTH "target.md" and "moved.md" contain
|
||||
* the original content from D1 — violating the content-uniqueness
|
||||
* invariant.
|
||||
*
|
||||
* The server pause is used to keep the create HTTP request in-flight
|
||||
* while the local file at D1's old path is modified (step 7).
|
||||
*/
|
||||
function verifyNoDuplicateContent(state: ClientState): void {
|
||||
const entries = [...state.files.entries()];
|
||||
|
||||
// The word "original" was D1's initial content. After the create-merge,
|
||||
// it should appear in at most ONE file. If the stale orphan was re-synced
|
||||
// as a separate document, "original" will appear in multiple files.
|
||||
const filesContainingOriginal = entries.filter(([, content]) =>
|
||||
content.includes("original")
|
||||
);
|
||||
|
||||
assert(
|
||||
filesContainingOriginal.length <= 1,
|
||||
`Content "original" found in ${filesContainingOriginal.length} files: ` +
|
||||
`${filesContainingOriginal.map(([p]) => p).join(", ")}. ` +
|
||||
`This means the stale doc orphan was re-synced, creating duplicate content.\n` +
|
||||
`Files:\n${entries.map(([k, v]) => ` ${k}: "${v}"`).join("\n")}`
|
||||
);
|
||||
}
|
||||
|
||||
export const staleDocOrphanDuplicateContentTest: TestDefinition = {
|
||||
name: "Stale Doc Orphan Creates Duplicate Content After Create-Merge",
|
||||
description:
|
||||
"When a create merges with an existing document, the stale VFS " +
|
||||
"record is removed but the file is kept on disk (local changes). " +
|
||||
"If the orphaned file is later re-synced as a new document, the " +
|
||||
"original content appears in multiple files.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// ── Setup: both clients share D1 at "target.md" ──
|
||||
{ type: "create", client: 0, path: "target.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// ── Client 1 goes offline ──
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
// ── Client 0 renames the document to a new path ──
|
||||
// Server now has D1 at "moved.md"
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "target.md",
|
||||
newPath: "moved.md"
|
||||
},
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
// ── Client 1 (offline) creates a file at D1's new server path ──
|
||||
// Client 1 doesn't know D1 was renamed there.
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "moved.md",
|
||||
content: "unrelated-content"
|
||||
},
|
||||
|
||||
// ── Pause server to stall the create HTTP request ──
|
||||
{ type: "pause-server" },
|
||||
|
||||
// ── Enable sync on client 1 ──
|
||||
// scheduleSyncForOfflineChanges runs:
|
||||
// "target.md": D1, hash matches → no update
|
||||
// "moved.md": no metadata → create scheduled
|
||||
// The create HTTP request stalls (server frozen).
|
||||
// enableSync waits up to 10 s for WebSocket then returns.
|
||||
{ type: "enable-sync", client: 1 },
|
||||
|
||||
// ── Modify D1's old path while the create is in-flight ──
|
||||
// This makes hasLocalChanges = true when ensureUniqueDocumentId
|
||||
// checks the stale doc at "target.md".
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "target.md",
|
||||
content: "original extra-edit"
|
||||
},
|
||||
|
||||
// ── Resume server ──
|
||||
// Create completes: server merges with D1 → MergingUpdate
|
||||
// ensureUniqueDocumentId: D1 at "target.md" → stale doc
|
||||
// hasLocalChanges("target.md"): "original extra-edit" ≠ "original" → true
|
||||
// File kept, VFS record removed.
|
||||
//
|
||||
// WebSocket connects → second reconciliation detects orphaned
|
||||
// "target.md" → re-synced as new document → DUPLICATE CONTENT.
|
||||
{ type: "resume-server" },
|
||||
|
||||
// ── Settle ──
|
||||
{ type: "sync" },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// ── Verify: "original" must not appear in multiple files ──
|
||||
{ type: "assert-consistent", verify: verifyNoDuplicateContent }
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import type { ClientState, TestDefinition } from "../test-definition";
|
||||
import { assert } from "../utils/assert";
|
||||
|
||||
function verifyAllContent(state: ClientState): void {
|
||||
// All three creates at the same path should merge into a single file
|
||||
assert(
|
||||
state.files.size === 1,
|
||||
`Expected 1 file after 3-way merge, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
assert(
|
||||
state.files.has("A.md"),
|
||||
`Expected merged file at A.md, got: ${Array.from(state.files.keys()).join(", ")}`
|
||||
);
|
||||
|
||||
const content = state.files.get("A.md") ?? "";
|
||||
assert(
|
||||
content.includes("from-zero"),
|
||||
`Expected merged content to include "from-zero", got: "${content}"`
|
||||
);
|
||||
assert(
|
||||
content.includes("from-one"),
|
||||
`Expected merged content to include "from-one", got: "${content}"`
|
||||
);
|
||||
assert(
|
||||
content.includes("from-two"),
|
||||
`Expected merged content to include "from-two", got: "${content}"`
|
||||
);
|
||||
}
|
||||
|
||||
export const threeClientConvergenceTest: TestDefinition = {
|
||||
name: "Three Client Convergence",
|
||||
description:
|
||||
"Three clients all create the same file offline with different content. " +
|
||||
"When all three enable sync, the server must merge all three versions " +
|
||||
"and all clients must converge to the same state with all content preserved.",
|
||||
clients: 3,
|
||||
steps: [
|
||||
// All three create A.md offline with different content
|
||||
{ type: "create", client: 0, path: "A.md", content: "from-zero" },
|
||||
{ type: "create", client: 1, path: "A.md", content: "from-one" },
|
||||
{ type: "create", client: 2, path: "A.md", content: "from-two" },
|
||||
|
||||
// Enable sync on all three
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "enable-sync", client: 2 },
|
||||
{ type: "sync" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// All three must converge and all content must be preserved
|
||||
{ type: "assert-consistent", verify: verifyAllContent }
|
||||
]
|
||||
};
|
||||
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