Clean up deterministic tests

This commit is contained in:
Andras Schmelczer 2026-03-28 11:12:43 +00:00
parent 7b9287ca52
commit f36a84b275
113 changed files with 1366 additions and 3835 deletions

View file

@ -6,7 +6,7 @@ Complements the fuzz-based E2E tests (`test-client`): fuzz tests discover bugs t
## How it works ## How it works
Each test is a `TestDefinition`: a name, a client count, and an ordered list of steps. The `TestRunner` spins up N `DeterministicAgent` instances (each wrapping a real `SyncClient` with an `InMemoryFileSystem`) pointed at a shared vault on the server, then executes steps one by one. Each test is a `TestDefinition`: a client count and an ordered list of steps. The test name is derived from the registry key (which matches the file name). The `TestRunner` spins up N `DeterministicAgent` instances (each wrapping a real `SyncClient` with an `InMemoryFileSystem`) pointed at a shared vault on the server, then executes steps one by one.
Tests that don't pause the server share a single server process (vault-name isolation). Tests that use `pause-server`/`resume-server` (SIGSTOP/SIGCONT) each get a dedicated server, since SIGSTOP freezes the entire process. Tests that don't pause the server share a single server process (vault-name isolation). Tests that use `pause-server`/`resume-server` (SIGSTOP/SIGCONT) each get a dedicated server, since SIGSTOP freezes the entire process.
@ -14,7 +14,7 @@ All tests run in parallel up to a concurrency limit.
## Step types ## Step types
Clients always start with syincing being disabled. Clients always start with syncing disabled.
**File operations** (per-client, fire-and-forget — sync is enqueued but not awaited): **File operations** (per-client, fire-and-forget — sync is enqueued but not awaited):
- `create`, `update`, `rename`, `delete` - `create`, `update`, `rename`, `delete`
@ -26,11 +26,9 @@ Clients always start with syincing being disabled.
**Server control:** **Server control:**
- `pause-server` / `resume-server` — SIGSTOP/SIGCONT the server process - `pause-server` / `resume-server` — SIGSTOP/SIGCONT the server process
- `wait` — sleep for N milliseconds
**Assertions:** **Assertions:**
- `assert-content`, `assert-exists`, `assert-not-exists` - `assert-consistent` — all clients have identical files; optionally takes a custom `verify(state: AssertableState)` callback
- `assert-consistent` — all clients have identical files; optionally takes a custom verify function
## Running ## Running
@ -56,18 +54,31 @@ npm run test -w deterministic-tests -- -j 4
import type { TestDefinition } from "../test-definition"; import type { TestDefinition } from "../test-definition";
export const myScenarioTest: TestDefinition = { export const myScenarioTest: TestDefinition = {
name: "My Scenario", description: "Client 0 creates A.md offline. After syncing, both clients should have the file.",
description: "What this test verifies",
clients: 2, clients: 2,
steps: [ steps: [
{ type: "create", client: 0, path: "A.md", content: "hello" }, { type: "create", client: 0, path: "A.md", content: "hello" },
{ type: "sync" }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-consistent" } { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("A.md", "hello") }
] ]
}; };
``` ```
The `verify` callback receives an `AssertableState` object with chainable assertion methods:
```typescript
s.assertFileCount(n) // exact file count
s.assertFileExists("path") // file must exist
s.assertFileNotExists("path") // file must not exist
s.assertContent("path", "expected") // exact content match
s.assertContains("path", "a", "b") // all substrings present
s.assertAnyFileContains("text") // substring in any file
s.assertContentInAtMostOneFile("text") // no duplicate content
s.ifFileExists("path", (s) => ...) // conditional assertion
```
2. Register it in `src/test-registry.ts`: 2. Register it in `src/test-registry.ts`:
```typescript ```typescript
@ -78,4 +89,3 @@ const TESTS = {
"my-scenario": myScenarioTest "my-scenario": myScenarioTest
}; };
``` ```

View file

@ -34,7 +34,7 @@ function testUsesPauseServer(test: TestDefinition): boolean {
} }
interface NamedTestResult { interface NamedTestResult {
test: TestDefinition; name: string;
result: TestResult; result: TestResult;
} }
@ -64,13 +64,13 @@ async function main(): Promise<void> {
const filterArg = process.argv.find((a) => a.startsWith("--filter=")); const filterArg = process.argv.find((a) => a.startsWith("--filter="));
const filter = filterArg?.slice("--filter=".length); const filter = filterArg?.slice("--filter=".length);
const testsToRun: TestDefinition[] = []; const testsToRun: [string, TestDefinition][] = [];
for (const [key, test] of Object.entries(TESTS)) { for (const [key, test] of Object.entries(TESTS)) {
if (test) { if (test) {
if (filter && !key.includes(filter) && !test.name.toLowerCase().includes(filter.toLowerCase())) { if (filter && !key.includes(filter)) {
continue; continue;
} }
testsToRun.push(test); testsToRun.push([key, test]);
} }
} }
@ -84,8 +84,10 @@ async function main(): Promise<void> {
} }
const concurrency = parseConcurrency(); const concurrency = parseConcurrency();
const regularTests = testsToRun.filter((t) => !testUsesPauseServer(t)); const regularTests = testsToRun.filter(
const pauseTests = testsToRun.filter((t) => testUsesPauseServer(t)); ([, t]) => !testUsesPauseServer(t)
);
const pauseTests = testsToRun.filter(([, t]) => testUsesPauseServer(t));
logger.info(`Server: ${serverPath}`); logger.info(`Server: ${serverPath}`);
logger.info(`Config: ${configPath}`); logger.info(`Config: ${configPath}`);
@ -113,7 +115,8 @@ async function main(): Promise<void> {
const results = await runWithConcurrency( const results = await runWithConcurrency(
regularTests, regularTests,
concurrency, concurrency,
async (test) => runSharedServerTest(test, sharedServer) async ([name, test]) =>
runSharedServerTest(name, test, sharedServer)
); );
allResults.push(...results); allResults.push(...results);
@ -137,7 +140,8 @@ async function main(): Promise<void> {
const results = await runWithConcurrency( const results = await runWithConcurrency(
pauseTests, pauseTests,
concurrency, concurrency,
async (test) => runDedicatedServerTest(test, serverPath, configPath) async ([name, test]) =>
runDedicatedServerTest(name, test, serverPath, configPath)
); );
allResults.push(...results); allResults.push(...results);
@ -149,8 +153,8 @@ async function main(): Promise<void> {
logger.info(`\n--- Results: ${passed.length}/${allResults.length} passed ---`); logger.info(`\n--- Results: ${passed.length}/${allResults.length} passed ---`);
if (failed.length > 0) { if (failed.length > 0) {
for (const { test, result } of failed) { for (const { name, result } of failed) {
logger.error(` FAILED: ${test.name}: ${result.error}`); logger.error(` FAILED: ${name}: ${result.error}`);
} }
process.exit(1); process.exit(1);
} else { } else {
@ -165,27 +169,25 @@ main().catch((err: unknown) => {
}); });
/**
* Run a test on a shared server (for tests that don't use pause-server).
*/
async function runSharedServerTest( async function runSharedServerTest(
name: string,
test: TestDefinition, test: TestDefinition,
sharedServer: ServerControl sharedServer: ServerControl
): Promise<NamedTestResult> { ): Promise<NamedTestResult> {
const testLogger = new PrefixedLogger(logger, test.name); const testLogger = new PrefixedLogger(logger, name);
const runner = new TestRunner( const runner = new TestRunner(
sharedServer, sharedServer,
testLogger, testLogger,
TOKEN, TOKEN,
sharedServer.remoteUri sharedServer.remoteUri
); );
const result = await runner.runTest(test); const result = await runner.runTest(name, test);
if (result.success) { if (result.success) {
logger.info(`PASSED: ${test.name} (${result.duration}ms)`); logger.info(`PASSED: ${name} (${result.duration}ms)`);
} else { } else {
logger.error(`FAILED: ${test.name} - ${result.error}`); logger.error(`FAILED: ${name} - ${result.error}`);
} }
return { test, result }; return { name, result };
} }
/** /**
@ -194,11 +196,12 @@ async function runSharedServerTest(
* isolated servers to avoid interfering with other tests. * isolated servers to avoid interfering with other tests.
*/ */
async function runDedicatedServerTest( async function runDedicatedServerTest(
name: string,
test: TestDefinition, test: TestDefinition,
serverPath: string, serverPath: string,
configPath: string configPath: string
): Promise<NamedTestResult> { ): Promise<NamedTestResult> {
const testLogger = new PrefixedLogger(logger, test.name); const testLogger = new PrefixedLogger(logger, name);
const server = new ServerControl(serverPath, configPath, testLogger); const server = new ServerControl(serverPath, configPath, testLogger);
serverManager.track(server); serverManager.track(server);
@ -210,13 +213,13 @@ async function runDedicatedServerTest(
TOKEN, TOKEN,
server.remoteUri server.remoteUri
); );
const result = await runner.runTest(test); const result = await runner.runTest(name, test);
if (result.success) { if (result.success) {
logger.info(`PASSED: ${test.name} (${result.duration}ms)`); logger.info(`PASSED: ${name} (${result.duration}ms)`);
} else { } else {
logger.error(`FAILED: ${test.name} - ${result.error}`); logger.error(`FAILED: ${name} - ${result.error}`);
} }
return { test, result }; return { name, result };
} finally { } finally {
try { try {
await server.stop(); await server.stop();

View file

@ -1,4 +1,4 @@
import type { StoredDatabase, SyncSettings, RelativePath } from "sync-client"; import type { StoredDatabase, SyncSettings, RelativePath, TextWithCursors } from "sync-client";
import { SyncClient, debugging, LogLevel } from "sync-client"; import { SyncClient, debugging, LogLevel } from "sync-client";
import { assert } from "./utils/assert"; import { assert } from "./utils/assert";
import { sleep } from "./utils/sleep"; import { sleep } from "./utils/sleep";
@ -16,6 +16,8 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
database: Partial<StoredDatabase>; database: Partial<StoredDatabase>;
}> = {}; }> = {};
private isSyncEnabled = IS_SYNC_ENABLED_DEFAULT; private isSyncEnabled = IS_SYNC_ENABLED_DEFAULT;
private readonly syncErrors: Error[] = [];
private readonly pendingSyncOperations = new Set<Promise<void>>();
public constructor( public constructor(
clientId: number, clientId: number,
@ -81,9 +83,11 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
const contentBytes = new TextEncoder().encode(content); const contentBytes = new TextEncoder().encode(content);
this.files.set(path, contentBytes); this.files.set(path, contentBytes);
this.enqueueSync(async () => if (this.isSyncEnabled) {
this.client.syncLocallyCreatedFile(path) this.enqueueSync(async () =>
); this.client.syncLocallyCreatedFile(path)
);
}
} }
public async updateFile(path: string, content: string): Promise<void> { public async updateFile(path: string, content: string): Promise<void> {
@ -96,9 +100,11 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
const contentBytes = new TextEncoder().encode(content); const contentBytes = new TextEncoder().encode(content);
this.files.set(path, contentBytes); this.files.set(path, contentBytes);
this.enqueueSync(async () => if (this.isSyncEnabled) {
this.client.syncLocallyUpdatedFile({ relativePath: path }) this.enqueueSync(async () =>
); this.client.syncLocallyUpdatedFile({ relativePath: path })
);
}
} }
public async renameFile(oldPath: string, newPath: string): Promise<void> { public async renameFile(oldPath: string, newPath: string): Promise<void> {
@ -109,11 +115,6 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
`File ${oldPath} does not exist on client ${this.clientId}` `File ${oldPath} does not exist on client ${this.clientId}`
); );
} }
if (oldPath !== newPath && this.files.has(newPath)) {
this.log(
`Target path ${newPath} already exists, will be overwritten (ensureClearPath)`
);
}
this.files.set(newPath, file); this.files.set(newPath, file);
if (oldPath !== newPath) { if (oldPath !== newPath) {
this.files.delete(oldPath); this.files.delete(oldPath);
@ -140,18 +141,47 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
public async waitForSync(): Promise<void> { public async waitForSync(): Promise<void> {
this.log("Waiting for sync to complete..."); this.log("Waiting for sync to complete...");
// Drain agent-level sync operations first. These are the fire-and-forget
// promises from enqueueSync() that call into the SyncClient's methods.
// Without this, waitUntilFinished() might return before the SyncClient
// has even been told about the operation.
await this.drainPendingSyncOperations();
await withTimeout( await withTimeout(
this.client.waitUntilFinished(), this.client.waitUntilFinished(),
WAIT_TIMEOUT_MS, WAIT_TIMEOUT_MS,
`Client ${this.clientId} waitForSync timed out after ${WAIT_TIMEOUT_MS}ms` `Client ${this.clientId} waitForSync timed out after ${WAIT_TIMEOUT_MS}ms`
); );
if (this.syncErrors.length > 0) {
const errors = this.syncErrors.splice(0);
throw new Error(
`Client ${this.clientId} had ${errors.length} sync error(s):\n${errors.map((e) => e.message).join("\n")}`
);
}
this.log("Sync complete"); this.log("Sync complete");
} }
public async disableSync(): Promise<void> { public async disableSync(): Promise<void> {
this.log("Disabling sync"); this.log("Disabling sync");
// Drain pending enqueued operations before disabling so the SyncClient
// knows about all operations that were enqueued while sync was enabled.
await this.drainPendingSyncOperations();
await this.client.setSetting("isSyncEnabled", false); await this.client.setSetting("isSyncEnabled", false);
this.isSyncEnabled = false; this.isSyncEnabled = false;
// Wait for in-flight operations to drain. Disabling sync triggers
// a reset, which aborts in-flight fetches with SyncResetError.
try {
await withTimeout(
this.client.waitUntilFinished(),
WAIT_TIMEOUT_MS,
`Client ${this.clientId} disableSync drain timed out`
);
} catch (error) {
if (error instanceof Error && error.name === "SyncResetError") {
this.log("Disable sync drain interrupted by reset (expected)");
} else {
throw error;
}
}
} }
public async enableSync(): Promise<void> { public async enableSync(): Promise<void> {
@ -161,44 +191,6 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
await this.waitForWebSocket(); await this.waitForWebSocket();
} }
public async assertContent(
path: string,
expectedContent: string
): Promise<void> {
this.log(`Asserting content of ${path} equals "${expectedContent}"`);
const actualBytes = await this.read(path).catch(() => {
throw new Error(
`File ${path} does not exist on client ${this.clientId}`
);
});
const actualContent = new TextDecoder().decode(actualBytes);
assert(
actualContent === expectedContent,
`Content mismatch on client ${this.clientId} for ${path}:\nExpected: "${expectedContent}"\nActual: "${actualContent}"`
);
this.log(`✓ Content assertion passed for ${path}`);
}
public async assertExists(path: string): Promise<void> {
this.log(`Asserting ${path} exists`);
const exists = await this.exists(path);
assert(
exists,
`File ${path} does not exist on client ${this.clientId}`
);
this.log(`✓ File ${path} exists`);
}
public async assertNotExists(path: string): Promise<void> {
this.log(`Asserting ${path} does not exist`);
const exists = await this.exists(path);
assert(
!exists,
`File ${path} exists on client ${this.clientId} but should not`
);
this.log(`✓ File ${path} does not exist`);
}
public async getFiles(): Promise<RelativePath[]> { public async getFiles(): Promise<RelativePath[]> {
return this.listFilesRecursively(); return this.listFilesRecursively();
} }
@ -217,6 +209,7 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
return; return;
} }
try { try {
await this.drainPendingSyncOperations();
await withTimeout( await withTimeout(
this.client.waitUntilFinished(), this.client.waitUntilFinished(),
WAIT_TIMEOUT_MS, WAIT_TIMEOUT_MS,
@ -233,6 +226,49 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
this.log("Cleanup complete"); this.log("Cleanup complete");
} }
// Yield the event loop before each FS operation so that the SyncClient's
// async calls create real interleaving points, matching the behavior of
// actual disk I/O. Without this, all FS operations resolve in the same
// microtask, hiding concurrency bugs that only manifest with real latency.
public override async read(path: RelativePath): Promise<Uint8Array> {
await Promise.resolve();
return super.read(path);
}
public override async write(
path: RelativePath,
content: Uint8Array
): Promise<void> {
await Promise.resolve();
return super.write(path, content);
}
public override async atomicUpdateText(
path: RelativePath,
updater: (current: TextWithCursors) => TextWithCursors
): Promise<string> {
await Promise.resolve();
return super.atomicUpdateText(path, updater);
}
public override async exists(path: RelativePath): Promise<boolean> {
await Promise.resolve();
return super.exists(path);
}
public override async delete(path: RelativePath): Promise<void> {
await Promise.resolve();
return super.delete(path);
}
public override async rename(
oldPath: RelativePath,
newPath: RelativePath
): Promise<void> {
await Promise.resolve();
return super.rename(oldPath, newPath);
}
private async waitForWebSocket(): Promise<void> { private async waitForWebSocket(): Promise<void> {
const deadline = Date.now() + WEBSOCKET_CONNECT_TIMEOUT_MS; const deadline = Date.now() + WEBSOCKET_CONNECT_TIMEOUT_MS;
while (!this.client.isWebSocketConnected && Date.now() < deadline) { while (!this.client.isWebSocketConnected && Date.now() < deadline) {
@ -244,11 +280,28 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
); );
} }
/**
* Wait until all agent-level enqueued sync operations have completed.
* Uses a loop because completing one operation can trigger new enqueues.
*/
private async drainPendingSyncOperations(): Promise<void> {
while (this.pendingSyncOperations.size > 0) {
await Promise.all(this.pendingSyncOperations);
}
}
private enqueueSync(operation: () => Promise<void>): void { private enqueueSync(operation: () => Promise<void>): void {
void this.executeSyncOperation(operation).catch((error) => { const promise = this.executeSyncOperation(operation).catch(
this.log( (error: unknown) => {
`Background sync failed (will retry on reconnect): ${error}` const err =
); error instanceof Error ? error : new Error(String(error));
this.log(`Background sync failed: ${err.message}`);
this.syncErrors.push(err);
}
);
this.pendingSyncOperations.add(promise);
void promise.finally(() => {
this.pendingSyncOperations.delete(promise);
}); });
} }

View file

@ -104,7 +104,7 @@ export class ServerControl {
public async waitForReady(maxAttempts = 50): Promise<void> { public async waitForReady(maxAttempts = 50): Promise<void> {
const pingUrl = `${this.remoteUri}/vaults/test/ping`; const pingUrl = `${this.remoteUri}/vaults/test/ping`;
for (let i = 0; i < maxAttempts; i++) { for (let i = 0; i < maxAttempts; i++) {
if (this.process === null || this.process.exitCode !== null) { if (this.process?.exitCode !== null) {
throw new Error( throw new Error(
"Server process died while waiting for it to become ready" "Server process died while waiting for it to become ready"
); );

View file

@ -1,4 +1,4 @@
import { ServerControl } from "./server-control"; import type { ServerControl } from "./server-control";
import type { Logger } from "sync-client"; import type { Logger } from "sync-client";
export class ServerManager { export class ServerManager {

View file

@ -1,5 +1,8 @@
import type { AssertableState } from "./utils/assertable-state";
export interface ClientState { export interface ClientState {
files: Map<string, string>; files: Map<string, string>;
clientFiles: Map<string, string>[];
} }
export type TestStep = export type TestStep =
@ -13,13 +16,9 @@ export type TestStep =
| { type: "pause-server" } | { type: "pause-server" }
| { type: "resume-server" } | { type: "resume-server" }
| { type: "barrier" } | { type: "barrier" }
| { type: "assert-content"; client: number; path: string; content: string } | { type: "assert-consistent"; verify?: (state: AssertableState) => void };
| { type: "assert-exists"; client: number; path: string }
| { type: "assert-not-exists"; client: number; path: string }
| { type: "assert-consistent"; verify?: (state: ClientState) => void };
export interface TestDefinition { export interface TestDefinition {
name: string;
description?: string; description?: string;
clients: number; clients: number;
steps: TestStep[]; steps: TestStep[];

View file

@ -1,86 +1,49 @@
import type { TestDefinition } from "./test-definition"; import type { TestDefinition } from "./test-definition";
import { writeWriteConflictTest } from "./tests/write-write-conflict.test";
import { renameCreateConflictTest } from "./tests/rename-create-conflict.test"; import { renameCreateConflictTest } from "./tests/rename-create-conflict.test";
import { createDeleteNoopTest } from "./tests/create-delete-noop.test";
import { renameChainTest } from "./tests/rename-chain.test"; import { renameChainTest } from "./tests/rename-chain.test";
import { serverPauseResumeTest } from "./tests/server-pause-resume.test";
import { createMergeDeleteTest } from "./tests/create-merge-delete.test";
import { renameUpdateConflictTest } from "./tests/rename-update-conflict.test"; import { renameUpdateConflictTest } from "./tests/rename-update-conflict.test";
import { deleteRenameConflictTest } from "./tests/delete-rename-conflict.test"; import { deleteRenameConflictTest } from "./tests/delete-rename-conflict.test";
import { multiFileOperationsTest } from "./tests/multi-file-operations.test"; import { multiFileOperationsTest } from "./tests/multi-file-operations.test";
import { duplicateContentFilesTest } from "./tests/duplicate-content-files.test";
import { deleteRecreateSamePathTest } from "./tests/delete-recreate-same-path.test"; import { deleteRecreateSamePathTest } from "./tests/delete-recreate-same-path.test";
import { rapidSyncToggleTest } from "./tests/rapid-sync-toggle.test";
import { concurrentDeleteUpdateTest } from "./tests/concurrent-delete-update.test";
import { offlineRenameAndEditTest } from "./tests/offline-rename-and-edit.test"; import { offlineRenameAndEditTest } from "./tests/offline-rename-and-edit.test";
import { threeClientConvergenceTest } from "./tests/three-client-convergence.test";
import { updateDuringServerPauseTest } from "./tests/update-during-server-pause.test";
import { emptyFileSyncTest } from "./tests/empty-file-sync.test";
import { renameToExistingPathTest } from "./tests/rename-to-existing-path.test"; import { renameToExistingPathTest } from "./tests/rename-to-existing-path.test";
import { concurrentRenameSameTargetTest } from "./tests/concurrent-rename-same-target.test";
import { multipleUpdatesCoalesceTest } from "./tests/multiple-updates-coalesce.test";
import { deleteNonexistentFileTest } from "./tests/delete-nonexistent-file.test";
import { createWhileServerPausedTest } from "./tests/create-while-server-paused.test";
import { interleavedOperationsTest } from "./tests/interleaved-operations.test";
import { simultaneousCreateDeleteSamePathTest } from "./tests/simultaneous-create-delete-same-path.test"; import { simultaneousCreateDeleteSamePathTest } from "./tests/simultaneous-create-delete-same-path.test";
import { largeFileCountTest } from "./tests/large-file-count.test";
import { offlineOperationsBothClientsTest } from "./tests/offline-operations-both-clients.test";
import { updateThenRenameTest } from "./tests/update-then-rename.test";
import { idempotencyAfterServerPauseTest } from "./tests/idempotency-after-server-pause.test"; import { idempotencyAfterServerPauseTest } from "./tests/idempotency-after-server-pause.test";
import { concurrentCreateSamePathMergeTest } from "./tests/concurrent-create-same-path-merge.test";
import { sequentialCreateDuplicateContentTest } from "./tests/sequential-create-duplicate-content.test"; import { sequentialCreateDuplicateContentTest } from "./tests/sequential-create-duplicate-content.test";
import { offlineMultiUpdateCatchupTest } from "./tests/offline-multi-update-catchup.test";
import { mcThreeClientRenameOfflineUpdateTest } from "./tests/mc-three-client-rename-offline-update.test"; import { mcThreeClientRenameOfflineUpdateTest } from "./tests/mc-three-client-rename-offline-update.test";
import { mcMultiDeleteOfflineRenameTest } from "./tests/mc-multi-delete-offline-rename.test"; import { mcMultiDeleteOfflineRenameTest } from "./tests/mc-multi-delete-offline-rename.test";
import { mcCrossCreateRenameSameTargetTest } from "./tests/mc-cross-create-rename-same-target.test"; import { mcCrossCreateRenameSameTargetTest } from "./tests/mc-cross-create-rename-same-target.test";
import { mcDeleteThenOfflineRenameTest } from "./tests/mc-delete-then-offline-rename.test"; import { mcDeleteThenOfflineRenameTest } from "./tests/mc-delete-then-offline-rename.test";
import { offlineMixedOperationsTest } from "./tests/offline-mixed-operations.test"; import { offlineMixedOperationsTest } from "./tests/offline-mixed-operations.test";
import { offlineCreateRenameCreateTest } from "./tests/offline-create-rename-create.test";
import { offlineConcurrentRenamesTest } from "./tests/offline-concurrent-renames.test"; import { offlineConcurrentRenamesTest } from "./tests/offline-concurrent-renames.test";
import { offlineMultipleEditsTest } from "./tests/offline-multiple-edits.test"; import { offlineMultipleEditsTest } from "./tests/offline-multiple-edits.test";
import { serverPauseBothClientsCreateTest } from "./tests/server-pause-both-clients-create.test"; import { serverPauseBothClientsCreateTest } from "./tests/server-pause-both-clients-create.test";
import { serverPauseRenameTest } from "./tests/server-pause-rename-propagation.test";
import { serverPauseConcurrentCreatesTest } from "./tests/server-pause-concurrent-creates.test";
import { serverPauseUpdateAndCreateTest } from "./tests/server-pause-update-and-create.test"; import { serverPauseUpdateAndCreateTest } from "./tests/server-pause-update-and-create.test";
import { renameSwapTest } from "./tests/rename-swap.test"; import { renameSwapTest } from "./tests/rename-swap.test";
import { renameCircularTest } from "./tests/rename-circular.test"; import { renameCircularTest } from "./tests/rename-circular.test";
import { renameNestedPathTest } from "./tests/rename-nested-path.test";
import { renameRoundtripTest } from "./tests/rename-roundtrip.test"; import { renameRoundtripTest } from "./tests/rename-roundtrip.test";
import { offlineRenameRemoteCreateOldPathTest } from "./tests/offline-rename-remote-create-old-path.test"; import { offlineRenameRemoteCreateOldPathTest } from "./tests/offline-rename-remote-create-old-path.test";
import { offlineEditRemoteRenameTest } from "./tests/offline-edit-remote-rename.test"; import { offlineEditRemoteRenameTest } from "./tests/offline-edit-remote-rename.test";
import { renameChainThenDeleteTest } from "./tests/rename-chain-then-delete.test"; import { renameChainThenDeleteTest } from "./tests/rename-chain-then-delete.test";
import { offlineDeleteRemoteRenameTest } from "./tests/offline-delete-remote-rename.test"; import { offlineDeleteRemoteRenameTest } from "./tests/offline-delete-remote-rename.test";
import { renameToRecentlyDeletedPathTest } from "./tests/rename-to-recently-deleted-path.test"; import { renameToRecentlyDeletedPathTest } from "./tests/rename-to-recently-deleted-path.test";
import { createUpdateCoalesceServerPauseTest } from "./tests/create-update-coalesce-server-pause.test";
import { overlappingEditsSameSectionTest } from "./tests/overlapping-edits-same-section.test"; import { overlappingEditsSameSectionTest } from "./tests/overlapping-edits-same-section.test";
import { rapidUpdatesAfterMergeTest } from "./tests/rapid-updates-after-merge.test"; import { rapidUpdatesAfterMergeTest } from "./tests/rapid-updates-after-merge.test";
import { offlineRenamePendingCreateTest } from "./tests/offline-rename-pending-create.test";
import { deleteRecreateConcurrentUpdateTest } from "./tests/delete-recreate-concurrent-update.test"; import { deleteRecreateConcurrentUpdateTest } from "./tests/delete-recreate-concurrent-update.test";
import { moveAndConcurrentRemoteUpdateTest } from "./tests/move-and-concurrent-remote-update.test"; import { moveAndConcurrentRemoteUpdateTest } from "./tests/move-and-concurrent-remote-update.test";
import { offlineDeleteVsRemoteUpdateTest } from "./tests/offline-delete-vs-remote-update.test"; import { offlineDeleteVsRemoteUpdateTest } from "./tests/offline-delete-vs-remote-update.test";
import { doubleOfflineCycleTest } from "./tests/double-offline-cycle.test"; import { doubleOfflineCycleTest } from "./tests/double-offline-cycle.test";
import { createRenameCreateSamePathTest } from "./tests/create-rename-create-same-path.test";
import { concurrentEditExactSamePositionTest } from "./tests/concurrent-edit-exact-same-position.test";
import { serverPauseRenameEditResumeTest } from "./tests/server-pause-rename-edit-resume.test"; import { serverPauseRenameEditResumeTest } from "./tests/server-pause-rename-edit-resume.test";
import { renameTrackedToOccupiedPendingPathTest } from "./tests/rename-tracked-to-occupied-pending-path.test";
import { offlineUpdateBothThenDeleteOneTest } from "./tests/offline-update-both-then-delete-one.test"; import { offlineUpdateBothThenDeleteOneTest } from "./tests/offline-update-both-then-delete-one.test";
import { moveIdenticalContentAmbiguityTest } from "./tests/move-identical-content-ambiguity.test";
import { coalesceUpdateRemoteUpdateDataLossTest } from "./tests/coalesce-update-remote-update-data-loss.test";
import { offlineCreateSamePathMergeableTest } from "./tests/offline-create-same-path-binary-conflict.test"; import { offlineCreateSamePathMergeableTest } from "./tests/offline-create-same-path-binary-conflict.test";
import { deleteDuringPendingCreateTest } from "./tests/delete-during-pending-create.test"; import { deleteDuringPendingCreateTest } from "./tests/delete-during-pending-create.test";
import { threeClientRenameCreateDeleteTest } from "./tests/three-client-rename-create-delete.test"; import { threeClientRenameCreateDeleteTest } from "./tests/three-client-rename-create-delete.test";
import { keyMigrationEventDropTest } from "./tests/key-migration-event-drop.test"; import { keyMigrationEventDropTest } from "./tests/key-migration-event-drop.test";
import { renameToPathOfUnconfirmedDeleteTest } from "./tests/rename-to-path-of-unconfirmed-delete.test"; import { renameToPathOfUnconfirmedDeleteTest } from "./tests/rename-to-path-of-unconfirmed-delete.test";
import { offlineEditThenMoveSameContentTest } from "./tests/offline-edit-then-move-same-content.test"; import { offlineEditThenMoveSameContentTest } from "./tests/offline-edit-then-move-same-content.test";
import { concurrentRenameAndCreateAtTargetTest } from "./tests/concurrent-rename-and-create-at-target.test";
import { createRenameCreateSamePathOfflineTest } from "./tests/create-rename-create-same-path-offline.test";
import { rapidCreateUpdateDeleteCycleTest } from "./tests/rapid-create-update-delete-cycle.test"; import { rapidCreateUpdateDeleteCycleTest } from "./tests/rapid-create-update-delete-cycle.test";
import { serverPauseBothEditSameFileTest } from "./tests/server-pause-both-edit-same-file.test"; import { serverPauseBothEditSameFileTest } from "./tests/server-pause-both-edit-same-file.test";
import { reconcilePendingAtOccupiedPathTest } from "./tests/reconcile-pending-at-occupied-path.test";
import { offlineRenameBothClientsSameSourceTest } from "./tests/offline-rename-both-clients-same-source.test";
import { createDuringReconciliationTest } from "./tests/create-during-reconciliation.test";
import { deleteRecreateDifferentContentTest } from "./tests/delete-recreate-different-content.test"; import { deleteRecreateDifferentContentTest } from "./tests/delete-recreate-different-content.test";
import { moveChainThreeFilesTest } from "./tests/move-chain-three-files.test";
import { updateDuringCreateProcessingTest } from "./tests/update-during-create-processing.test"; import { updateDuringCreateProcessingTest } from "./tests/update-during-create-processing.test";
import { offlineMoveThenRemoteDeleteTest } from "./tests/offline-move-then-remote-delete.test"; import { offlineMoveThenRemoteDeleteTest } from "./tests/offline-move-then-remote-delete.test";
import { resetClearsRecentlyDeletedResurrectionTest } from "./tests/reset-clears-recently-deleted-resurrection.test"; import { resetClearsRecentlyDeletedResurrectionTest } from "./tests/reset-clears-recently-deleted-resurrection.test";
@ -90,109 +53,64 @@ import { updateSurvivesRemoteDeleteTest } from "./tests/update-survives-remote-d
import { movePreservesRemoteUpdateTest } from "./tests/move-preserves-remote-update.test"; import { movePreservesRemoteUpdateTest } from "./tests/move-preserves-remote-update.test";
import { recentlyDeletedClearedOnReconnectTest } from "./tests/recently-deleted-cleared-on-reconnect.test"; import { recentlyDeletedClearedOnReconnectTest } from "./tests/recently-deleted-cleared-on-reconnect.test";
import { migrateKeyPreservesExistingTest } from "./tests/migrate-key-preserves-existing.test"; import { migrateKeyPreservesExistingTest } from "./tests/migrate-key-preserves-existing.test";
import { userParenthesizedFileNotDeletedTest } from "./tests/user-parenthesized-file-not-deleted.test";
import { concurrentUpdateDiffConsistencyTest } from "./tests/concurrent-update-diff-consistency.test";
import { concurrentDeleteDuringRemoteUpdateTest } from "./tests/concurrent-delete-during-remote-update.test";
import { binaryPendingCreateNotDisplacedTest } from "./tests/binary-pending-create-not-displaced.test";
import { failedVfsMoveFallsBackTest } from "./tests/failed-vfs-move-falls-back.test"; import { failedVfsMoveFallsBackTest } from "./tests/failed-vfs-move-falls-back.test";
import { watermarkAdvancesOnSkipTest } from "./tests/watermark-advances-on-skip.test"; import { watermarkAdvancesOnSkipTest } from "./tests/watermark-advances-on-skip.test";
import { remoteDeleteCoalesceLosesLocalUpdateTest } from "./tests/remote-delete-coalesce-loses-local-update.test";
import { updateVsRemoteDeleteDataLossTest } from "./tests/update-vs-remote-delete-data-loss.test";
import { watermarkGapRemoteUpdateNotRecordedTest } from "./tests/watermark-gap-remote-update-not-recorded.test"; import { watermarkGapRemoteUpdateNotRecordedTest } from "./tests/watermark-gap-remote-update-not-recorded.test";
import { renameEmptyFileLosesIdentityTest } from "./tests/rename-empty-file-loses-identity.test";
import { queueResetLosesCoalescedLocalEditTest } from "./tests/queue-reset-loses-coalesced-local-edit.test"; import { queueResetLosesCoalescedLocalEditTest } from "./tests/queue-reset-loses-coalesced-local-edit.test";
import { renameToPendingPathFallbackTest } from "./tests/rename-to-pending-path-fallback.test"; import { renameToPendingPathFallbackTest } from "./tests/rename-to-pending-path-fallback.test";
import { coalescedRemoteUpdateWatermarkLossTest } from "./tests/coalesced-remote-update-watermark-loss.test";
import { moveRemoteUpdateRevertsRenameTest } from "./tests/move-remote-update-reverts-rename.test"; import { moveRemoteUpdateRevertsRenameTest } from "./tests/move-remote-update-reverts-rename.test";
import { createMergePreservesRenamedUpdateTest } from "./tests/create-merge-preserves-renamed-update.test";
import { localEditLostDuringCreateMergeTest } from "./tests/local-edit-lost-during-create-merge.test"; import { localEditLostDuringCreateMergeTest } from "./tests/local-edit-lost-during-create-merge.test";
import { concurrentBinaryCreateDeconflictionTest } from "./tests/concurrent-binary-create-deconfliction.test";
import { renamePendingCreateBeforeResponseTest } from "./tests/rename-pending-create-before-response.test"; import { renamePendingCreateBeforeResponseTest } from "./tests/rename-pending-create-before-response.test";
import { createRenameResponseSkipsFileTest } from "./tests/create-rename-response-skips-file.test"; import { createRenameResponseSkipsFileTest } from "./tests/create-rename-response-skips-file.test";
import { staleDocOrphanDuplicateContentTest } from "./tests/stale-doc-orphan-duplicate-content.test"; import { onlineCreateRenameConcurrentCreateOrphanTest } from "./tests/online-create-rename-concurrent-create-orphan.test";
import { concurrentRenameFirstWinsTest } from "./tests/concurrent-rename-first-wins.test";
import { binaryToTextTransitionTest } from "./tests/binary-to-text-transition.test";
export const TESTS: Partial<Record<string, TestDefinition>> = { export const TESTS: Partial<Record<string, TestDefinition>> = {
"write-write-conflict": writeWriteConflictTest,
"rename-create-conflict": renameCreateConflictTest, "rename-create-conflict": renameCreateConflictTest,
"create-delete-noop": createDeleteNoopTest,
"rename-chain": renameChainTest, "rename-chain": renameChainTest,
"server-pause-resume": serverPauseResumeTest,
"create-merge-delete": createMergeDeleteTest,
"rename-update-conflict": renameUpdateConflictTest, "rename-update-conflict": renameUpdateConflictTest,
"delete-rename-conflict": deleteRenameConflictTest, "delete-rename-conflict": deleteRenameConflictTest,
"multi-file-operations": multiFileOperationsTest, "multi-file-operations": multiFileOperationsTest,
"duplicate-content-files": duplicateContentFilesTest,
"delete-recreate-same-path": deleteRecreateSamePathTest, "delete-recreate-same-path": deleteRecreateSamePathTest,
"rapid-sync-toggle": rapidSyncToggleTest,
"concurrent-delete-update": concurrentDeleteUpdateTest,
"offline-rename-and-edit": offlineRenameAndEditTest, "offline-rename-and-edit": offlineRenameAndEditTest,
"three-client-convergence": threeClientConvergenceTest,
"update-during-server-pause": updateDuringServerPauseTest,
"empty-file-sync": emptyFileSyncTest,
"rename-to-existing-path": renameToExistingPathTest, "rename-to-existing-path": renameToExistingPathTest,
"concurrent-rename-same-target": concurrentRenameSameTargetTest,
"multiple-updates-coalesce": multipleUpdatesCoalesceTest,
"delete-nonexistent-file": deleteNonexistentFileTest,
"create-while-server-paused": createWhileServerPausedTest,
"interleaved-operations": interleavedOperationsTest,
"simultaneous-create-delete-same-path": simultaneousCreateDeleteSamePathTest, "simultaneous-create-delete-same-path": simultaneousCreateDeleteSamePathTest,
"large-file-count": largeFileCountTest,
"offline-operations-both-clients": offlineOperationsBothClientsTest,
"update-then-rename": updateThenRenameTest,
"idempotency-after-server-pause": idempotencyAfterServerPauseTest, "idempotency-after-server-pause": idempotencyAfterServerPauseTest,
"concurrent-create-same-path-merge": concurrentCreateSamePathMergeTest,
"sequential-create-duplicate-content": sequentialCreateDuplicateContentTest, "sequential-create-duplicate-content": sequentialCreateDuplicateContentTest,
"offline-multi-update-catchup": offlineMultiUpdateCatchupTest,
"mc-three-client-rename-offline-update": mcThreeClientRenameOfflineUpdateTest, "mc-three-client-rename-offline-update": mcThreeClientRenameOfflineUpdateTest,
"mc-multi-delete-offline-rename": mcMultiDeleteOfflineRenameTest, "mc-multi-delete-offline-rename": mcMultiDeleteOfflineRenameTest,
"mc-cross-create-rename-same-target": mcCrossCreateRenameSameTargetTest, "mc-cross-create-rename-same-target": mcCrossCreateRenameSameTargetTest,
"mc-delete-then-offline-rename": mcDeleteThenOfflineRenameTest, "mc-delete-then-offline-rename": mcDeleteThenOfflineRenameTest,
"offline-mixed-operations": offlineMixedOperationsTest, "offline-mixed-operations": offlineMixedOperationsTest,
"offline-create-rename-create": offlineCreateRenameCreateTest,
"offline-concurrent-renames": offlineConcurrentRenamesTest, "offline-concurrent-renames": offlineConcurrentRenamesTest,
"offline-multiple-edits": offlineMultipleEditsTest, "offline-multiple-edits": offlineMultipleEditsTest,
"server-pause-both-clients-create": serverPauseBothClientsCreateTest, "server-pause-both-clients-create": serverPauseBothClientsCreateTest,
"server-pause-rename-propagation": serverPauseRenameTest,
"server-pause-concurrent-creates": serverPauseConcurrentCreatesTest,
"server-pause-update-and-create": serverPauseUpdateAndCreateTest, "server-pause-update-and-create": serverPauseUpdateAndCreateTest,
"rename-swap": renameSwapTest, "rename-swap": renameSwapTest,
"rename-circular": renameCircularTest, "rename-circular": renameCircularTest,
"rename-nested-path": renameNestedPathTest,
"rename-roundtrip": renameRoundtripTest, "rename-roundtrip": renameRoundtripTest,
"offline-rename-remote-create-old-path": offlineRenameRemoteCreateOldPathTest, "offline-rename-remote-create-old-path": offlineRenameRemoteCreateOldPathTest,
"offline-edit-remote-rename": offlineEditRemoteRenameTest, "offline-edit-remote-rename": offlineEditRemoteRenameTest,
"rename-chain-then-delete": renameChainThenDeleteTest, "rename-chain-then-delete": renameChainThenDeleteTest,
"offline-delete-remote-rename": offlineDeleteRemoteRenameTest, "offline-delete-remote-rename": offlineDeleteRemoteRenameTest,
"rename-to-recently-deleted-path": renameToRecentlyDeletedPathTest, "rename-to-recently-deleted-path": renameToRecentlyDeletedPathTest,
"create-update-coalesce-server-pause": createUpdateCoalesceServerPauseTest,
"overlapping-edits-same-section": overlappingEditsSameSectionTest, "overlapping-edits-same-section": overlappingEditsSameSectionTest,
"rapid-updates-after-merge": rapidUpdatesAfterMergeTest, "rapid-updates-after-merge": rapidUpdatesAfterMergeTest,
"offline-rename-pending-create": offlineRenamePendingCreateTest,
"delete-recreate-concurrent-update": deleteRecreateConcurrentUpdateTest, "delete-recreate-concurrent-update": deleteRecreateConcurrentUpdateTest,
"move-and-concurrent-remote-update": moveAndConcurrentRemoteUpdateTest, "move-and-concurrent-remote-update": moveAndConcurrentRemoteUpdateTest,
"double-offline-cycle": doubleOfflineCycleTest, "double-offline-cycle": doubleOfflineCycleTest,
"create-rename-create-same-path": createRenameCreateSamePathTest,
"concurrent-edit-exact-same-position": concurrentEditExactSamePositionTest,
"server-pause-rename-edit-resume": serverPauseRenameEditResumeTest, "server-pause-rename-edit-resume": serverPauseRenameEditResumeTest,
"rename-tracked-to-occupied-pending-path": renameTrackedToOccupiedPendingPathTest,
"offline-update-both-then-delete-one": offlineUpdateBothThenDeleteOneTest, "offline-update-both-then-delete-one": offlineUpdateBothThenDeleteOneTest,
"move-identical-content-ambiguity": moveIdenticalContentAmbiguityTest,
"coalesce-update-remote-update-data-loss": coalesceUpdateRemoteUpdateDataLossTest,
"offline-create-same-path-mergeable": offlineCreateSamePathMergeableTest, "offline-create-same-path-mergeable": offlineCreateSamePathMergeableTest,
"delete-during-pending-create": deleteDuringPendingCreateTest, "delete-during-pending-create": deleteDuringPendingCreateTest,
"three-client-rename-create-delete": threeClientRenameCreateDeleteTest, "three-client-rename-create-delete": threeClientRenameCreateDeleteTest,
"key-migration-event-drop": keyMigrationEventDropTest, "key-migration-event-drop": keyMigrationEventDropTest,
"rename-to-path-of-unconfirmed-delete": renameToPathOfUnconfirmedDeleteTest, "rename-to-path-of-unconfirmed-delete": renameToPathOfUnconfirmedDeleteTest,
"offline-edit-then-move-same-content": offlineEditThenMoveSameContentTest, "offline-edit-then-move-same-content": offlineEditThenMoveSameContentTest,
"concurrent-rename-and-create-at-target": concurrentRenameAndCreateAtTargetTest,
"create-rename-create-same-path-offline": createRenameCreateSamePathOfflineTest,
"rapid-create-update-delete-cycle": rapidCreateUpdateDeleteCycleTest, "rapid-create-update-delete-cycle": rapidCreateUpdateDeleteCycleTest,
"server-pause-both-edit-same-file": serverPauseBothEditSameFileTest, "server-pause-both-edit-same-file": serverPauseBothEditSameFileTest,
"reconcile-pending-at-occupied-path": reconcilePendingAtOccupiedPathTest,
"offline-rename-both-clients-same-source": offlineRenameBothClientsSameSourceTest,
"create-during-reconciliation": createDuringReconciliationTest,
"delete-recreate-different-content": deleteRecreateDifferentContentTest, "delete-recreate-different-content": deleteRecreateDifferentContentTest,
"move-chain-three-files": moveChainThreeFilesTest,
"update-during-create-processing": updateDuringCreateProcessingTest, "update-during-create-processing": updateDuringCreateProcessingTest,
"offline-move-then-remote-delete": offlineMoveThenRemoteDeleteTest, "offline-move-then-remote-delete": offlineMoveThenRemoteDeleteTest,
"reset-clears-recently-deleted-resurrection": resetClearsRecentlyDeletedResurrectionTest, "reset-clears-recently-deleted-resurrection": resetClearsRecentlyDeletedResurrectionTest,
@ -203,24 +121,16 @@ export const TESTS: Partial<Record<string, TestDefinition>> = {
"move-preserves-remote-update": movePreservesRemoteUpdateTest, "move-preserves-remote-update": movePreservesRemoteUpdateTest,
"recently-deleted-cleared-on-reconnect": recentlyDeletedClearedOnReconnectTest, "recently-deleted-cleared-on-reconnect": recentlyDeletedClearedOnReconnectTest,
"migrate-key-preserves-existing": migrateKeyPreservesExistingTest, "migrate-key-preserves-existing": migrateKeyPreservesExistingTest,
"user-parenthesized-file-not-deleted": userParenthesizedFileNotDeletedTest,
"concurrent-update-diff-consistency": concurrentUpdateDiffConsistencyTest,
"concurrent-delete-during-remote-update": concurrentDeleteDuringRemoteUpdateTest,
"binary-pending-create-not-displaced": binaryPendingCreateNotDisplacedTest,
"failed-vfs-move-falls-back": failedVfsMoveFallsBackTest, "failed-vfs-move-falls-back": failedVfsMoveFallsBackTest,
"watermark-advances-on-skip": watermarkAdvancesOnSkipTest, "watermark-advances-on-skip": watermarkAdvancesOnSkipTest,
"remote-delete-coalesce-loses-local-update": remoteDeleteCoalesceLosesLocalUpdateTest,
"update-vs-remote-delete-data-loss": updateVsRemoteDeleteDataLossTest,
"watermark-gap-remote-update-not-recorded": watermarkGapRemoteUpdateNotRecordedTest, "watermark-gap-remote-update-not-recorded": watermarkGapRemoteUpdateNotRecordedTest,
"rename-empty-file-loses-identity": renameEmptyFileLosesIdentityTest,
"queue-reset-loses-coalesced-local-edit": queueResetLosesCoalescedLocalEditTest, "queue-reset-loses-coalesced-local-edit": queueResetLosesCoalescedLocalEditTest,
"rename-to-pending-path-fallback": renameToPendingPathFallbackTest, "rename-to-pending-path-fallback": renameToPendingPathFallbackTest,
"coalesced-remote-update-watermark-loss": coalescedRemoteUpdateWatermarkLossTest,
"move-remote-update-reverts-rename": moveRemoteUpdateRevertsRenameTest, "move-remote-update-reverts-rename": moveRemoteUpdateRevertsRenameTest,
"create-merge-preserves-renamed-update": createMergePreservesRenamedUpdateTest,
"local-edit-lost-during-create-merge": localEditLostDuringCreateMergeTest, "local-edit-lost-during-create-merge": localEditLostDuringCreateMergeTest,
"concurrent-binary-create-deconfliction": concurrentBinaryCreateDeconflictionTest,
"rename-pending-create-before-response": renamePendingCreateBeforeResponseTest, "rename-pending-create-before-response": renamePendingCreateBeforeResponseTest,
"create-rename-response-skips-file": createRenameResponseSkipsFileTest, "create-rename-response-skips-file": createRenameResponseSkipsFileTest,
"stale-doc-orphan-duplicate-content": staleDocOrphanDuplicateContentTest "online-create-rename-concurrent-create-orphan": onlineCreateRenameConcurrentCreateOrphanTest,
"concurrent-rename-first-wins": concurrentRenameFirstWinsTest,
"binary-to-text-transition": binaryToTextTransitionTest,
}; };

View file

@ -1,13 +1,13 @@
import type { import type {
TestDefinition, TestDefinition,
TestResult, TestResult,
TestStep, TestStep
ClientState
} from "./test-definition"; } from "./test-definition";
import { DeterministicAgent } from "./deterministic-agent"; import { DeterministicAgent } from "./deterministic-agent";
import type { ServerControl } from "./server-control"; import type { ServerControl } from "./server-control";
import type { SyncSettings, Logger } from "sync-client"; import type { SyncSettings, Logger } from "sync-client";
import { assert } from "./utils/assert"; import { assert } from "./utils/assert";
import { AssertableState } from "./utils/assertable-state";
import { sleep } from "./utils/sleep"; import { sleep } from "./utils/sleep";
import { withTimeout } from "./utils/with-timeout"; import { withTimeout } from "./utils/with-timeout";
import { import {
@ -37,9 +37,12 @@ export class TestRunner {
this.remoteUri = remoteUri; this.remoteUri = remoteUri;
} }
public async runTest(test: TestDefinition): Promise<TestResult> { public async runTest(
name: string,
test: TestDefinition
): Promise<TestResult> {
const startTime = Date.now(); const startTime = Date.now();
this.logger.info(`Running test: ${test.name}`); this.logger.info(`Running test: ${name}`);
if (test.description !== undefined && test.description !== "") { if (test.description !== undefined && test.description !== "") {
this.logger.info(`Description: ${test.description}`); this.logger.info(`Description: ${test.description}`);
} }
@ -65,7 +68,7 @@ export class TestRunner {
await this.cleanup(); await this.cleanup();
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
this.logger.info(`\n✓ Test passed: ${test.name} (${duration}ms)`); this.logger.info(`\n✓ Test passed: ${name} (${duration}ms)`);
return { return {
success: true, success: true,
@ -75,7 +78,7 @@ export class TestRunner {
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
const errorMessage = const errorMessage =
error instanceof Error ? error.message : String(error); error instanceof Error ? error.message : String(error);
this.logger.info(`\n✗ Test failed: ${test.name}`); this.logger.info(`\n✗ Test failed: ${name}`);
this.logger.info(`Error: ${errorMessage}`); this.logger.info(`Error: ${errorMessage}`);
await this.cleanup(); await this.cleanup();
@ -192,21 +195,6 @@ export class TestRunner {
await this.waitForConvergence(); await this.waitForConvergence();
break; break;
case "assert-content":
await this.getAgent(step.client).assertContent(
step.path,
step.content
);
break;
case "assert-exists":
await this.getAgent(step.client).assertExists(step.path);
break;
case "assert-not-exists":
await this.getAgent(step.client).assertNotExists(step.path);
break;
case "assert-consistent": case "assert-consistent":
await this.assertConsistent(step.verify); await this.assertConsistent(step.verify);
break; break;
@ -263,17 +251,21 @@ export class TestRunner {
} }
/** /**
* Wait for all agents to be simultaneously idle. Two full rounds are * Wait for all agents to be simultaneously idle.
* needed because completing work on agent A can trigger a server *
* broadcast that enqueues new work on agent B, and vice versa. * Completing work on agent A can trigger a server broadcast that
* * enqueues new work on agent B, which can cascade further. With N
* However, the 2nd sync may result in merges which can trigger another * agents the worst-case cascade depth is N (a chain ABCA),
* round of syncs, so this function should be called in a loop with a * so we run N+1 sequential passes to drain it. Extra passes are
* timeout to ensure true convergence rather than just waiting for the * essentially free when there is no outstanding work.
* current round of syncs to complete. *
* The outer {@link waitForConvergence} loop with consistency checks
* remains the ultimate guarantee this method just minimizes how
* many slow retry iterations are needed.
*/ */
private async waitAllAgentsSettled(): Promise<void> { private async waitAllAgentsSettled(): Promise<void> {
for (let round = 0; round < 2; round++) { const rounds = this.agents.length + 1;
for (let round = 0; round < rounds; round++) {
for (const agent of this.agents) { for (const agent of this.agents) {
await agent.waitForSync(); await agent.waitForSync();
} }
@ -281,47 +273,52 @@ export class TestRunner {
} }
private async assertConsistent( private async assertConsistent(
verify?: (state: ClientState) => void verify?: (state: AssertableState) => void
): Promise<void> { ): Promise<void> {
this.logger.info("Asserting all clients are consistent..."); this.logger.info("Asserting all clients are consistent...");
assert(this.agents.length >= 2, "Need at least 2 agents for consistency check"); assert(this.agents.length >= 2, "Need at least 2 agents for consistency check");
const [referenceAgent] = this.agents; // Snapshot all agents' file states upfront to minimize the window
const referenceFiles = (await referenceAgent.getFiles()).sort(); // where background sync could mutate state between reads.
const referenceState: ClientState = { files: new Map() }; const clientFiles: Map<string, string>[] = [];
for (const agent of this.agents) {
for (const file of referenceFiles) { const sortedFiles = (await agent.getFiles()).sort();
const content = await referenceAgent.getFileContent(file); const fileMap = new Map<string, string>();
referenceState.files.set(file, content); for (const file of sortedFiles) {
const content = await agent.getFileContent(file);
fileMap.set(file, content);
}
clientFiles.push(fileMap);
} }
const referenceFiles = Array.from(clientFiles[0].keys());
this.logger.info( this.logger.info(
`Reference client has ${referenceFiles.length} files: ${referenceFiles.join(", ")}` `Reference client has ${referenceFiles.length} files: ${referenceFiles.join(", ")}`
); );
for (let i = 1; i < this.agents.length; i++) { for (let i = 1; i < clientFiles.length; i++) {
const agent = this.agents[i]; const agentFileKeys = Array.from(clientFiles[i].keys());
const files = (await agent.getFiles()).sort();
this.logger.info( this.logger.info(
`Client ${i} has ${files.length} files: ${files.join(", ")}` `Client ${i} has ${agentFileKeys.length} files: ${agentFileKeys.join(", ")}`
); );
assert( assert(
files.length === referenceFiles.length, agentFileKeys.length === referenceFiles.length,
`File count mismatch: client 0 has ${referenceFiles.length} files, client ${i} has ${files.length} files` `File count mismatch: client 0 has ${referenceFiles.length} files, client ${i} has ${agentFileKeys.length} files`
); );
for (let j = 0; j < files.length; j++) { for (let j = 0; j < agentFileKeys.length; j++) {
assert( assert(
files[j] === referenceFiles[j], agentFileKeys[j] === referenceFiles[j],
`File list mismatch at index ${j}: client 0 has "${referenceFiles[j]}", client ${i} has "${files[j]}"` `File list mismatch at index ${j}: client 0 has "${referenceFiles[j]}", client ${i} has "${agentFileKeys[j]}"`
); );
} }
for (const file of referenceFiles) { for (const file of referenceFiles) {
const referenceContent = referenceState.files.get(file); const referenceContent = clientFiles[0].get(file);
const agentContent = await agent.getFileContent(file); const agentContent = clientFiles[i].get(file);
assert( assert(
referenceContent === agentContent, referenceContent === agentContent,
@ -335,7 +332,12 @@ export class TestRunner {
if (verify) { if (verify) {
this.logger.info("Running custom verification..."); this.logger.info("Running custom verification...");
try { try {
verify(referenceState); verify(
new AssertableState({
files: clientFiles[0],
clientFiles
})
);
} catch (error) { } catch (error) {
const msg = const msg =
error instanceof Error ? error.message : String(error); error instanceof Error ? error.message : String(error);

View file

@ -1,11 +1,9 @@
import type { TestDefinition } from "../test-definition"; import type { TestDefinition } from "../test-definition";
import type { AssertableState } from "../utils/assertable-state";
export const textPendingCreateNotDisplacedTest: TestDefinition = { export const textPendingCreateNotDisplacedTest: TestDefinition = {
name: "Both offline binary creates at same path survive sync",
description: description:
"Two clients each create a binary file at the same path while offline. " + "Two clients each create a text file at the same path while offline. " +
"After syncing, both files should exist on both clients at separate paths.", "After syncing, the file should contain merged content from both clients.",
clients: 2, clients: 2,
steps: [ steps: [
{ {
@ -25,16 +23,6 @@ export const textPendingCreateNotDisplacedTest: TestDefinition = {
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-consistent", verify: verifyBothFilesExist } { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertFileExists("data.txt").assertAnyFileContains("data from client 0", "data from client 1") }
] ]
}; };
function verifyBothFilesExist(state: AssertableState): void {
state
.assertFileCount(1)
.assertFileExists("data.txt")
.assertAnyFileContains(
"data from client 0",
"data from client 1"
);
}

View file

@ -1,7 +1,6 @@
import type { TestDefinition } from "../test-definition"; import type { TestDefinition } from "../test-definition";
export const concurrentUpdateDiffConsistencyTest: TestDefinition = { export const concurrentUpdateDiffConsistencyTest: TestDefinition = {
name: "Concurrent edits to different sections merge correctly",
description: description:
"Both clients edit different sections of the same file while offline. " + "Both clients edit different sections of the same file while offline. " +
"After syncing, the merged file should contain both edits.", "After syncing, the merged file should contain both edits.",

View file

@ -0,0 +1,46 @@
import type { TestDefinition } from "../test-definition";
export const userParenthesizedFileNotDeletedTest: TestDefinition = {
description:
"A user-created file named 'Chapter (1).bin' alongside 'Chapter.bin' should not " +
"be mistakenly removed when another client creates a conflicting file.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{
type: "create",
client: 0,
path: "Chapter.bin",
content: "chapter one"
},
{
type: "create",
client: 0,
path: "Chapter (1).bin",
content: "chapter one notes"
},
{ type: "sync", client: 0 },
{
type: "create",
client: 1,
path: "Chapter.bin",
content: "chapter one notes"
},
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state) => {
state
.assertFileCount(3)
.assertFileExists("Chapter.bin")
.assertFileExists("Chapter (1).bin")
.assertFileExists("Chapter (2).bin");
}
}
]
};

View file

@ -1,7 +1,6 @@
import type { TestDefinition } from "../test-definition"; import type { TestDefinition } from "../test-definition";
export const createDeleteNoopTest: TestDefinition = { export const createDeleteNoopTest: TestDefinition = {
name: "Offline create then delete results in no file",
description: description:
"A client creates a file, updates it multiple times, then deletes it, all while " + "A client creates a file, updates it multiple times, then deletes it, all while " +
"offline. After syncing, neither client should have the file.", "offline. After syncing, neither client should have the file.",
@ -17,8 +16,6 @@ export const createDeleteNoopTest: TestDefinition = {
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-not-exists", client: 0, path: "temp.md" }, { type: "assert-consistent", verify: (s) => s.assertFileNotExists("temp.md") }
{ type: "assert-not-exists", client: 1, path: "temp.md" },
{ type: "assert-consistent" }
] ]
}; };

View file

@ -1,7 +1,6 @@
import type { TestDefinition } from "../test-definition"; import type { TestDefinition } from "../test-definition";
export const createMergeDeleteTest: TestDefinition = { export const createMergeDeleteTest: TestDefinition = {
name: "Concurrent Create, Merge, Then Delete",
description: description:
"Two clients create A.md offline with different content. Both come online and " + "Two clients create A.md offline with different content. Both come online and " +
"the content is merged. Then one client deletes A.md. Both clients should " + "the content is merged. Then one client deletes A.md. Both clients should " +
@ -23,8 +22,6 @@ export const createMergeDeleteTest: TestDefinition = {
{ type: "delete", client: 0, path: "A.md" }, { type: "delete", client: 0, path: "A.md" },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-not-exists", client: 0, path: "A.md" }, { type: "assert-consistent", verify: (s) => s.assertFileCount(0).assertFileNotExists("A.md") }
{ type: "assert-not-exists", client: 1, path: "A.md" },
{ type: "assert-consistent", verify: (state) => state.assertFileCount(0) }
] ]
}; };

View file

@ -1,7 +1,6 @@
import type { TestDefinition } from "../test-definition"; import type { TestDefinition } from "../test-definition";
export const moveIdenticalContentAmbiguityTest: TestDefinition = { export const moveIdenticalContentAmbiguityTest: TestDefinition = {
name: "Move Detection Ambiguity With Identical Content",
description: description:
"Two files with identical content exist. One is deleted and the other renamed " + "Two files with identical content exist. One is deleted and the other renamed " +
"while offline. The system should still converge correctly despite the ambiguity.", "while offline. The system should still converge correctly despite the ambiguity.",
@ -23,19 +22,6 @@ export const moveIdenticalContentAmbiguityTest: TestDefinition = {
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "barrier" }, { type: "barrier" },
{
type: "assert-content",
client: 1,
path: "A.md",
content: "identical content"
},
{
type: "assert-content",
client: 1,
path: "B.md",
content: "identical content"
},
{ type: "disable-sync", client: 1 }, { type: "disable-sync", client: 1 },
{ type: "delete", client: 1, path: "A.md" }, { type: "delete", client: 1, path: "A.md" },
{ type: "rename", client: 1, oldPath: "B.md", newPath: "C.md" }, { type: "rename", client: 1, oldPath: "B.md", newPath: "C.md" },

View file

@ -1,24 +0,0 @@
import type { TestDefinition } from "../test-definition";
export const writeWriteConflictTest: TestDefinition = {
name: "Write/Write Conflict",
description:
"Two clients simultaneously create the same file with different content. " +
"Both contributions should be preserved in the merged result without duplication.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "A.md", content: "hello" },
{ type: "create", client: 1, path: "A.md", content: "hello" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state) => {
state
.assertFileCount(1)
.assertContent("A.md", "hello")
}
}
]
};

View file

@ -1,7 +1,6 @@
import type { TestDefinition } from "../test-definition"; import type { TestDefinition } from "../test-definition";
export const createUpdateCoalesceServerPauseTest: TestDefinition = { export const createUpdateCoalesceServerPauseTest: TestDefinition = {
name: "Create and Immediate Update While Server Is Paused",
description: description:
"Client creates a file and immediately updates it while the server is " + "Client creates a file and immediately updates it while the server is " +
"paused. When the server resumes, both clients should have the final " + "paused. When the server resumes, both clients should have the final " +

View file

@ -1,7 +1,6 @@
import type { TestDefinition } from "../test-definition"; import type { TestDefinition } from "../test-definition";
export const createDuringReconciliationTest: TestDefinition = { export const createDuringReconciliationTest: TestDefinition = {
name: "File Created Right After Reconnect Syncs Correctly",
description: description:
"Client creates two files while offline, reconnects, then immediately " + "Client creates two files while offline, reconnects, then immediately " +
"creates a third file. All three files should sync to the other client.", "creates a third file. All three files should sync to the other client.",

View file

@ -0,0 +1,44 @@
import type { TestDefinition } from "../test-definition";
export const createMergePreservesRenamedUpdateTest: TestDefinition = {
description:
"Both clients create the same file, which gets merged. One client goes " +
"offline, renames the file, updates it, and creates a new file at the " +
"original path. After reconnecting, the updated content must be preserved.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "doc.md", content: "alpha" },
{ type: "create", client: 1, path: "doc.md", content: "beta" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "disable-sync", client: 1 },
{
type: "rename",
client: 1,
oldPath: "doc.md",
newPath: "moved.md"
},
{
type: "update",
client: 1,
path: "moved.md",
content: "alpha beta extra-update"
},
{
type: "create",
client: 1,
path: "doc.md",
content: "new-content"
},
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "assert-consistent", verify: (state) => state.assertContent("moved.md", "alpha beta extra-update").assertContent("doc.md", "new-content") }
]
};

View file

@ -0,0 +1,34 @@
import type { TestDefinition } from "../test-definition";
export const createRenameCreateSamePathTest: TestDefinition = {
description:
"Client creates A.md, renames to B.md, creates new A.md, renames " +
"to C.md, creates yet another A.md. All three files should exist " +
"as separate documents on both clients.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "A.md", content: "first file" },
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
{ type: "create", client: 0, path: "A.md", content: "second file" },
{ type: "rename", client: 0, oldPath: "A.md", newPath: "C.md" },
{ type: "create", client: 0, path: "A.md", content: "third file" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state) => {
state
.assertFileCount(3)
.assertContent("B.md", "first file")
.assertContent("C.md", "second file")
.assertContent("A.md", "third file");
}
}
]
};

View file

@ -0,0 +1,41 @@
import type { TestDefinition } from "../test-definition";
export const moveChainThreeFilesTest: TestDefinition = {
description:
"Three files have their contents rotated (A gets C's content, B gets A's, C gets B's) " +
"while offline. After reconnecting, both clients should converge with the rotated contents.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "create", client: 0, path: "A.md", content: "was A" },
{ type: "create", client: 0, path: "B.md", content: "was B" },
{ type: "create", client: 0, path: "C.md", content: "was C" },
{ type: "barrier" },
{ type: "disable-sync", client: 0 },
{ type: "delete", client: 0, path: "A.md" },
{ type: "delete", client: 0, path: "B.md" },
{ type: "delete", client: 0, path: "C.md" },
{ type: "create", client: 0, path: "A.md", content: "was C" },
{ type: "create", client: 0, path: "B.md", content: "was A" },
{ type: "create", client: 0, path: "C.md", content: "was B" },
{ type: "enable-sync", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state) => {
state
.assertFileCount(3)
.assertContent("A.md", "was C")
.assertContent("B.md", "was A")
.assertContent("C.md", "was B");
}
}
]
};

View file

@ -1,8 +1,6 @@
import type { TestDefinition } from "../test-definition"; import type { TestDefinition } from "../test-definition";
import type { AssertableState } from "../utils/assertable-state";
export const binaryPendingCreateNotDisplacedTest: TestDefinition = { export const binaryPendingCreateNotDisplacedTest: TestDefinition = {
name: "Both offline binary creates at same path survive sync",
description: description:
"Two clients each create a binary file at the same path while offline. " + "Two clients each create a binary file at the same path while offline. " +
"After syncing, both files should exist on both clients at separate paths.", "After syncing, both files should exist on both clients at separate paths.",
@ -25,17 +23,6 @@ export const binaryPendingCreateNotDisplacedTest: TestDefinition = {
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-consistent", verify: verifyBothFilesExist } { type: "assert-consistent", verify: (s) => s.assertFileCount(2).assertFileExists("data.bin").assertFileExists("data (1).bin").assertAnyFileContains("binary data from client 0", "binary data from client 1") }
] ]
}; };
function verifyBothFilesExist(state: AssertableState): void {
state
.assertFileCount(2)
.assertFileExists("data.bin")
.assertFileExists("data (1).bin")
.assertAnyFileContains(
"binary data from client 0",
"binary data from client 1"
);
}

View file

@ -1,7 +1,6 @@
import type { TestDefinition } from "../test-definition"; import type { TestDefinition } from "../test-definition";
export const coalesceUpdateRemoteUpdateDataLossTest: TestDefinition = { export const coalesceUpdateRemoteUpdateDataLossTest: TestDefinition = {
name: "Local and remote edits to the same file are both preserved",
description: description:
"Client 0 edits a file while client 1 is offline. Client 1 reconnects " + "Client 0 edits a file while client 1 is offline. Client 1 reconnects " +
"and immediately edits the same file. Both edits should be preserved.", "and immediately edits the same file. Both edits should be preserved.",

View file

@ -1,29 +1,24 @@
import type { TestDefinition } from "../test-definition"; import type { TestDefinition } from "../test-definition";
import type { AssertableState } from "../utils/assertable-state";
export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = { export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = {
name: "Coalesced Remote Updates Lose Earlier vaultUpdateIds",
description: description:
"When multiple remote-update events for the same document coalesce, " + "Client 0 sends three rapid updates. After syncing, both clients " +
"only the last vaultUpdateId is recorded. Earlier IDs create " + "disconnect and reconnect twice. Content should remain correct " +
"permanent watermark gaps that cause unnecessary server replays " + "after each reconnect.",
"on every reconnect.",
clients: 2, clients: 2,
steps: [ steps: [
// Setup: both clients have doc.md
{ type: "create", client: 0, path: "doc.md", content: "original" }, { type: "create", client: 0, path: "doc.md", content: "original" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "barrier" }, { 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 1" },
{ type: "update", client: 0, path: "doc.md", content: "update 2" }, { type: "update", client: 0, path: "doc.md", content: "update 2" },
{ type: "update", client: 0, path: "doc.md", content: "final update" }, { type: "update", client: 0, path: "doc.md", content: "final update" },
{ type: "sync", client: 0 }, { type: "sync", client: 0 },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-consistent", verify: verifyContent }, { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("doc.md", "final update") },
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 }, { type: "disable-sync", client: 1 },
@ -31,18 +26,13 @@ export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = {
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-consistent", verify: verifyContent }, { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("doc.md", "final update") },
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 }, { type: "disable-sync", client: 1 },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-consistent", verify: verifyContent } { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("doc.md", "final update") }
] ]
}; };
function verifyContent(state: AssertableState): void {
state.assertFileCount(1).assertContent("doc.md", "final update");
}

View file

@ -1,8 +1,6 @@
import { AssertableState } from "src/utils/assertable-state";
import type { TestDefinition } from "../test-definition"; import type { TestDefinition } from "../test-definition";
export const concurrentDeleteDuringRemoteUpdateTest: TestDefinition = { export const concurrentDeleteDuringRemoteUpdateTest: TestDefinition = {
name: "Delete and remote update of same file do not crash",
description: description:
"One client updates a file while the other deletes it at the same " + "One client updates a file while the other deletes it at the same " +
"time. Both clients should converge without errors.", "time. Both clients should converge without errors.",

View file

@ -1,7 +1,6 @@
import type { TestDefinition } from "../test-definition"; import type { TestDefinition } from "../test-definition";
export const concurrentEditExactSamePositionTest: TestDefinition = { export const concurrentEditExactSamePositionTest: TestDefinition = {
name: "Concurrent edits to the exact same word are both preserved",
description: description:
"Both clients replace the same word in a file with different text " + "Both clients replace the same word in a file with different text " +
"while offline. After syncing, the merged result should contain " + "while offline. After syncing, the merged result should contain " +
@ -17,12 +16,6 @@ export const concurrentEditExactSamePositionTest: TestDefinition = {
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "barrier" }, { type: "barrier" },
{
type: "assert-content",
client: 1,
path: "doc.md",
content: "the quick brown fox"
},
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 }, { type: "disable-sync", client: 1 },

View file

@ -1,7 +1,6 @@
import type { TestDefinition } from "../test-definition"; import type { TestDefinition } from "../test-definition";
export const concurrentRenameAndCreateAtTargetTest: TestDefinition = { export const concurrentRenameAndCreateAtTargetTest: TestDefinition = {
name: "Rename to path where another client creates a file",
description: description:
"One client renames X to Y while another creates a new file at Y, " + "One client renames X to Y while another creates a new file at Y, " +
"both offline. After syncing, Y should contain merged content from " + "both offline. After syncing, Y should contain merged content from " +

View file

@ -1,7 +1,6 @@
import type { TestDefinition } from "../test-definition"; import type { TestDefinition } from "../test-definition";
export const concurrentRenameAndCreateAtTargetTest: TestDefinition = { export const concurrentRenameAndCreateAtTargetTest: TestDefinition = {
name: "Rename to path where another client creates a file",
description: description:
"One client renames X to Y while another creates a new file at Y, " + "One client renames X to Y while another creates a new file at Y, " +
"both offline. After syncing, Y should contain merged content from " + "both offline. After syncing, Y should contain merged content from " +

View file

@ -1,7 +1,6 @@
import type { TestDefinition } from "../test-definition"; import type { TestDefinition } from "../test-definition";
export const concurrentRenameSameTargetTest: TestDefinition = { export const concurrentRenameSameTargetTest: TestDefinition = {
name: "Two clients rename different files to the same target path",
description: description:
"One client renames A to C while the other renames B to C, both offline. " + "One client renames A to C while the other renames B to C, both offline. " +
"After syncing, both file contents should be preserved via path deconfliction.", "After syncing, both file contents should be preserved via path deconfliction.",

View file

@ -0,0 +1,47 @@
import type { TestDefinition } from "../test-definition";
export const binaryToTextTransitionTest: TestDefinition = {
description:
"A .bin file is created and synced. Both clients edit it offline, " +
"then it is renamed to .md. Both clients edit different sections " +
"offline again. The second merge should preserve both edits.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "data.bin", content: "original content" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "assert-consistent", verify: (s) => s.assertContent("data.bin", "original content") },
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
{ type: "update", client: 0, path: "data.bin", content: "version A from client 0" },
{ type: "update", client: 1, path: "data.bin", content: "version B from client 1" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContainsAny("data.bin", "version A from client 0", "version B from client 1") },
{ type: "disable-sync", client: 1 },
{ type: "rename", client: 0, oldPath: "data.bin", newPath: "data.md" },
{ type: "sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "assert-consistent", verify: (s) => s.assertFileExists("data.md") },
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
{ type: "update", client: 0, path: "data.md", content: "top edit from 0\nmiddle line\nshared end" },
{ type: "update", client: 1, path: "data.md", content: "shared start\nmiddle line\nbottom edit from 1" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("data.md", "top edit from 0", "bottom edit from 1") },
],
};

View file

@ -0,0 +1,36 @@
import type { TestDefinition } from "../test-definition";
export const concurrentRenameFirstWinsTest: TestDefinition = {
description:
"Both clients start online with the same file. Both go offline, " +
"rename the file to different paths, and edit it. When they reconnect, " +
"the first rename to reach the server wins the path and both content " +
"edits are merged.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "A.md", content: "line 1\nline 2\nline 3" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "assert-consistent", verify: (s) => s.assertContent("A.md", "line 1\nline 2\nline 3") },
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
{ type: "update", client: 0, path: "B.md", content: "edit from 0\nline 2\nline 3" },
{ type: "rename", client: 1, oldPath: "A.md", newPath: "C.md" },
{ type: "update", client: 1, path: "C.md", content: "line 1\nline 2\nedit from 1" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "assert-consistent", verify: (s) => {
s.assertFileNotExists("A.md");
s.assertFileCount(1);
s.assertAnyFileContains("edit from 0", "edit from 1");
} },
],
};

View file

@ -1,52 +0,0 @@
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 }
]
};

View file

@ -1,82 +0,0 @@
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 }
]
};

View file

@ -1,80 +0,0 @@
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 }
]
};

View file

@ -1,46 +1,16 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const createRenameResponseSkipsFileTest: TestDefinition = {
name: "Create Then Immediate Rename — File Not Lost",
description: description:
"Client creates a file online then immediately renames it. " + "Client 0 creates a file online then immediately renames it. " +
"The create response arrives at the original path. " + "Client 1 must receive the file content at the renamed path.",
"The other client must receive the file content.",
clients: 2, clients: 2,
steps: [ steps: [
// Both clients online
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Client 0 creates doc.md while online (HTTP request fires immediately)
{ {
type: "create", type: "create",
client: 0, client: 0,
@ -48,7 +18,6 @@ export const createRenameResponseSkipsFileTest: TestDefinition = {
content: "the-content" content: "the-content"
}, },
// Immediately rename — the create request is already in-flight
{ {
type: "rename", type: "rename",
client: 0, client: 0,
@ -56,12 +25,10 @@ export const createRenameResponseSkipsFileTest: TestDefinition = {
newPath: "renamed.md" newPath: "renamed.md"
}, },
// Let everything sync
{ type: "sync" }, { type: "sync" },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Both clients must have the content (at whatever path) { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertAnyFileContains("the-content") }
{ type: "assert-consistent", verify: verifyBothClientsHaveContent }
] ]
}; };

View file

@ -1,33 +0,0 @@
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" }
]
};

View file

@ -0,0 +1,24 @@
import type { TestDefinition } from "../test-definition";
export const deleteByOtherClientThenRecreateTest: TestDefinition = {
description:
"Client 1 deletes a file and the delete propagates. Then client 0 " +
"creates a new file at the same path. Both clients must have the file.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "A.md", content: "original" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "delete", client: 1, path: "A.md" },
{ type: "barrier" },
{ type: "assert-consistent", verify: (s) => s.assertFileNotExists("A.md") },
{ type: "create", client: 0, path: "A.md", content: "recreated by client 0" },
{ type: "barrier" },
{ type: "assert-consistent", verify: (s) => s.assertContent("A.md", "recreated by client 0") },
],
};

View file

@ -1,34 +1,9 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const deleteDuringPendingCreateTest: TestDefinition = {
name: "Delete During Pending Create (Server Paused)",
description: description:
"Client creates a file, server is paused so the create request stalls. " + "Client 0 creates a file while the server is paused, then deletes it before the server resumes. " +
"Client then deletes the file while the create is in-flight. When the " + "After resume, the file should end up deleted on both clients.",
"server resumes, the create succeeds but the file should still end up " +
"deleted on both clients.",
clients: 2, clients: 2,
steps: [ steps: [
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
@ -36,10 +11,8 @@ export const deleteDuringPendingCreateTest: TestDefinition = {
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Pause server so the create request stalls
{ type: "pause-server" }, { type: "pause-server" },
// Client 0 creates a file (HTTP request will stall)
{ {
type: "create", type: "create",
client: 0, client: 0,
@ -47,19 +20,12 @@ export const deleteDuringPendingCreateTest: TestDefinition = {
content: "this will be deleted" 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" }, { type: "delete", client: 0, path: "ephemeral.md" },
// Resume server — the create request completes, then delete follows
{ type: "resume-server" }, { type: "resume-server" },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// File should be gone on both clients { type: "assert-consistent", verify: (s) => s.assertFileCount(0).assertFileNotExists("ephemeral.md") }
{ type: "assert-not-exists", client: 0, path: "ephemeral.md" },
{ type: "assert-not-exists", client: 1, path: "ephemeral.md" },
{ type: "assert-consistent", verify: verifyNoFiles }
] ]
}; };

View file

@ -1,48 +1,21 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const deleteRecreateConcurrentUpdateTest: TestDefinition = {
name: "Delete + Recreate with Concurrent Remote Update",
description: description:
"Client 0 deletes A.md and recreates it with new content while offline. " + "Client 0 deletes and recreates A.md with new content while offline. Client 1 updates A.md concurrently. " +
"Client 1 (online) updates A.md with different content. When Client 0 " + "After client 0 reconnects, both clients must converge with client 0's recreated content preserved.",
"reconnects, the system must reconcile the delete-recreate with the " +
"concurrent update. Both clients must converge.",
clients: 2, clients: 2,
steps: [ steps: [
// Setup
{ type: "create", client: 0, path: "A.md", content: "original" }, { type: "create", client: 0, path: "A.md", content: "original" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Client 0 goes offline, deletes and recreates
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
{ type: "delete", client: 0, path: "A.md" }, { type: "delete", client: 0, path: "A.md" },
{ type: "create", client: 0, path: "A.md", content: "recreated by client 0" }, { type: "create", client: 0, path: "A.md", content: "recreated by client 0" },
// Client 1 updates the same file concurrently
{ {
type: "update", type: "update",
client: 1, client: 1,
@ -51,12 +24,10 @@ export const deleteRecreateConcurrentUpdateTest: TestDefinition = {
}, },
{ type: "sync", client: 1 }, { type: "sync", client: 1 },
// Client 0 reconnects
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Both clients must converge { type: "assert-consistent", verify: (s) => s.assertFileExists("A.md").assertContains("A.md", "recreated") }
{ type: "assert-consistent", verify: verifyConvergence }
] ]
}; };

View file

@ -1,55 +1,11 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const deleteRecreateDifferentContentTest: TestDefinition = {
name: "Delete + Recreate Same Path While Other Client Edits",
description: description:
"Client 0 deletes and recreates A.md with new content while " + "Client 0 deletes and recreates A.md with new content offline while client 1 edits A.md offline. " +
"Client 1 edits A.md. The coalesced delete+create should produce " + "Both clients should converge with content from both sides merged.",
"correct behavior and both clients should converge.",
clients: 2, clients: 2,
steps: [ steps: [
// Setup: create A.md
{ {
type: "create", type: "create",
client: 0, client: 0,
@ -61,11 +17,9 @@ export const deleteRecreateDifferentContentTest: TestDefinition = {
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Both go offline
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 }, { type: "disable-sync", client: 1 },
// Client 0: delete and recreate with new content
{ type: "delete", client: 0, path: "A.md" }, { type: "delete", client: 0, path: "A.md" },
{ {
type: "create", type: "create",
@ -74,7 +28,6 @@ export const deleteRecreateDifferentContentTest: TestDefinition = {
content: "brand new content" content: "brand new content"
}, },
// Client 1: edit the same file
{ {
type: "update", type: "update",
client: 1, client: 1,
@ -82,13 +35,12 @@ export const deleteRecreateDifferentContentTest: TestDefinition = {
content: "edit from client 1" content: "edit from client 1"
}, },
// Reconnect both
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "sync", client: 0 }, { type: "sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-consistent", verify: verifyFinalState } { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("A.md", "brand new content", "edit from client 1") }
] ]
}; };

View file

@ -1,21 +1,18 @@
import type { TestDefinition } from "../test-definition"; import type { TestDefinition } from "../test-definition";
export const deleteRecreateSamePathTest: TestDefinition = { export const deleteRecreateSamePathTest: TestDefinition = {
name: "Delete Then Recreate at Same Path",
description: description:
"Client 0 creates A.md, syncs. Then deletes A.md and creates a new A.md " + "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.", "with different content. Both clients should converge on the new content.",
clients: 2, clients: 2,
steps: [ steps: [
// Setup: create and sync A.md
{ type: "create", client: 0, path: "A.md", content: "version 1" }, { type: "create", client: 0, path: "A.md", content: "version 1" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-content", client: 1, path: "A.md", content: "version 1" }, { type: "assert-consistent", verify: (s) => s.assertContent("A.md", "version 1") },
// Client 0 deletes then recreates A.md with new content
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
{ type: "delete", client: 0, path: "A.md" }, { type: "delete", client: 0, path: "A.md" },
{ type: "create", client: 0, path: "A.md", content: "version 2" }, { type: "create", client: 0, path: "A.md", content: "version 2" },
@ -23,21 +20,6 @@ export const deleteRecreateSamePathTest: TestDefinition = {
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Both clients should have the new content { type: "assert-consistent", verify: (s) => s.assertContent("A.md", "version 2") }
{ 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" }
] ]
}; };

View file

@ -1,71 +1,34 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const deleteRenameConflictTest: TestDefinition = {
name: "Delete vs Rename Conflict",
description: description:
"Client 0 deletes A.md while Client 1 (offline) renames A.md to C.md. " + "Client 0 deletes A.md while client 1 renames A.md to C.md offline. " +
"When Client 1 reconnects, the system must reconcile the conflicting " + "After client 1 reconnects, both clients should converge to the same state.",
"operations. Both clients should converge to the same state.",
clients: 2, clients: 2,
steps: [ 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: "A.md", content: "content-a" },
{ type: "create", client: 0, path: "B.md", content: "content-b" }, { type: "create", client: 0, path: "B.md", content: "content-b" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-exists", client: 1, path: "A.md" }, { type: "assert-consistent", verify: (s) => s.assertFileExists("A.md").assertFileExists("B.md") },
{ type: "assert-exists", client: 1, path: "B.md" },
// Client 1 goes offline
{ type: "disable-sync", client: 1 }, { type: "disable-sync", client: 1 },
// Client 0 deletes A.md and syncs
{ type: "delete", client: 0, path: "A.md" }, { type: "delete", client: 0, path: "A.md" },
{ type: "sync", client: 0 }, { type: "sync", client: 0 },
// Client 1 (offline) renames A.md to C.md
{ type: "rename", client: 1, oldPath: "A.md", newPath: "C.md" }, { type: "rename", client: 1, oldPath: "A.md", newPath: "C.md" },
// Client 1 reconnects
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync", client: 1 }, { type: "sync", client: 1 },
{ type: "barrier" }, { type: "barrier" },
// Both clients must converge — the key invariant is consistency. { type: "assert-consistent", verify: (s) => {
// B.md should still exist on both (unaffected by the conflict). s.assertContent("B.md", "content-b");
{ type: "assert-exists", client: 0, path: "B.md" }, s.assertFileNotExists("A.md");
{ type: "assert-exists", client: 1, path: "B.md" }, s.ifFileExists("C.md", (s) => s.assertContent("C.md", "content-a"));
{ type: "assert-consistent", verify: verifyConflictResolution } } },
] ]
}; };

View file

@ -1,42 +1,11 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 offlineonline 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 = { export const doubleOfflineCycleTest: TestDefinition = {
name: "Double Offline Cycle",
description: description:
"Client 0 goes offline, edits, comes online, syncs. Then goes " + "Client 0 goes through three offline-edit-reconnect cycles. " +
"offline again, edits more, comes online again. Both offline edits " + "Each offline edit must propagate to client 1 after reconnection.",
"must propagate to Client 1. Tests that runningReconciliation is " +
"properly cleared between cycles.",
clients: 2, clients: 2,
steps: [ steps: [
// Setup: create and sync
{ {
type: "create", type: "create",
client: 0, client: 0,
@ -47,14 +16,8 @@ export const doubleOfflineCycleTest: TestDefinition = {
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ { type: "assert-consistent", verify: (s) => s.assertContent("doc.md", "initial") },
type: "assert-content",
client: 1,
path: "doc.md",
content: "initial"
},
// First offline cycle: edit
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
{ {
type: "update", type: "update",
@ -63,18 +26,11 @@ export const doubleOfflineCycleTest: TestDefinition = {
content: "first edit" content: "first edit"
}, },
// Come online, sync first edit
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ { type: "assert-consistent", verify: (s) => s.assertContent("doc.md", "first edit") },
type: "assert-content",
client: 1,
path: "doc.md",
content: "first edit"
},
// Second offline cycle: edit again
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
{ {
type: "update", type: "update",
@ -83,18 +39,11 @@ export const doubleOfflineCycleTest: TestDefinition = {
content: "second edit" content: "second edit"
}, },
// Come online, sync second edit
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ { type: "assert-consistent", verify: (s) => s.assertContent("doc.md", "second edit") },
type: "assert-content",
client: 1,
path: "doc.md",
content: "second edit"
},
// Third offline cycle: edit once more
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
{ {
type: "update", type: "update",
@ -103,10 +52,9 @@ export const doubleOfflineCycleTest: TestDefinition = {
content: "third edit" content: "third edit"
}, },
// Come online, sync third edit
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-consistent", verify: verifyAllEdits } { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("doc.md", "third edit") }
] ]
}; };

View file

@ -1,34 +1,11 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const failedVfsMoveFallsBackTest: TestDefinition = {
name: "Rename Overwrite — A.md Renamed to Occupied B.md",
description: description:
"File A is renamed to B's path (overwriting B). Both clients " + "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.", "should converge on a single file at B.md with A's content.",
clients: 2, clients: 2,
steps: [ steps: [
// Setup: create two files
{ type: "create", client: 0, path: "A.md", content: "content A" }, { 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: "B.md", content: "content B" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
@ -36,12 +13,10 @@ export const failedVfsMoveFallsBackTest: TestDefinition = {
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Client 0 renames A.md to B.md (overwrite)
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Both clients should have only B.md { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("B.md", "content A") }
{ type: "assert-consistent", verify: verifyOneFile }
] ]
}; };

View file

@ -1,55 +1,24 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const idempotencyAfterServerPauseTest: TestDefinition = {
name: "Idempotency Key Prevents Duplicates After Server Pause",
description: description:
"Client 0 creates a file. The server is paused mid-response (SIGSTOP), " + "Client 0 creates a file, then the server is paused mid-response. " +
"so the client's HTTP request stalls. When the server resumes, the " + "After the server resumes, both clients must converge to a single copy of the file with no duplicates.",
"idempotency key should prevent duplicate documents from being created. " +
"Both clients must converge to a single copy of the file.",
clients: 2, clients: 2,
steps: [ steps: [
// Both clients online
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { 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: "create", client: 0, path: "doc.md", content: "important data" },
{ type: "pause-server" }, { 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" }, { type: "resume-server" },
// Sync and converge
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// There must be exactly one doc.md with the correct content — no { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("doc.md", "important data") }
// duplicates like "doc (1).md".
{ type: "assert-consistent", verify: verifyNoDuplicates }
] ]
}; };

View file

@ -1,39 +0,0 @@
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" }
]
};

View file

@ -1,48 +1,25 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const interruptedDeleteRetryTest: TestDefinition = {
name: "Interrupted Delete Is Retried After Reconnect",
description: description:
"A delete that was interrupted by a server pause/disconnect " + "Client 0 deletes a file, then the server is paused. " +
"should be retried when the connection is restored.", "After the server resumes, both clients should have zero files.",
clients: 2, clients: 2,
steps: [ steps: [
// Setup: create file, sync both
{ type: "create", client: 0, path: "doc.md", content: "to be deleted" }, { type: "create", client: 0, path: "doc.md", content: "to be deleted" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Client 0 deletes the file
{ type: "delete", client: 0, path: "doc.md" }, { type: "delete", client: 0, path: "doc.md" },
// Pause server to interrupt the delete request
{ type: "pause-server" }, { type: "pause-server" },
// Resume server - the interrupted delete should be retried
{ type: "resume-server" }, { type: "resume-server" },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Both clients should have 0 files { type: "assert-consistent", verify: (s) => s.assertFileCount(0) },
{ type: "assert-consistent", verify: verifyNoFiles },
], ],
}; };

View file

@ -1,45 +1,9 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const keyMigrationEventDropTest: TestDefinition = {
name: "Key Migration Does Not Drop Local Updates",
description: description:
"Client creates a file and immediately updates it before the create " + "Client 0 creates a file and immediately updates it while the server is paused. " +
"is acknowledged. The queue key migrates from path-based to documentId. " + "After resume, both clients should have the updated content.",
"The local update should not be lost during key migration.",
clients: 2, clients: 2,
steps: [ steps: [
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
@ -47,10 +11,8 @@ export const keyMigrationEventDropTest: TestDefinition = {
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Pause server so create request stalls
{ type: "pause-server" }, { type: "pause-server" },
// Client 0 creates file, then immediately updates it
{ {
type: "create", type: "create",
client: 0, client: 0,
@ -64,12 +26,10 @@ export const keyMigrationEventDropTest: TestDefinition = {
content: "updated content" content: "updated content"
}, },
// Resume server — create completes, update should follow
{ type: "resume-server" }, { type: "resume-server" },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// The updated content should be on both clients, not the initial { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("A.md", "updated content") }
{ type: "assert-consistent", verify: verifyUpdatedContent }
] ]
}; };

View file

@ -1,54 +0,0 @@
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()
};

View file

@ -1,53 +1,16 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const localEditLostDuringCreateMergeTest: TestDefinition = {
name: "Local Edit Lost During Create-Merge Response",
description: description:
"When a create returns a MergingUpdate and the file was locally " + "Client 1 creates doc.md. Client 0 creates the same file offline, then connects with the server paused. " +
"edited between the request and response, the local edit must " + "Client 0 edits the file while the create is stalled. After resume, both clients' content must be merged.",
"not be lost by the 3-way merge.",
clients: 2, clients: 2,
steps: [ steps: [
// Client 1 creates doc.md while client 0 is offline
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync", client: 1 }, { type: "sync", client: 1 },
{ type: "create", client: 1, path: "doc.md", content: "from-client-1" }, { type: "create", client: 1, path: "doc.md", content: "from-client-1" },
{ type: "sync", client: 1 }, { type: "sync", client: 1 },
// Client 0 creates the same file offline (doesn't know about client 1's version)
{ {
type: "create", type: "create",
client: 0, client: 0,
@ -55,13 +18,10 @@ export const localEditLostDuringCreateMergeTest: TestDefinition = {
content: "from-client-0" content: "from-client-0"
}, },
// Pause server so client 0's create stalls mid-flight
{ type: "pause-server" }, { type: "pause-server" },
// Bring client 0 online — its create request will stall
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
// Client 0 updates the file WHILE the create is in-flight
{ {
type: "update", type: "update",
client: 0, client: 0,
@ -69,16 +29,12 @@ export const localEditLostDuringCreateMergeTest: TestDefinition = {
content: "local-edit-during-create" content: "local-edit-during-create"
}, },
// Resume server — create completes with MergingUpdate
{ type: "resume-server" }, { 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: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// The local edit must be preserved { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("doc.md", "from-client-1", "local-edit-during-create") }
{ type: "assert-consistent", verify: verifyLocalEditPreserved }
] ]
}; };

View file

@ -1,109 +1,45 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const mcCrossCreateRenameSameTargetTest: TestDefinition = {
name: "MC: Cross-Create then Rename to Same Target",
description: description:
"Client 0 creates X.md, Client 1 creates Y.md. Both sync. Client 0 renames " + "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 " + "X.md -> Z.md. Client 1 (offline) renames Y.md -> Z.md. Both must converge " +
"with both contents preserved via path deconfliction.", "with both contents preserved via path deconfliction.",
clients: 2, clients: 2,
steps: [ steps: [
// Phase 1: Both create files offline at different paths
{ type: "create", client: 0, path: "X.md", content: "content-x" }, { type: "create", client: 0, path: "X.md", content: "content-x" },
{ type: "create", client: 1, path: "Y.md", content: "content-y" }, { 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: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Verify both files exist on both clients {
{ type: "assert-exists", client: 0, path: "X.md" }, type: "assert-consistent",
{ type: "assert-exists", client: 0, path: "Y.md" }, verify: (s) => s.assertFileExists("X.md").assertFileExists("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 }, { 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: "rename", client: 0, oldPath: "X.md", newPath: "Z.md" },
{ type: "sync", client: 0 }, { type: "sync", client: 0 },
// Phase 4: Client 1 (offline) renames Y.md -> Z.md
{ type: "rename", client: 1, oldPath: "Y.md", newPath: "Z.md" }, { type: "rename", client: 1, oldPath: "Y.md", newPath: "Z.md" },
// Phase 5: Client 1 reconnects
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Both contents must be preserved, both clients consistent {
{ type: "assert-consistent", verify: verifyBothContentsPreserved } type: "assert-consistent",
verify: (s) => {
s.assertFileCount(2)
.assertFileNotExists("X.md")
.assertFileNotExists("Y.md")
.assertFileExists("Z.md")
.assertAnyFileContains("content-x", "content-y");
}
}
] ]
}; };

View file

@ -1,98 +1,37 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const mcDeleteThenOfflineRenameTest: TestDefinition = {
name: "MC: Delete Synced Then Offline Rename",
description: description:
"Client 0 creates A.md, both sync. Client 1 goes offline. Client 0 deletes " + "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. " + "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.", "Both must converge. C.md (unrelated) must be unaffected.",
clients: 2, clients: 2,
steps: [ 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: "A.md", content: "original" },
{ type: "create", client: 0, path: "C.md", content: "unrelated" }, { type: "create", client: 0, path: "C.md", content: "unrelated" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { 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 }, { type: "disable-sync", client: 1 },
// Phase 3: Client 0 deletes A.md and syncs
{ type: "delete", client: 0, path: "A.md" }, { type: "delete", client: 0, path: "A.md" },
{ type: "sync", client: 0 }, { 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" }, { type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
// Phase 5: Client 1 reconnects
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Both must converge — key assertions {
{ type: "assert-consistent", verify: verifyState } type: "assert-consistent",
verify: (s) => {
s.assertContent("C.md", "unrelated")
.assertFileNotExists("A.md");
s.ifFileExists("B.md", (s) => s.assertContent("B.md", "original"));
}
}
] ]
}; };

View file

@ -1,43 +1,6 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const mcMultiDeleteOfflineRenameTest: TestDefinition = {
name: "MC: Multi-File Delete + Offline Rename",
description: description:
"Client 0 creates 5 files. Client 1 deletes 2 while Client 0 (offline) " + "Client 0 creates 5 files. Client 1 deletes 2 while Client 0 (offline) " +
"renames one of the deleted files. Both must converge.", "renames one of the deleted files. Both must converge.",
@ -53,23 +16,28 @@ export const mcMultiDeleteOfflineRenameTest: TestDefinition = {
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Client 0 goes offline
{ type: "disable-sync", client: 0 }, { 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-2.md" },
{ type: "delete", client: 1, path: "file-4.md" }, { type: "delete", client: 1, path: "file-4.md" },
{ type: "sync", client: 1 }, { type: "sync", client: 1 },
// Client 0 (offline) renames file-2
{ type: "rename", client: 0, oldPath: "file-2.md", newPath: "renamed.md" }, { type: "rename", client: 0, oldPath: "file-2.md", newPath: "renamed.md" },
// Client 0 reconnects
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Both must converge {
{ type: "assert-consistent", verify: verifyState } type: "assert-consistent",
verify: (s) => {
s.assertFileExists("file-1.md")
.assertFileExists("file-3.md")
.assertFileExists("file-5.md")
.assertFileNotExists("file-2.md")
.assertFileNotExists("file-4.md");
s.ifFileExists("renamed.md", (s) => s.assertContent("renamed.md", "content-2"));
}
}
] ]
}; };

View file

@ -1,39 +1,11 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const mcThreeClientRenameOfflineUpdateTest: TestDefinition = {
name: "MC: Three-Client Rename + Offline Update",
description: description:
"Client 0 creates A.md. Client 1 renames to B.md. Client 2 (offline) " + "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.", "updates A.md. All three converge with updated content at B.md.",
clients: 3, clients: 3,
steps: [ steps: [
// Phase 1: Client 0 creates A.md, everyone syncs
{ type: "create", client: 0, path: "A.md", content: "original" }, { type: "create", client: 0, path: "A.md", content: "original" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
@ -41,26 +13,18 @@ export const mcThreeClientRenameOfflineUpdateTest: TestDefinition = {
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Phase 2: Client 2 goes offline
{ type: "disable-sync", client: 2 }, { 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: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
{ type: "sync", client: 1 }, { type: "sync", client: 1 },
{ type: "sync", client: 0 }, { 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" }, { type: "update", client: 2, path: "A.md", content: "updated-by-client-2" },
// Phase 5: Client 2 reconnects
{ type: "enable-sync", client: 2 }, { type: "enable-sync", client: 2 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// All three must converge { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertFileNotExists("A.md").assertContains("B.md", "updated-by-client-2") }
{ type: "assert-consistent", verify: verifyState }
] ]
}; };

View file

@ -1,35 +1,9 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const migrateKeyPreservesExistingTest: TestDefinition = {
name: "Key Migration Preserves Existing Queue State",
description: description:
"When migrateKey is called and the new key already has queued " + "Client 0 creates a file and immediately updates it while the server is paused. " +
"events, the existing events must not be silently dropped.", "After resume, the update must not be lost.",
clients: 2, clients: 2,
steps: [ steps: [
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
@ -37,10 +11,8 @@ export const migrateKeyPreservesExistingTest: TestDefinition = {
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Pause server so create stalls
{ type: "pause-server" }, { type: "pause-server" },
// Client 0 creates and immediately updates
{ type: "create", client: 0, path: "A.md", content: "initial" }, { type: "create", client: 0, path: "A.md", content: "initial" },
{ {
type: "update", type: "update",
@ -49,11 +21,10 @@ export const migrateKeyPreservesExistingTest: TestDefinition = {
content: "updated by client 0" content: "updated by client 0"
}, },
// Resume server
{ type: "resume-server" }, { type: "resume-server" },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-consistent", verify: verifyContent } { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("A.md", "updated by client 0") }
] ]
}; };

View file

@ -1,53 +1,11 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const moveAndConcurrentRemoteUpdateTest: TestDefinition = {
name: "Move and Concurrent Remote Update",
description: description:
"Client 0 renames A.md to B.md while Client 1 updates A.md content. " + "Client 0 renames A.md to B.md offline while client 1 updates A.md. " +
"The coalescing table merges move + remote-update into just 'move', " + "After client 0 reconnects, both should have B.md with client 1's updated content.",
"potentially dropping the remote content update. Both clients should " +
"converge to B.md with Client 1's updated content.",
clients: 2, clients: 2,
steps: [ steps: [
// Setup: both clients share A.md
{ {
type: "create", type: "create",
client: 0, client: 0,
@ -58,18 +16,10 @@ export const moveAndConcurrentRemoteUpdateTest: TestDefinition = {
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { 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: "disable-sync", client: 0 },
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
// Client 1 updates A.md while Client 0 is offline
{ {
type: "update", type: "update",
client: 1, client: 1,
@ -78,12 +28,10 @@ export const moveAndConcurrentRemoteUpdateTest: TestDefinition = {
}, },
{ type: "sync", 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: "enable-sync", client: 0 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-consistent", verify: verifyBothContentAndPath } { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertFileNotExists("A.md").assertContains("B.md", "updated by client 1") }
] ]
}; };

View file

@ -1,78 +0,0 @@
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: AB, BC, CA. 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 CA)
* - B.md has A's old content (move from AB)
* - C.md has B's old content (move from BC)
*
* 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 }
]
};

View file

@ -1,104 +0,0 @@
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 BC 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 }
]
};

View file

@ -1,59 +1,37 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const movePreservesRemoteUpdateTest: TestDefinition = {
name: "Local Move Preserves Remote Content Update",
description: description:
"When a user renames a file and another client edits it concurrently, " + "Client 0 renames a file offline while client 1 edits it offline. " +
"the content update should not be lost.", "After both reconnect, the renamed file should contain client 1's edit.",
clients: 2, clients: 2,
steps: [ steps: [
// Setup
{ type: "create", client: 0, path: "doc.md", content: "line 1\nline 2" }, { type: "create", client: 0, path: "doc.md", content: "line 1\nline 2" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Both go offline
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 }, { type: "disable-sync", client: 1 },
// Client 0 renames, client 1 edits content
{ type: "rename", client: 0, oldPath: "doc.md", newPath: "renamed.md" }, { type: "rename", client: 0, oldPath: "doc.md", newPath: "renamed.md" },
{ type: "update", client: 1, path: "doc.md", content: "line 1\nclient 1 edit\nline 2" }, { type: "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: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-consistent", verify: verifyContentPreserved }, {
type: "assert-consistent",
verify: (s) => {
s.assertFileCount(1);
const content = Array.from(s.files.values())[0];
if (!content.includes("client 1 edit")) {
throw new Error(`Expected merged content to include "client 1 edit", got: "${content}"`);
}
}
},
], ],
}; };

View file

@ -1,71 +1,35 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const moveRemoteUpdateRevertsRenameTest: TestDefinition = {
name: "Remote Update + Local Move Coalescing May Revert Rename",
description: description:
"When a remote-update broadcast arrives and the user renames the " + "Client 1 updates a file while client 0 is offline. Client 0 reconnects and renames the file. " +
"file, the coalescing (remote-update + local-move = remote-update) " + "Both clients should converge with client 1's updated content.",
"discards the rename info. The force path may revert the rename " +
"by moving the file back to the server's path.",
clients: 2, clients: 2,
steps: [ steps: [
// Setup: both clients have doc.md
{ type: "create", client: 0, path: "doc.md", content: "original" }, { type: "create", client: 0, path: "doc.md", content: "original" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Client 1 updates the file content (broadcasts to client 0)
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
{ type: "update", client: 1, path: "doc.md", content: "updated by client 1" }, { type: "update", client: 1, path: "doc.md", content: "updated by client 1" },
{ type: "sync", 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: "enable-sync", client: 0 },
{ type: "rename", client: 0, oldPath: "doc.md", newPath: "renamed.md" }, { type: "rename", client: 0, oldPath: "doc.md", newPath: "renamed.md" },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Both should converge {
{ type: "assert-consistent", verify: verifyState } type: "assert-consistent",
verify: (s) => {
s.assertFileCount(1);
const content = Array.from(s.files.values())[0];
if (content !== "updated by client 1") {
throw new Error(`Expected "updated by client 1", got: "${content}"`);
}
}
}
] ]
}; };

View file

@ -1,43 +1,11 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 (AB) 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 = { export const moveThenDeleteStalePathTest: TestDefinition = {
name: "Move Then Delete (Stale Path Fix)",
description: description:
"Client 0 creates A.md, syncs. Then renames A.md to B.md and " + "Client 0 renames A.md to B.md and immediately deletes B.md. " +
"immediately deletes B.md. The coalesced delete action has the " + "Both clients should end up with zero files.",
"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, clients: 2,
steps: [ steps: [
// Setup: create and sync
{ {
type: "create", type: "create",
client: 0, client: 0,
@ -48,25 +16,13 @@ export const moveThenDeleteStalePathTest: TestDefinition = {
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { 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: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
{ type: "delete", client: 0, path: "B.md" }, { type: "delete", client: 0, path: "B.md" },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Both clients should have 0 files { type: "assert-consistent", verify: (s) => s.assertFileCount(0).assertFileNotExists("A.md").assertFileNotExists("B.md") }
{ 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 }
] ]
}; };

View file

@ -1,53 +1,11 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const multiFileOperationsTest: TestDefinition = {
name: "Multi-File Operations",
description: description:
"Client 0 creates A.md, B.md, C.md. Both clients sync. Client 1 goes offline. " + "Client 0 deletes A.md while client 1 is offline. Client 1 updates B.md and renames A.md to D.md offline. " +
"Client 0 deletes A.md. Client 1 (offline) updates B.md and renames A.md to D.md. " + "After client 1 reconnects, both clients must converge with B.md updated and C.md intact.",
"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, clients: 2,
steps: [ steps: [
// Setup: create three files and sync
{ type: "create", client: 0, path: "A.md", content: "content-a" }, { 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: "B.md", content: "content-b" },
{ type: "create", client: 0, path: "C.md", content: "content-c" }, { type: "create", client: 0, path: "C.md", content: "content-c" },
@ -56,23 +14,26 @@ export const multiFileOperationsTest: TestDefinition = {
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Client 1 goes offline
{ type: "disable-sync", client: 1 }, { type: "disable-sync", client: 1 },
// Client 0 deletes A.md and syncs
{ type: "delete", client: 0, path: "A.md" }, { type: "delete", client: 0, path: "A.md" },
{ type: "sync", client: 0 }, { 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: "update", client: 1, path: "B.md", content: "updated by client 1" },
{ type: "rename", client: 1, oldPath: "A.md", newPath: "D.md" }, { type: "rename", client: 1, oldPath: "A.md", newPath: "D.md" },
// Client 1 reconnects
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync", client: 1 }, { type: "sync", client: 1 },
{ type: "barrier" }, { type: "barrier" },
// Verify convergence: B.md and C.md must exist. B.md must have update. {
{ type: "assert-consistent", verify: verifyState } type: "assert-consistent",
verify: (s) => {
s.assertContains("B.md", "updated")
.assertFileExists("C.md")
.assertFileNotExists("A.md");
s.ifFileExists("D.md", (s) => s.assertContent("D.md", "content-a"));
}
}
] ]
}; };

View file

@ -1,43 +0,0 @@
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" }
]
};

View file

@ -1,41 +1,6 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const offlineConcurrentRenamesTest: TestDefinition = {
name: "Offline Concurrent Renames of Same File",
description: description:
"Client 0 creates A.md and syncs to both clients. Both clients go offline. " + "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. " + "Client 0 renames A.md to B.md. Client 1 renames A.md to C.md. " +
@ -43,24 +8,19 @@ export const offlineConcurrentRenamesTest: TestDefinition = {
"agree on the final state and the content must not be lost.", "agree on the final state and the content must not be lost.",
clients: 2, clients: 2,
steps: [ steps: [
// Setup: create A.md and sync to both clients
{ type: "create", client: 0, path: "A.md", content: "shared-content" }, { type: "create", client: 0, path: "A.md", content: "shared-content" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ {
type: "assert-content", type: "assert-consistent",
client: 1, verify: (s) => s.assertContent("A.md", "shared-content")
path: "A.md",
content: "shared-content"
}, },
// Both clients go offline
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 }, { type: "disable-sync", client: 1 },
// Client 0 renames A.md -> B.md
{ {
type: "rename", type: "rename",
client: 0, client: 0,
@ -68,7 +28,6 @@ export const offlineConcurrentRenamesTest: TestDefinition = {
newPath: "B.md" newPath: "B.md"
}, },
// Client 1 renames A.md -> C.md
{ {
type: "rename", type: "rename",
client: 1, client: 1,
@ -76,17 +35,24 @@ export const offlineConcurrentRenamesTest: TestDefinition = {
newPath: "C.md" newPath: "C.md"
}, },
// Both reconnect
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// A.md must be gone from both {
{ type: "assert-not-exists", client: 0, path: "A.md" }, type: "assert-consistent",
{ type: "assert-not-exists", client: 1, path: "A.md" }, verify: (s) => {
s.assertFileNotExists("A.md")
// Both must converge to the same state with content preserved .assertFileCount(1)
{ type: "assert-consistent", verify: verifyConvergence } .assertAnyFileContains("shared-content");
s.ifFileExists("B.md", (s) =>
s.assertContent("B.md", "shared-content")
);
s.ifFileExists("C.md", (s) =>
s.assertContent("C.md", "shared-content")
);
}
}
] ]
}; };

View file

@ -1,71 +0,0 @@
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 }
]
};

View file

@ -1,52 +1,11 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const offlineCreateSamePathMergeableTest: TestDefinition = {
name: "Offline Create Same Path — Mergeable Text",
description: description:
"Both clients create a file at the same path while offline with " + "Both clients create a file at the same path while offline with different text content. " +
"different text content. When both sync, the server should 3-way " + "After both sync, both clients must converge to a merged result containing both contributions.",
"merge the content and both clients should converge to the merged result.",
clients: 2, clients: 2,
steps: [ steps: [
// Both clients create at same path while offline
{ {
type: "create", type: "create",
client: 0, client: 0,
@ -60,14 +19,23 @@ export const offlineCreateSamePathMergeableTest: TestDefinition = {
content: "beta wrote this different line" 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: "enable-sync", client: 0 },
{ type: "sync", client: 0 }, { type: "sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-consistent", verify: verifyMergedContent } {
type: "assert-consistent",
verify: (s) =>
s
.assertFileCount(1)
.assertFileExists("notes.md")
.assertContains(
"notes.md",
"alpha wrote this line",
"beta wrote this different line"
)
}
] ]
}; };

View file

@ -1,46 +1,11 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const offlineDeleteRemoteRenameTest: TestDefinition = {
name: "Offline Delete + Concurrent Remote Rename",
description: description:
"Client 0 goes offline and deletes A.md locally. Meanwhile Client 1 " + "Client 0 deletes A.md offline while client 1 renames it to A_renamed.md. " +
"renames A.md to A_renamed.md and syncs. When Client 0 reconnects, " + "After client 0 reconnects, both clients must converge.",
"the offline reconciliation discovers A.md is missing locally but the " +
"server has it renamed. The system must converge consistently.",
clients: 2, clients: 2,
steps: [ steps: [
// Setup
{ type: "create", client: 0, path: "A.md", content: "content-a" }, { 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: "B.md", content: "content-b" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
@ -48,11 +13,9 @@ export const offlineDeleteRemoteRenameTest: TestDefinition = {
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Client 0 goes offline and deletes A.md
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
{ type: "delete", client: 0, path: "A.md" }, { type: "delete", client: 0, path: "A.md" },
// Client 1 renames A.md -> A_renamed.md
{ {
type: "rename", type: "rename",
client: 1, client: 1,
@ -61,12 +24,19 @@ export const offlineDeleteRemoteRenameTest: TestDefinition = {
}, },
{ type: "sync", client: 1 }, { type: "sync", client: 1 },
// Client 0 reconnects
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Both clients must converge {
{ type: "assert-consistent", verify: verifyConvergence } type: "assert-consistent",
verify: (s) => {
s.assertFileNotExists("A.md")
.assertContent("B.md", "content-b");
s.ifFileExists("A_renamed.md", (s) =>
s.assertContent("A_renamed.md", "content-a")
);
}
}
] ]
}; };

View file

@ -1,48 +1,10 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const offlineDeleteVsRemoteUpdateTest: TestDefinition = {
name: "Offline Delete vs Remote Update",
description: description:
"Client 0 deletes A.md while Client 1 updates A.md. Tests the " + "Client 0 deletes A.md offline while client 1 updates it. Both clients must converge.",
"coalescing of remote-update + local-delete and whether both " +
"clients converge to a consistent state.",
clients: 2, clients: 2,
steps: [ steps: [
// Setup: both clients share A.md
{ {
type: "create", type: "create",
client: 0, client: 0,
@ -54,17 +16,13 @@ export const offlineDeleteVsRemoteUpdateTest: TestDefinition = {
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ {
type: "assert-content", type: "assert-consistent",
client: 1, verify: (s) => s.assertContent("A.md", "original content")
path: "A.md",
content: "original content"
}, },
// Client 0 goes offline and deletes A.md
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
{ type: "delete", client: 0, path: "A.md" }, { type: "delete", client: 0, path: "A.md" },
// Client 1 updates A.md while Client 0 is offline
{ {
type: "update", type: "update",
client: 1, client: 1,
@ -73,12 +31,13 @@ export const offlineDeleteVsRemoteUpdateTest: TestDefinition = {
}, },
{ type: "sync", 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: "enable-sync", client: 0 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-consistent", verify: verifyConsistentState } {
type: "assert-consistent",
verify: (s) => s.assertFileCount(0)
}
] ]
}; };

View file

@ -1,56 +1,21 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const offlineEditRemoteRenameTest: TestDefinition = {
name: "Offline Edit + Remote Rename",
description: description:
"Client 0 goes offline and edits A.md. Meanwhile Client 1 renames " + "Client 0 edits A.md offline while client 1 renames A.md to B.md. " +
"A.md to B.md. When Client 0 reconnects, its edit should be applied " + "After client 0 reconnects, the edit must appear in B.md and A.md must not exist.",
"to B.md (the renamed path). The edit must not be lost and A.md must " +
"not exist.",
clients: 2, clients: 2,
steps: [ steps: [
// Setup: create and sync
{ type: "create", client: 0, path: "A.md", content: "original" }, { type: "create", client: 0, path: "A.md", content: "original" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ {
type: "assert-content", type: "assert-consistent",
client: 1, verify: (s) => s.assertContent("A.md", "original")
path: "A.md",
content: "original"
}, },
// Client 0 goes offline and edits
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
{ {
type: "update", type: "update",
@ -59,7 +24,6 @@ export const offlineEditRemoteRenameTest: TestDefinition = {
content: "edited by client 0" content: "edited by client 0"
}, },
// Client 1 renames A.md -> B.md while Client 0 is offline
{ {
type: "rename", type: "rename",
client: 1, client: 1,
@ -68,13 +32,17 @@ export const offlineEditRemoteRenameTest: TestDefinition = {
}, },
{ type: "sync", client: 1 }, { type: "sync", client: 1 },
// Client 0 reconnects — edit must be preserved at new path
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-not-exists", client: 0, path: "A.md" }, {
{ type: "assert-not-exists", client: 1, path: "A.md" }, type: "assert-consistent",
{ type: "assert-consistent", verify: verifyEditPreservedAtNewPath } verify: (s) =>
s
.assertFileNotExists("A.md")
.assertFileCount(1)
.assertContains("B.md", "edited by client 0")
}
] ]
}; };

View file

@ -1,49 +1,10 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const offlineEditThenMoveSameContentTest: TestDefinition = {
name: "Offline Move + Edit Creates False Hash Match",
description: description:
"A file is renamed and edited to have the same content as a deleted " + "A file is renamed and edited to match a deleted file's content. Both clients must converge despite the ambiguity.",
"file. Move detection may match against the wrong document. The " +
"system should still converge.",
clients: 2, clients: 2,
steps: [ steps: [
// Setup: create two files with different content
{ {
type: "create", type: "create",
client: 0, client: 0,
@ -61,16 +22,12 @@ export const offlineEditThenMoveSameContentTest: TestDefinition = {
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Client 0 goes offline
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
// Delete A.md
{ type: "delete", client: 0, path: "A.md" }, { type: "delete", client: 0, path: "A.md" },
// Rename B.md → C.md
{ type: "rename", client: 0, oldPath: "B.md", newPath: "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", type: "update",
client: 0, client: 0,
@ -78,11 +35,18 @@ export const offlineEditThenMoveSameContentTest: TestDefinition = {
content: "content A" content: "content A"
}, },
// Reconnect
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-consistent", verify: verifyFinalState } {
type: "assert-consistent",
verify: (s) =>
s
.assertFileNotExists("A.md")
.assertFileNotExists("B.md")
.assertContent("C.md", "content A")
.assertFileCount(1)
}
] ]
}; };

View file

@ -1,57 +1,12 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const offlineMixedOperationsTest: TestDefinition = {
name: "Offline Mixed Operations (Delete + Rename + Edit)",
description: description:
"Client 0 creates 3 files, syncs to both clients. Client 0 goes offline, " + "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. " + "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.", "When Client 0 reconnects, all three operations should propagate to Client 1.",
clients: 2, clients: 2,
steps: [ steps: [
// Setup: Client 0 creates 3 files and syncs
{ type: "create", client: 0, path: "file1.md", content: "content-1" }, { 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: "file2.md", content: "content-2" },
{ type: "create", client: 0, path: "file3.md", content: "content-3" }, { type: "create", client: 0, path: "file3.md", content: "content-3" },
@ -60,30 +15,17 @@ export const offlineMixedOperationsTest: TestDefinition = {
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Verify initial sync
{ {
type: "assert-content", type: "assert-consistent",
client: 1, verify: (s) =>
path: "file1.md", s
content: "content-1" .assertContent("file1.md", "content-1")
}, .assertContent("file2.md", "content-2")
{ .assertContent("file3.md", "content-3")
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 }, { type: "disable-sync", client: 0 },
// Client 0 performs three different offline operations
{ type: "delete", client: 0, path: "file1.md" }, { type: "delete", client: 0, path: "file1.md" },
{ {
type: "rename", type: "rename",
@ -98,16 +40,19 @@ export const offlineMixedOperationsTest: TestDefinition = {
content: "updated-content-3" content: "updated-content-3"
}, },
// Client 0 reconnects
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// All operations should have propagated {
{ type: "assert-not-exists", client: 1, path: "file1.md" }, type: "assert-consistent",
{ type: "assert-not-exists", client: 1, path: "file2.md" }, verify: (s) =>
{ type: "assert-exists", client: 1, path: "moved.md" }, s
{ type: "assert-exists", client: 1, path: "file3.md" }, .assertFileNotExists("file1.md")
{ type: "assert-consistent", verify: verifyFinalState } .assertFileNotExists("file2.md")
.assertContent("moved.md", "content-2")
.assertContent("file3.md", "updated-content-3")
.assertFileCount(2)
}
] ]
}; };

View file

@ -1,44 +1,11 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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(AB) + 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 = { export const offlineMoveThenRemoteDeleteTest: TestDefinition = {
name: "Offline Move + Remote Delete Convergence",
description: description:
"Client 0 renames A→B offline while Client 1 deletes A. " + "Client 0 renames A.md to B.md offline while client 1 deletes A.md. " +
"The move+delete coalescing may use a stale path. " + "Both clients must converge to having no files.",
"Both clients should converge to having no files.",
clients: 2, clients: 2,
steps: [ steps: [
// Setup: both have A.md
{ {
type: "create", type: "create",
client: 0, client: 0,
@ -50,24 +17,23 @@ export const offlineMoveThenRemoteDeleteTest: TestDefinition = {
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Client 0 goes offline, renames A→B
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, { 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: "delete", client: 1, path: "A.md" },
{ type: "sync", client: 1 }, { type: "sync", client: 1 },
// Client 0 reconnects — receives remote-delete while move is pending
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Both should converge to no files {
{ type: "assert-not-exists", client: 0, path: "A.md" }, type: "assert-consistent",
{ type: "assert-not-exists", client: 1, path: "A.md" }, verify: (s) =>
{ type: "assert-not-exists", client: 0, path: "B.md" }, s
{ type: "assert-not-exists", client: 1, path: "B.md" }, .assertFileNotExists("A.md")
{ type: "assert-consistent", verify: verifyNoFiles } .assertFileNotExists("B.md")
.assertFileCount(0)
}
] ]
}; };

View file

@ -1,72 +1,38 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const offlineMultipleEditsTest: TestDefinition = {
name: "Offline Multiple Edits Converge to Latest",
description: description:
"Client 0 creates a file and syncs. Client 0 goes offline, edits the file " + "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 " + "5 times with different content. When Client 0 reconnects, both clients " +
"must converge to the final version.", "must converge to the final version.",
clients: 2, clients: 2,
steps: [ steps: [
// Setup: create file and sync to both clients
{ type: "create", client: 0, path: "doc.md", content: "original" }, { type: "create", client: 0, path: "doc.md", content: "original" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ {
type: "assert-content", type: "assert-consistent",
client: 1, verify: (s) => s.assertContent("doc.md", "original")
path: "doc.md",
content: "original"
}, },
// Client 0 goes offline
{ type: "disable-sync", client: 0 }, { 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-1" },
{ type: "update", client: 0, path: "doc.md", content: "edit-2" }, { 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-3" },
{ type: "update", client: 0, path: "doc.md", content: "edit-4" }, { type: "update", client: 0, path: "doc.md", content: "edit-4" },
{ type: "update", client: 0, path: "doc.md", content: "edit-5-final" }, { 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: "enable-sync", client: 0 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Both clients should have the final version
{ {
type: "assert-content", type: "assert-consistent",
client: 0, verify: (s) =>
path: "doc.md", s.assertFileCount(1).assertContent("doc.md", "edit-5-final")
content: "edit-5-final" }
},
{
type: "assert-content",
client: 1,
path: "doc.md",
content: "edit-5-final"
},
{ type: "assert-consistent", verify: verifyOnlyLatestVersion }
] ]
}; };

View file

@ -1,60 +1,37 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const offlineRenameAndEditTest: TestDefinition = {
name: "Offline Rename and Edit",
description: description:
"Client 0 creates A.md and syncs. Client 0 goes offline, renames A.md " + "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 " + "to B.md, then edits B.md. When Client 0 reconnects, the rename and edit " +
"should both propagate to Client 1.", "should both propagate to Client 1.",
clients: 2, clients: 2,
steps: [ steps: [
// Setup: create and sync
{ type: "create", client: 0, path: "A.md", content: "original" }, { type: "create", client: 0, path: "A.md", content: "original" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-content", client: 1, path: "A.md", content: "original" }, {
type: "assert-consistent",
verify: (s) => s.assertContent("A.md", "original")
},
// Client 0 goes offline, renames and edits
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
{ type: "update", client: 0, path: "B.md", content: "edited after rename" }, { type: "update", client: 0, path: "B.md", content: "edited after rename" },
// Client 0 reconnects
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// A.md should be gone, B.md should have edited content {
{ type: "assert-not-exists", client: 0, path: "A.md" }, type: "assert-consistent",
{ type: "assert-not-exists", client: 1, path: "A.md" }, verify: (s) =>
{ type: "assert-consistent", verify: verifyContent } s
.assertFileNotExists("A.md")
.assertFileCount(1)
.assertContent("B.md", "edited after rename")
}
] ]
}; };

View file

@ -1,49 +1,22 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const offlineRenameRemoteCreateOldPathTest: TestDefinition = {
name: "Offline Rename + Remote Create at Old Path",
description: description:
"Client 0 renames X.md to Y.md while offline. Client 1 updates X.md " + "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 " + "(same document). When Client 0 reconnects, the rename and update " +
"should merge. Y.md should exist with Client 1's content.", "should merge. Y.md should exist with Client 1's content.",
clients: 2, clients: 2,
steps: [ steps: [
// Setup: create X.md and sync
{ type: "create", client: 0, path: "X.md", content: "original" }, { type: "create", client: 0, path: "X.md", content: "original" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ {
type: "assert-content", type: "assert-consistent",
client: 1, verify: (s) => s.assertContent("X.md", "original")
path: "X.md",
content: "original"
}, },
// Client 0 goes offline and renames
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
{ {
type: "rename", type: "rename",
@ -52,7 +25,6 @@ export const offlineRenameRemoteCreateOldPathTest: TestDefinition = {
newPath: "Y.md" newPath: "Y.md"
}, },
// Client 1 updates the same document at X.md
{ {
type: "update", type: "update",
client: 1, client: 1,
@ -61,12 +33,16 @@ export const offlineRenameRemoteCreateOldPathTest: TestDefinition = {
}, },
{ type: "sync", client: 1 }, { type: "sync", client: 1 },
// Client 0 reconnects — must detect move AND merge with update
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Both clients should converge: Y.md with Client 1's content {
{ type: "assert-consistent", verify: verifyResult } type: "assert-consistent",
verify: (s) =>
s
.assertFileCount(1)
.assertContains("Y.md", "updated-by-client-1")
}
] ]
}; };

View file

@ -1,48 +1,6 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const offlineUpdateBothThenDeleteOneTest: TestDefinition = {
name: "Offline Update Both Files Then Delete One",
description: description:
"Client 0 goes offline, updates A.md and B.md, then deletes B.md. " + "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 " + "Client 1 updates B.md while Client 0 is offline. When Client 0 " +
@ -50,7 +8,6 @@ export const offlineUpdateBothThenDeleteOneTest: TestDefinition = {
"consistently resolved (delete wins).", "consistently resolved (delete wins).",
clients: 2, clients: 2,
steps: [ steps: [
// Setup: create two files
{ {
type: "create", type: "create",
client: 0, client: 0,
@ -68,22 +25,15 @@ export const offlineUpdateBothThenDeleteOneTest: TestDefinition = {
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ {
type: "assert-content", type: "assert-consistent",
client: 1, verify: (s) =>
path: "A.md", s
content: "A original" .assertContent("A.md", "A original")
}, .assertContent("B.md", "B original")
{
type: "assert-content",
client: 1,
path: "B.md",
content: "B original"
}, },
// Client 0 goes offline
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
// Client 0 updates both files
{ {
type: "update", type: "update",
client: 0, client: 0,
@ -97,10 +47,8 @@ export const offlineUpdateBothThenDeleteOneTest: TestDefinition = {
content: "B updated by client 0" content: "B updated by client 0"
}, },
// Client 0 deletes B.md
{ type: "delete", client: 0, path: "B.md" }, { type: "delete", client: 0, path: "B.md" },
// Meanwhile Client 1 updates B.md
{ {
type: "update", type: "update",
client: 1, client: 1,
@ -109,11 +57,16 @@ export const offlineUpdateBothThenDeleteOneTest: TestDefinition = {
}, },
{ type: "sync", client: 1 }, { type: "sync", client: 1 },
// Client 0 comes online
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-consistent", verify: verifyFinalState } {
type: "assert-consistent",
verify: (s) =>
s
.assertContent("A.md", "A updated by client 0")
.assertFileNotExists("B.md")
}
] ]
}; };

View file

@ -0,0 +1,30 @@
import type { TestDefinition } from "../test-definition";
export const onlineCreateRenameConcurrentCreateOrphanTest: TestDefinition = {
description:
"Client 0 creates a binary file and renames it while offline, then reconnects and immediately deletes it. " +
"Both clients must converge to zero files.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "disable-sync", client: 0 },
{ type: "create", client: 0, path: "data.bin", content: "BINARY:offline-content" },
{ type: "rename", client: 0, oldPath: "data.bin", newPath: "moved.bin" },
{ type: "enable-sync", client: 0 },
{ type: "delete", client: 0, path: "moved.bin" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state) => {
state.assertFileCount(0);
}
}
]
};

View file

@ -0,0 +1,34 @@
import type { TestDefinition } from "../test-definition";
export const onlineDeleteRecreateRapidCycleTest: TestDefinition = {
description:
"A file is deleted and recreated multiple times by alternating clients while both are online. " +
"Both clients must converge after each cycle.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "A.md", content: "round 0" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "delete", client: 1, path: "A.md" },
{ type: "barrier" },
{ type: "create", client: 0, path: "A.md", content: "round 1" },
{ type: "barrier" },
{ type: "delete", client: 0, path: "A.md" },
{ type: "barrier" },
{ type: "create", client: 1, path: "A.md", content: "round 2" },
{ type: "barrier" },
{ type: "delete", client: 1, path: "A.md" },
{ type: "barrier" },
{ type: "create", client: 0, path: "A.md", content: "round 3" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) => s.assertContent("A.md", "round 3"),
},
],
};

View file

@ -0,0 +1,27 @@
import type { TestDefinition } from "../test-definition";
export const onlineEditVsDeleteConvergenceTest: TestDefinition = {
description:
"Both clients are online. Client 0 edits a file while client 1 " +
"deletes it. The clients must converge to the same state.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "A.md", content: "original" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "update", client: 0, path: "A.md", content: "edited by client 0" },
{ type: "delete", client: 1, path: "A.md" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state) => {
state.ifFileExists("A.md", (s) =>
s.assertContainsAny("A.md", "edited by client 0")
);
}
},
],
};

View file

@ -1,48 +1,11 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const overlappingEditsSameSectionTest: TestDefinition = {
name: "Overlapping Edits in Same Section",
description: description:
"Both clients edit the same document by adding content to different " + "Both clients go offline and edit different parts of the same document. " +
"parts of the same section. Client 0 adds a line after the heading, " + "After both reconnect, both edits must be preserved without data loss.",
"Client 1 adds a line before the footer. The 3-way merge should " +
"preserve both edits without data loss.",
clients: 2, clients: 2,
steps: [ steps: [
// Setup: create a multi-line document
{ {
type: "create", type: "create",
client: 0, client: 0,
@ -54,11 +17,9 @@ export const overlappingEditsSameSectionTest: TestDefinition = {
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Both clients go offline and edit the same document
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 }, { type: "disable-sync", client: 1 },
// Client 0: add line after heading
{ {
type: "update", type: "update",
client: 0, client: 0,
@ -66,7 +27,6 @@ export const overlappingEditsSameSectionTest: TestDefinition = {
content: "# Title\nalpha addition\n\nfooter" content: "# Title\nalpha addition\n\nfooter"
}, },
// Client 1: add line before footer
{ {
type: "update", type: "update",
client: 1, client: 1,
@ -74,13 +34,16 @@ export const overlappingEditsSameSectionTest: TestDefinition = {
content: "# Title\n\nbeta addition\nfooter" content: "# Title\n\nbeta addition\nfooter"
}, },
// Both reconnect
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Both edits should be merged {
{ type: "assert-consistent", verify: verifyMergedEdits } type: "assert-consistent",
verify: (s) =>
s.assertFileCount(1)
.assertContains("doc.md", "# Title", "alpha addition", "beta addition", "footer"),
}
] ]
}; };

View file

@ -1,79 +1,32 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const queueResetLosesCoalescedLocalEditTest: TestDefinition = {
name: "Queue Reset Preserves Coalesced Local Edits",
description: description:
"When a local-update is coalesced into a remote-update action " + "Client 1 edits a shared file, then client 0 also edits it and immediately disconnects. " +
"and then the WebSocket disconnects, the queue reset removes " + "After client 0 reconnects, both edits must be preserved.",
"the remote-update — potentially losing the local edit. " +
"The filesystem scan on reconnect should recover it.",
clients: 2, clients: 2,
steps: [ steps: [
// Setup: both clients have doc.md
{ type: "create", client: 0, path: "doc.md", content: "original" }, { type: "create", client: 0, path: "doc.md", content: "original" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { 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: "update", client: 1, path: "doc.md", content: "from client 1" },
{ type: "sync", 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" }, { 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 }, { 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: "enable-sync", client: 0 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Both must converge with the local edit preserved {
{ type: "assert-consistent", verify: verifyEditSurvived } type: "assert-consistent",
verify: (s) =>
s.assertFileCount(1).assertContains("doc.md", "from client 0", "from client 1"),
}
] ]
}; };

View file

@ -1,42 +1,9 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const rapidCreateUpdateDeleteCycleTest: TestDefinition = {
name: "Rapid Create-Update-Delete-Create Cycle",
description: description:
"Client 0 rapidly creates, updates, deletes, then re-creates a file. " + "Client 0 rapidly creates, updates, deletes, then re-creates a file while the server is paused. " +
"The event coalescing should correctly reduce this to a single create " + "After the server resumes, client 1 must see only the final file.",
"of the final content. Client 1 should see only the final file.",
clients: 2, clients: 2,
steps: [ steps: [
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
@ -44,10 +11,8 @@ export const rapidCreateUpdateDeleteCycleTest: TestDefinition = {
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Pause server so all operations coalesce before being processed
{ type: "pause-server" }, { type: "pause-server" },
// Rapid cycle: create → update → delete
{ {
type: "create", type: "create",
client: 0, client: 0,
@ -62,7 +27,6 @@ export const rapidCreateUpdateDeleteCycleTest: TestDefinition = {
}, },
{ type: "delete", client: 0, path: "cycle.md" }, { type: "delete", client: 0, path: "cycle.md" },
// Re-create with final content
{ {
type: "create", type: "create",
client: 0, client: 0,
@ -70,11 +34,13 @@ export const rapidCreateUpdateDeleteCycleTest: TestDefinition = {
content: "final creation" content: "final creation"
}, },
// Resume server
{ type: "resume-server" }, { type: "resume-server" },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-consistent", verify: verifyFinalState } {
type: "assert-consistent",
verify: (s) => s.assertFileCount(1).assertContent("cycle.md", "final creation"),
}
] ]
}; };

View file

@ -0,0 +1,44 @@
import type { TestDefinition } from "../test-definition";
export const rapidEditDeleteOnlineConvergenceTest: TestDefinition = {
description:
"Client 0 rapidly edits multiple files while client 1 deletes some of them, all while both are online. " +
"Both clients must converge to a consistent state.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "A.md", content: "content A" },
{ type: "create", client: 0, path: "B.md", content: "content B" },
{ type: "create", client: 0, path: "C.md", content: "content C" },
{ type: "create", client: 0, path: "D.md", content: "content D" },
{ type: "create", client: 0, path: "E.md", content: "content E" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "update", client: 0, path: "A.md", content: "A edit 1" },
{ type: "update", client: 0, path: "B.md", content: "B edit 1" },
{ type: "update", client: 0, path: "C.md", content: "C edit 1" },
{ type: "delete", client: 1, path: "A.md" },
{ type: "delete", client: 1, path: "C.md" },
{ type: "delete", client: 1, path: "E.md" },
{ type: "update", client: 0, path: "A.md", content: "A edit 2" },
{ type: "update", client: 0, path: "B.md", content: "B edit 2" },
{ type: "update", client: 0, path: "C.md", content: "C edit 2" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) => {
for (const [path, content] of s.files) {
for (const clientFiles of s.clientFiles) {
if (clientFiles.has(path) && clientFiles.get(path) !== content) {
throw new Error(
`Content mismatch for ${path}: "${clientFiles.get(path)}" vs "${content}"`
);
}
}
}
},
},
],
};

View file

@ -1,36 +0,0 @@
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" }
]
};

View file

@ -1,37 +1,11 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const rapidUpdatesAfterMergeTest: TestDefinition = {
name: "Rapid Sequential Updates After Concurrent Merge",
description: description:
"Both clients create the same file (triggering a merge). After merge " + "Both clients create the same file offline, triggering a merge on sync. " +
"completes, Client 0 rapidly sends three updates in succession. Each " + "Client 0 then rapidly sends three updates. Both clients must converge to the final update.",
"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, clients: 2,
steps: [ steps: [
// Both create at same path (triggers merge)
{ type: "create", client: 0, path: "doc.md", content: "from client 0" }, { type: "create", client: 0, path: "doc.md", content: "from client 0" },
{ type: "create", client: 1, path: "doc.md", content: "from client 1" }, { type: "create", client: 1, path: "doc.md", content: "from client 1" },
@ -40,7 +14,6 @@ export const rapidUpdatesAfterMergeTest: TestDefinition = {
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// After merge, Client 0 sends rapid sequential updates
{ {
type: "update", type: "update",
client: 0, client: 0,
@ -65,10 +38,11 @@ export const rapidUpdatesAfterMergeTest: TestDefinition = {
}, },
{ type: "sync", client: 0 }, { type: "sync", client: 0 },
// Wait for propagation
{ type: "barrier" }, { type: "barrier" },
// Both clients must converge with update 3 {
{ type: "assert-consistent", verify: verifyFinalState } type: "assert-consistent",
verify: (s) => s.assertFileCount(1).assertContains("doc.md", "update 3"),
}
] ]
}; };

View file

@ -1,65 +1,38 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const recentlyDeletedClearedOnReconnectTest: TestDefinition = {
name: "Recently Deleted IDs Cleared On Reconnect",
description: description:
"After a client deletes a document and reconnects, it should " + "After a client deletes a document and reconnects, it should " +
"accept new documents from other clients even if they happen to " + "accept new documents from other clients even if they happen to " +
"arrive at the same path as the deleted document.", "arrive at the same path as the deleted document.",
clients: 2, clients: 2,
steps: [ steps: [
// Setup: both online
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Client 0 creates and syncs a file
{ type: "create", client: 0, path: "doc.md", content: "original" }, { type: "create", client: 0, path: "doc.md", content: "original" },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Client 0 deletes the file
{ type: "delete", client: 0, path: "doc.md" }, { type: "delete", client: 0, path: "doc.md" },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Client 0 goes offline
{ type: "disable-sync", client: 0 }, { 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: "create", client: 1, path: "doc.md", content: "new content from client 1" },
{ type: "sync", client: 1 }, { type: "sync", client: 1 },
// Client 0 comes back online - should receive the new file
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-consistent", verify: verifyFileExists }, {
type: "assert-consistent",
verify: (s) =>
s.assertFileCount(1).assertContent("doc.md", "new content from client 1"),
},
], ],
}; };

View file

@ -1,40 +1,23 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const renameChainThenDeleteTest: TestDefinition = {
name: "Rename Chain Then Delete (Offline Catchup)",
description: description:
"Client 0 creates X.md and syncs. Client 1 goes offline. Client 0 " + "Client 0 renames X.md to Y.md to Z.md, then deletes Z.md while client 1 is offline. " +
"renames X.md -> Y.md -> Z.md, then deletes Z.md. Client 1 reconnects " + "After client 1 reconnects, both clients must have no files.",
"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, clients: 2,
steps: [ steps: [
// Setup: create and sync
{ type: "create", client: 0, path: "X.md", content: "chain-content" }, { type: "create", client: 0, path: "X.md", content: "chain-content" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ {
type: "assert-content", type: "assert-consistent",
client: 1, verify: (s) => s.assertContent("X.md", "chain-content"),
path: "X.md",
content: "chain-content"
}, },
// Client 1 goes offline
{ type: "disable-sync", client: 1 }, { type: "disable-sync", client: 1 },
// Client 0: rename chain X -> Y -> Z, then delete Z
{ {
type: "rename", type: "rename",
client: 0, client: 0,
@ -52,12 +35,10 @@ export const renameChainThenDeleteTest: TestDefinition = {
{ type: "delete", client: 0, path: "Z.md" }, { type: "delete", client: 0, path: "Z.md" },
{ type: "sync", client: 0 }, { type: "sync", client: 0 },
// Client 1 reconnects — should detect X.md's document is deleted
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Both clients must agree: no files { type: "assert-consistent", verify: (s) => s.assertFileCount(0) }
{ type: "assert-consistent", verify: verifyAllDeleted }
] ]
}; };

View file

@ -1,7 +1,6 @@
import type { TestDefinition } from "../test-definition"; import type { TestDefinition } from "../test-definition";
export const renameChainTest: TestDefinition = { export const renameChainTest: TestDefinition = {
name: "Rename Chain",
description: description:
"Client 0 (offline) creates A.md, renames to B.md, then renames to C.md. " + "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 " + "When sync is enabled, only C.md should exist. Client 1 should receive C.md " +
@ -10,27 +9,20 @@ export const renameChainTest: TestDefinition = {
steps: [ steps: [
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
// Client 0 creates and renames while offline
{ type: "create", client: 0, path: "A.md", content: "important content" }, { 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: "A.md", newPath: "B.md" },
{ type: "rename", client: 0, oldPath: "B.md", newPath: "C.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: "enable-sync", client: 0 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Only C.md should exist on both clients {
{ type: "assert-not-exists", client: 0, path: "A.md" }, type: "assert-consistent",
{ type: "assert-not-exists", client: 0, path: "B.md" }, verify: (s) =>
{ type: "assert-exists", client: 0, path: "C.md" }, s.assertFileNotExists("A.md")
{ type: "assert-content", client: 0, path: "C.md", content: "important content" }, .assertFileNotExists("B.md")
.assertContent("C.md", "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" }
] ]
}; };

View file

@ -1,60 +1,10 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const renameCircularTest: TestDefinition = {
name: "Circular Rename Chain (3-Way Swap)",
description: description:
"Client 0 has A.md, B.md, C.md synced. Goes offline and performs a " + "Client 0 creates three files, syncs, then goes offline and performs a circular rename via a temp file (A->temp, C->A, B->C, temp->B). After reconnecting, both clients should have rotated content with no temp file remaining.",
"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, clients: 2,
steps: [ 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: "A.md", content: "content-a" },
{ type: "create", client: 0, path: "B.md", content: "content-b" }, { type: "create", client: 0, path: "B.md", content: "content-b" },
{ type: "create", client: 0, path: "C.md", content: "content-c" }, { type: "create", client: 0, path: "C.md", content: "content-c" },
@ -62,32 +12,32 @@ export const renameCircularTest: TestDefinition = {
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { 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-consistent",
{ type: "assert-content", client: 1, path: "C.md", content: "content-c" }, verify: (s) =>
s.assertContent("A.md", "content-a")
.assertContent("B.md", "content-b")
.assertContent("C.md", "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: "disable-sync", client: 0 },
{ type: "rename", client: 0, oldPath: "A.md", newPath: "temp-a.md" }, { type: "rename", client: 0, oldPath: "A.md", newPath: "temp-a.md" },
{ type: "rename", client: 0, oldPath: "C.md", newPath: "A.md" }, { type: "rename", client: 0, oldPath: "C.md", newPath: "A.md" },
{ type: "rename", client: 0, oldPath: "B.md", newPath: "C.md" }, { type: "rename", client: 0, oldPath: "B.md", newPath: "C.md" },
{ type: "rename", client: 0, oldPath: "temp-a.md", newPath: "B.md" }, { type: "rename", client: 0, oldPath: "temp-a.md", newPath: "B.md" },
// Client 0 reconnects
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Temp file should not exist on either client {
{ type: "assert-not-exists", client: 0, path: "temp-a.md" }, type: "assert-consistent",
{ type: "assert-not-exists", client: 1, path: "temp-a.md" }, verify: (s) =>
s.assertFileNotExists("temp-a.md")
// All three files should exist with rotated content .assertFileCount(3)
{ type: "assert-consistent", verify: verifyCircularRotation } .assertContent("A.md", "content-c")
.assertContent("B.md", "content-a")
.assertContent("C.md", "content-b"),
}
] ]
}; };

View file

@ -1,32 +1,8 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const renameCreateConflictTest: TestDefinition = {
name: "Rename-Create Conflict",
description: description:
"Client 0 creates file A, Client 1 renames A to B, then Client 0 (without syncing) creates B. " + "Client 0 creates A.md and syncs. Client 1 renames A.md to B.md and syncs. Client 0 (offline) creates B.md with the same content. After reconnecting, both clients should converge with only B.md.",
"The system must resolve the conflict deterministically.",
clients: 2, clients: 2,
steps: [ steps: [
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
@ -34,8 +10,10 @@ export const renameCreateConflictTest: TestDefinition = {
{ type: "create", client: 0, path: "A.md", content: "hi" }, { type: "create", client: 0, path: "A.md", content: "hi" },
{ type: "sync", client: 0 }, { type: "sync", client: 0 },
{ type: "sync", client: 1 }, { type: "sync", client: 1 },
{ type: "assert-exists", client: 1, path: "A.md" }, {
{ type: "assert-content", client: 1, path: "A.md", content: "hi" }, type: "assert-consistent",
verify: (s) => s.assertContent("A.md", "hi"),
},
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" }, { type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
{ type: "sync", client: 1 }, { type: "sync", client: 1 },
@ -43,6 +21,10 @@ export const renameCreateConflictTest: TestDefinition = {
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "sync", client: 0 }, { type: "sync", client: 0 },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-consistent", verify: verifyConflictResolution } {
type: "assert-consistent",
verify: (s) =>
s.assertFileNotExists("A.md").assertContent("B.md", "hi"),
}
] ]
}; };

View file

@ -1,56 +1,17 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const renamePendingCreateBeforeResponseTest: TestDefinition = {
name: "Rename Pending Create Before Server Response",
description: description:
"When a file is renamed while its create request is in-flight, " + "Client 0 creates a file while the server is paused, then renames it before the create completes. After the server resumes, both clients should converge with the file at the renamed path.",
"the document must not become orphaned. Both clients should " +
"converge with the file at the renamed path.",
clients: 2, clients: 2,
steps: [ steps: [
// Both clients online
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Pause server so the create stalls
{ type: "pause-server" }, { type: "pause-server" },
// Client 0 creates doc.md (request stalls at server)
{ {
type: "create", type: "create",
client: 0, client: 0,
@ -58,9 +19,6 @@ export const renamePendingCreateBeforeResponseTest: TestDefinition = {
content: "original-content" content: "original-content"
}, },
// Wait for the create to enter the executor
// Client 0 renames the file WHILE create is in-flight
{ {
type: "rename", type: "rename",
client: 0, client: 0,
@ -68,15 +26,16 @@ export const renamePendingCreateBeforeResponseTest: TestDefinition = {
newPath: "renamed.md" newPath: "renamed.md"
}, },
// Resume server — create response arrives for "doc.md"
{ type: "resume-server" }, { type: "resume-server" },
// Give time for create response + follow-up rename sync
{ type: "sync" }, { type: "sync" },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// File should be at renamed.md on both clients {
{ type: "assert-consistent", verify: verifyFileAtRenamedPath } type: "assert-consistent",
verify: (s) =>
s.assertFileCount(1).assertContent("renamed.md", "original-content"),
}
] ]
}; };

View file

@ -1,61 +1,38 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const renameRoundtripTest: TestDefinition = {
name: "Rename Round-Trip (A->B->A)",
description: description:
"Client 0 creates A.md and syncs. Then renames A.md to B.md and syncs. " + "Client 0 creates A.md, renames it to B.md, then renames it back to A.md. After each step both clients sync. Both should end with only A.md at the original path.",
"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, clients: 2,
steps: [ steps: [
// Setup: create and sync
{ type: "create", client: 0, path: "A.md", content: "original" }, { type: "create", client: 0, path: "A.md", content: "original" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-content", client: 1, path: "A.md", content: "original" }, {
type: "assert-consistent",
verify: (s) => s.assertContent("A.md", "original"),
},
// First rename: A.md -> B.md
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Verify intermediate state: only B.md exists {
{ type: "assert-not-exists", client: 0, path: "A.md" }, type: "assert-consistent",
{ type: "assert-not-exists", client: 1, path: "A.md" }, verify: (s) =>
{ type: "assert-exists", client: 0, path: "B.md" }, s.assertFileNotExists("A.md").assertContent("B.md", "original"),
{ 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: "rename", client: 0, oldPath: "B.md", newPath: "A.md" },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Final state: back to A.md with original content {
{ type: "assert-not-exists", client: 0, path: "B.md" }, type: "assert-consistent",
{ type: "assert-not-exists", client: 1, path: "B.md" }, verify: (s) =>
{ type: "assert-consistent", verify: verifyRoundtrip } s.assertFileNotExists("B.md").assertContent("A.md", "original"),
}
] ]
}; };

View file

@ -1,28 +1,6 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const renameSwapTest: TestDefinition = {
name: "Offline Swap via Temp File",
description: description:
"Client 0 has A.md and B.md synced. Goes offline and swaps them using " + "Client 0 has A.md and B.md synced. Goes offline and swaps them using " +
"a temp file: A.md -> temp.md, B.md -> A.md, temp.md -> B.md. " + "a temp file: A.md -> temp.md, B.md -> A.md, temp.md -> B.md. " +
@ -30,32 +8,34 @@ export const renameSwapTest: TestDefinition = {
"The temp file should not exist on either client.", "The temp file should not exist on either client.",
clients: 2, clients: 2,
steps: [ 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: "A.md", content: "content-a" },
{ type: "create", client: 0, path: "B.md", content: "content-b" }, { type: "create", client: 0, path: "B.md", content: "content-b" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { 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-consistent",
verify: (s) =>
s.assertContent("A.md", "content-a").assertContent("B.md", "content-b"),
},
// Client 0 goes offline and performs the swap
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
{ type: "rename", client: 0, oldPath: "A.md", newPath: "temp.md" }, { type: "rename", client: 0, oldPath: "A.md", newPath: "temp.md" },
{ type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" }, { type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" },
{ type: "rename", client: 0, oldPath: "temp.md", newPath: "B.md" }, { type: "rename", client: 0, oldPath: "temp.md", newPath: "B.md" },
// Client 0 reconnects
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// temp.md should not exist on either client {
{ type: "assert-not-exists", client: 0, path: "temp.md" }, type: "assert-consistent",
{ type: "assert-not-exists", client: 1, path: "temp.md" }, verify: (s) =>
s
// Both clients should have the swapped content .assertFileNotExists("temp.md")
{ type: "assert-consistent", verify: verifySwap } .assertContent("A.md", "content-b")
.assertContent("B.md", "content-a"),
}
] ]
}; };

View file

@ -1,32 +1,11 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const renameToExistingPathTest: TestDefinition = {
name: "Rename to Existing Path",
description: description:
"Client 0 has A.md and B.md. Client 0 renames A.md to B.md (overwriting B.md). " + "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.", "Both clients should converge: A.md gone, B.md has A.md's content.",
clients: 2, clients: 2,
steps: [ steps: [
// Setup: create two files and sync
{ type: "create", client: 0, path: "A.md", content: "alpha" }, { type: "create", client: 0, path: "A.md", content: "alpha" },
{ type: "create", client: 0, path: "B.md", content: "beta" }, { type: "create", client: 0, path: "B.md", content: "beta" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
@ -34,14 +13,14 @@ export const renameToExistingPathTest: TestDefinition = {
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Client 0 renames A.md to B.md (overwrites B.md)
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Both should converge {
{ type: "assert-not-exists", client: 0, path: "A.md" }, type: "assert-consistent",
{ type: "assert-not-exists", client: 1, path: "A.md" }, verify: (s) =>
{ type: "assert-consistent", verify: verifyFinalState } s.assertFileNotExists("A.md").assertContent("B.md", "alpha"),
}
] ]
}; };

View file

@ -1,50 +1,10 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const renameToPathOfUnconfirmedDeleteTest: TestDefinition = {
name: "Rename to Path of Unconfirmed Delete",
description: description:
"Client deletes A.md and renames B.md to A.md while offline. " + "Client 0 deletes A.md and renames B.md to A.md while offline. After reconnecting, A.md should exist with B's content and B.md should be gone.",
"On reconnect, the VFS must handle the path conflict between " +
"the tracked A.md (pending delete) and the rename destination.",
clients: 2, clients: 2,
steps: [ steps: [
// Setup: both clients have A.md and B.md
{ {
type: "create", type: "create",
client: 0, client: 0,
@ -62,21 +22,22 @@ export const renameToPathOfUnconfirmedDeleteTest: TestDefinition = {
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Client 0 goes offline
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
// Delete A.md, then rename B.md → A.md
{ type: "delete", client: 0, path: "A.md" }, { type: "delete", client: 0, path: "A.md" },
{ type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" }, { type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" },
// Reconnect
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { 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-consistent",
{ type: "assert-not-exists", client: 1, path: "B.md" }, verify: (s) =>
{ type: "assert-consistent", verify: verifyFinalState } s
.assertFileCount(1)
.assertFileNotExists("B.md")
.assertContent("A.md", "content B"),
}
] ]
}; };

View file

@ -1,80 +1,30 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const renameToPendingPathFallbackTest: TestDefinition = {
name: "Rename Tracked File to Path With Pending Create",
description: description:
"When a tracked document is renamed to a path occupied by a " + "Client 0 creates B.md and syncs. Goes offline, creates A.md, then renames B.md to A.md (overwriting the unsynced A). After reconnecting, B.md should be gone and A.md should have B's content.",
"pending create, the VFS move is skipped (no branch for pending " +
"state). The fallback update fails with FileNotFoundError. " +
"Reconciliation should eventually recover.",
clients: 2, clients: 2,
steps: [ steps: [
// Setup: B.md tracked and synced on both clients
{ type: "create", client: 0, path: "B.md", content: "tracked B content" }, { type: "create", client: 0, path: "B.md", content: "tracked B content" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Client 0 goes offline
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
// Client 0 creates A.md (pending, never synced)
{ type: "create", client: 0, path: "A.md", content: "pending A content" }, { 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" }, { type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" },
// Re-enable sync
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { 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-consistent",
{ type: "assert-not-exists", client: 1, path: "B.md" }, verify: (s) =>
{ type: "assert-consistent", verify: verifyFinalState } s.assertFileNotExists("B.md").assertContains("A.md", "tracked B content"),
}
] ]
}; };

View file

@ -1,41 +1,10 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const renameToRecentlyDeletedPathTest: TestDefinition = {
name: "Rename to a Path That Was Recently Deleted",
description: description:
"Client 0 deletes B.md and syncs. Client 1 (offline) renames A.md " + "Client 0 deletes B.md. Client 1 renames A.md to B.md offline. After reconnecting, only B.md should exist with A's content.",
"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, clients: 2,
steps: [ steps: [
// Setup: create both files
{ type: "create", client: 0, path: "A.md", content: "content-a" }, { 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: "B.md", content: "content-b" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
@ -43,14 +12,11 @@ export const renameToRecentlyDeletedPathTest: TestDefinition = {
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Client 1 goes offline
{ type: "disable-sync", client: 1 }, { type: "disable-sync", client: 1 },
// Client 0 deletes B.md
{ type: "delete", client: 0, path: "B.md" }, { type: "delete", client: 0, path: "B.md" },
{ type: "sync", client: 0 }, { type: "sync", client: 0 },
// Client 1 (offline) renames A.md to B.md
{ {
type: "rename", type: "rename",
client: 1, client: 1,
@ -58,12 +24,17 @@ export const renameToRecentlyDeletedPathTest: TestDefinition = {
newPath: "B.md" newPath: "B.md"
}, },
// Client 1 reconnects
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Both clients should converge: only B.md with content-a {
{ type: "assert-consistent", verify: verifyConvergence } type: "assert-consistent",
verify: (s) =>
s
.assertFileCount(1)
.assertFileNotExists("A.md")
.assertContent("B.md", "content-a"),
}
] ]
}; };

View file

@ -1,58 +1,35 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const renameUpdateConflictTest: TestDefinition = {
name: "Rename vs Update Conflict",
description: description:
"Client 0 renames A.md to B.md while Client 1 (offline) updates A.md. " + "Client 0 renames A.md to B.md while client 1 updates A.md offline. After client 1 reconnects, both should converge with the update at B.md.",
"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, clients: 2,
steps: [ steps: [
// Setup: create A.md and sync to both clients
{ type: "create", client: 0, path: "A.md", content: "original" }, { type: "create", client: 0, path: "A.md", content: "original" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-content", client: 1, path: "A.md", content: "original" }, {
type: "assert-consistent",
verify: (s) => s.assertContent("A.md", "original"),
},
// Client 1 goes offline
{ type: "disable-sync", client: 1 }, { 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: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
{ type: "sync", client: 0 }, { type: "sync", client: 0 },
// Client 1 (offline) updates A.md
{ type: "update", client: 1, path: "A.md", content: "updated by client 1" }, { 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: "enable-sync", client: 1 },
{ type: "sync", client: 1 }, { type: "sync", client: 1 },
{ type: "barrier" }, { type: "barrier" },
// Verify convergence {
{ type: "assert-consistent", verify: verifyConvergence } type: "assert-consistent",
verify: (s) =>
s.assertFileNotExists("A.md").assertContains("B.md", "updated"),
}
] ]
}; };

View file

@ -1,43 +1,12 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const resetClearsRecentlyDeletedResurrectionTest: TestDefinition = {
name: "Sync Reset Does Not Resurrect Deleted Documents",
description: description:
"Client 0 deletes a file. Client 1 toggles sync off and on " + "Client 0 deletes a file. Client 1 toggles sync off and on " +
"(simulating reconnect). The deleted file should NOT reappear " + "(simulating reconnect). The deleted file should NOT reappear " +
"on Client 1 after the sync reset.", "on Client 1 after the sync reset.",
clients: 2, clients: 2,
steps: [ steps: [
// Setup
{ {
type: "create", type: "create",
client: 0, client: 0,
@ -49,26 +18,25 @@ export const resetClearsRecentlyDeletedResurrectionTest: TestDefinition = {
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Client 0 deletes the file
{ type: "delete", client: 0, path: "ghost.md" }, { type: "delete", client: 0, path: "ghost.md" },
{ type: "sync", client: 0 }, { type: "sync", client: 0 },
// Wait for broadcast to propagate
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Client 1 should NOT have the file {
{ type: "assert-not-exists", client: 1, path: "ghost.md" }, type: "assert-consistent",
verify: (s) => s.assertFileNotExists("ghost.md"),
},
// Client 1 toggles sync (simulating disconnect/reconnect)
{ type: "disable-sync", client: 1 }, { type: "disable-sync", client: 1 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// File should STILL be gone — no resurrection {
{ type: "assert-not-exists", client: 0, path: "ghost.md" }, type: "assert-consistent",
{ type: "assert-not-exists", client: 1, path: "ghost.md" }, verify: (s) => s.assertFileCount(0),
{ type: "assert-consistent", verify: verifyNoFiles } }
] ]
}; };

View file

@ -1,66 +1,32 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const sequentialCreateDuplicateContentTest: TestDefinition = {
name: "Sequential Creates With Identical Content Preserved",
description: description:
"Client 0 creates A.md and syncs it. Then Client 0 creates B.md with " + "Client 0 creates A.md, syncs, then creates B.md with identical content. Both files must remain as separate documents on both clients.",
"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, clients: 2,
steps: [ steps: [
// Create A.md and sync it fully
{ type: "create", client: 0, path: "A.md", content: "identical content here" }, { type: "create", client: 0, path: "A.md", content: "identical content here" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Verify A.md arrived on client 1
{ {
type: "assert-content", type: "assert-consistent",
client: 1, verify: (s) => s.assertContent("A.md", "identical content here"),
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: "create", client: 0, path: "B.md", content: "identical content here" },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Both files must exist on both clients with correct content. {
// This catches bugs where duplicate detection (content hash matching type: "assert-consistent",
// during offline reconciliation) accidentally treats B.md as a verify: (s) =>
// "move" of A.md, or where the server merges B.md into A.md's s
// document because of identical content at a different path. .assertFileCount(2)
{ type: "assert-consistent", verify: verifyBothFilesPreserved } .assertContent("A.md", "identical content here")
.assertContent("B.md", "identical content here"),
}
] ]
}; };

View file

@ -1,36 +1,8 @@
import type { ClientState, TestDefinition } from "../test-definition"; import type { 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 = { export const serverPauseBothClientsCreateTest: TestDefinition = {
name: "Server Pause While Both Clients Create",
description: description:
"Both clients are synced. Client 0 creates alpha.md. The server is immediately " + "Client 0 creates a file, then the server is paused. Client 1 creates a different file while the server is paused. After the server resumes, both files should exist on both clients.",
"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, clients: 2,
steps: [ steps: [
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
@ -38,8 +10,6 @@ export const serverPauseBothClientsCreateTest: TestDefinition = {
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { 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", type: "create",
client: 0, client: 0,
@ -48,8 +18,6 @@ export const serverPauseBothClientsCreateTest: TestDefinition = {
}, },
{ type: "pause-server" }, { 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", type: "create",
client: 1, client: 1,
@ -57,18 +25,17 @@ export const serverPauseBothClientsCreateTest: TestDefinition = {
content: "from client 1" content: "from client 1"
}, },
// Resume the server — both stalled requests should complete
{ type: "resume-server" }, { type: "resume-server" },
// Let both clients finish all pending sync work
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
// Both files must exist on both clients {
{ type: "assert-exists", client: 0, path: "alpha.md" }, type: "assert-consistent",
{ type: "assert-exists", client: 0, path: "beta.md" }, verify: (s) =>
{ type: "assert-exists", client: 1, path: "alpha.md" }, s
{ type: "assert-exists", client: 1, path: "beta.md" }, .assertContains("alpha.md", "from client 0")
{ type: "assert-consistent", verify: verifyBothFiles } .assertContains("beta.md", "from client 1"),
}
] ]
}; };

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