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 }
|
||||
]
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue