Compare commits

...

10 commits

204 changed files with 4354 additions and 6849 deletions

View file

@ -0,0 +1,35 @@
name: Check
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-Dwarnings"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: "25.x"
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: "1.92.0"
components: clippy, rustfmt
- name: Lint & test
run: scripts/check.sh

View file

@ -0,0 +1,38 @@
name: Deploy Documentation
on:
push:
branches:
- main
paths:
- "docs/**"
- ".forgejo/workflows/deploy-docs.yml"
workflow_dispatch:
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: "25.x"
- name: Build docs
run: scripts/build-docs.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: docs
path: docs/.vitepress/dist

View file

@ -0,0 +1,71 @@
name: E2E tests
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
schedule:
- cron: "0 * * * *"
workflow_dispatch:
concurrency:
group: e2e-tests
cancel-in-progress: false
env:
RUSTFLAGS: "-Dwarnings"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: "25.x"
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: "1.92.0"
components: clippy, rustfmt
- name: Setup rust
run: |
which sqlx || cargo install sqlx-cli
cd sync-server
sqlx database create --database-url sqlite://db.sqlite3
sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3
- name: E2E tests
run: |
cd sync-server
cargo run config-e2e.yml --color never &
SERVER_PID=$!
cd ..
scripts/e2e.sh 8
EXIT_CODE=$?
kill $SERVER_PID 2>/dev/null || true
wait $SERVER_PID 2>/dev/null || true
exit $EXIT_CODE
- name: Upload e2e logs
if: always()
uses: actions/upload-artifact@v4
with:
name: e2e-logs
path: logs/
retention-days: 30
- name: Cleanup
if: always()
run: scripts/clean-up.sh

View file

@ -0,0 +1,51 @@
name: Publish CLI
on:
push:
branches: ["main"]
tags: ["*"]
pull_request:
branches: ["main"]
jobs:
publish-docker:
runs-on: ubuntu-docker
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract registry hostname
id: registry
run: echo "host=$(echo '${{ github.server_url }}' | sed 's|https\?://||')" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log into container registry
uses: docker/login-action@v3
with:
registry: ${{ steps.registry.outputs.host }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ steps.registry.outputs.host }}/${{ github.repository }}-cli
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v5
with:
context: frontend
file: frontend/local-client-cli/Dockerfile
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ steps.registry.outputs.host }}/${{ github.repository }}-cli:buildcache
cache-to: type=registry,ref=${{ steps.registry.outputs.host }}/${{ github.repository }}-cli:buildcache,mode=max

View file

@ -0,0 +1,71 @@
name: Publish Obsidian plugin
on:
push:
tags: ["*"]
env:
CARGO_TERM_COLOR: always
jobs:
publish-plugin:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: "25.x"
- name: Build plugin
run: |
cd frontend
npm ci
npm run build
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: "1.92.0"
components: clippy, rustfmt
- name: Install cross-compilation tools
run: |
apt update
apt install -y gcc-aarch64-linux-gnu musl-tools gcc-mingw-w64-x86-64 jq
- name: Build Linux and Windows binaries
run: ./scripts/build-sync-server-binaries.sh
- name: Create release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SERVER_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
run: |
tag="${GITHUB_REF#refs/tags/}"
mkdir -p release
cp frontend/obsidian-plugin/dist/* release/
cp sync-server/artifacts/sync-server-* release/
# Create draft release via Forgejo API
RELEASE_ID=$(curl -s -X POST \
"${SERVER_URL}/api/v1/repos/${REPO}/releases" \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${tag}\", \"name\": \"${tag}\", \"draft\": true}" \
| jq -r '.id')
# Upload release assets
for file in release/*; do
filename=$(basename "$file")
curl -s -X POST \
"${SERVER_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}" \
-H "Authorization: token ${GITHUB_TOKEN}" \
-F "attachment=@${file}"
done

View file

@ -0,0 +1,51 @@
name: Publish server Docker image
on:
push:
branches: ["main"]
tags: ["*"]
pull_request:
branches: ["main"]
jobs:
publish-docker:
runs-on: ubuntu-docker
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract registry hostname
id: registry
run: echo "host=$(echo '${{ github.server_url }}' | sed 's|https\?://||')" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log into container registry
if: github.ref_type == 'tag'
uses: docker/login-action@v3
with:
registry: ${{ steps.registry.outputs.host }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ steps.registry.outputs.host }}/${{ github.repository }}
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v5
with:
context: sync-server
platforms: linux/amd64,linux/arm64
push: ${{ github.ref_type == 'tag' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ steps.registry.outputs.host }}/${{ github.repository }}:buildcache
cache-to: type=registry,ref=${{ steps.registry.outputs.host }}/${{ github.repository }}:buildcache,mode=max

View file

@ -6,7 +6,7 @@ Complements the fuzz-based E2E tests (`test-client`): fuzz tests discover bugs t
## 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.
@ -14,7 +14,7 @@ All tests run in parallel up to a concurrency limit.
## 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):
- `create`, `update`, `rename`, `delete`
@ -26,11 +26,9 @@ Clients always start with syincing being disabled.
**Server control:**
- `pause-server` / `resume-server` — SIGSTOP/SIGCONT the server process
- `wait` — sleep for N milliseconds
**Assertions:**
- `assert-content`, `assert-exists`, `assert-not-exists`
- `assert-consistent` — all clients have identical files; optionally takes a custom verify function
- `assert-consistent` — all clients have identical files; optionally takes a custom `verify(state: AssertableState)` callback
## Running
@ -56,18 +54,31 @@ npm run test -w deterministic-tests -- -j 4
import type { TestDefinition } from "../test-definition";
export const myScenarioTest: TestDefinition = {
name: "My Scenario",
description: "What this test verifies",
description: "Client 0 creates A.md offline. After syncing, both clients should have the file.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "A.md", content: "hello" },
{ type: "sync" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ 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`:
```typescript
@ -78,4 +89,3 @@ const TESTS = {
"my-scenario": myScenarioTest
};
```

View file

@ -34,7 +34,7 @@ function testUsesPauseServer(test: TestDefinition): boolean {
}
interface NamedTestResult {
test: TestDefinition;
name: string;
result: TestResult;
}
@ -64,13 +64,13 @@ async function main(): Promise<void> {
const filterArg = process.argv.find((a) => a.startsWith("--filter="));
const filter = filterArg?.slice("--filter=".length);
const testsToRun: TestDefinition[] = [];
const testsToRun: [string, TestDefinition][] = [];
for (const [key, test] of Object.entries(TESTS)) {
if (test) {
if (filter && !key.includes(filter) && !test.name.toLowerCase().includes(filter.toLowerCase())) {
if (filter && !key.includes(filter)) {
continue;
}
testsToRun.push(test);
testsToRun.push([key, test]);
}
}
@ -84,8 +84,10 @@ async function main(): Promise<void> {
}
const concurrency = parseConcurrency();
const regularTests = testsToRun.filter((t) => !testUsesPauseServer(t));
const pauseTests = testsToRun.filter((t) => testUsesPauseServer(t));
const regularTests = testsToRun.filter(
([, t]) => !testUsesPauseServer(t)
);
const pauseTests = testsToRun.filter(([, t]) => testUsesPauseServer(t));
logger.info(`Server: ${serverPath}`);
logger.info(`Config: ${configPath}`);
@ -113,7 +115,8 @@ async function main(): Promise<void> {
const results = await runWithConcurrency(
regularTests,
concurrency,
async (test) => runSharedServerTest(test, sharedServer)
async ([name, test]) =>
runSharedServerTest(name, test, sharedServer)
);
allResults.push(...results);
@ -137,7 +140,8 @@ async function main(): Promise<void> {
const results = await runWithConcurrency(
pauseTests,
concurrency,
async (test) => runDedicatedServerTest(test, serverPath, configPath)
async ([name, test]) =>
runDedicatedServerTest(name, test, serverPath, configPath)
);
allResults.push(...results);
@ -149,8 +153,8 @@ async function main(): Promise<void> {
logger.info(`\n--- Results: ${passed.length}/${allResults.length} passed ---`);
if (failed.length > 0) {
for (const { test, result } of failed) {
logger.error(` FAILED: ${test.name}: ${result.error}`);
for (const { name, result } of failed) {
logger.error(` FAILED: ${name}: ${result.error}`);
}
process.exit(1);
} 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(
name: string,
test: TestDefinition,
sharedServer: ServerControl
): Promise<NamedTestResult> {
const testLogger = new PrefixedLogger(logger, test.name);
const testLogger = new PrefixedLogger(logger, name);
const runner = new TestRunner(
sharedServer,
testLogger,
TOKEN,
sharedServer.remoteUri
);
const result = await runner.runTest(test);
const result = await runner.runTest(name, test);
if (result.success) {
logger.info(`PASSED: ${test.name} (${result.duration}ms)`);
logger.info(`PASSED: ${name} (${result.duration}ms)`);
} 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.
*/
async function runDedicatedServerTest(
name: string,
test: TestDefinition,
serverPath: string,
configPath: string
): Promise<NamedTestResult> {
const testLogger = new PrefixedLogger(logger, test.name);
const testLogger = new PrefixedLogger(logger, name);
const server = new ServerControl(serverPath, configPath, testLogger);
serverManager.track(server);
@ -210,13 +213,13 @@ async function runDedicatedServerTest(
TOKEN,
server.remoteUri
);
const result = await runner.runTest(test);
const result = await runner.runTest(name, test);
if (result.success) {
logger.info(`PASSED: ${test.name} (${result.duration}ms)`);
logger.info(`PASSED: ${name} (${result.duration}ms)`);
} else {
logger.error(`FAILED: ${test.name} - ${result.error}`);
logger.error(`FAILED: ${name} - ${result.error}`);
}
return { test, result };
return { name, result };
} finally {
try {
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 { assert } from "./utils/assert";
import { sleep } from "./utils/sleep";
@ -16,6 +16,8 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
database: Partial<StoredDatabase>;
}> = {};
private isSyncEnabled = IS_SYNC_ENABLED_DEFAULT;
private readonly syncErrors: Error[] = [];
private readonly pendingSyncOperations = new Set<Promise<void>>();
public constructor(
clientId: number,
@ -81,9 +83,11 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
const contentBytes = new TextEncoder().encode(content);
this.files.set(path, contentBytes);
this.enqueueSync(async () =>
this.client.syncLocallyCreatedFile(path)
);
if (this.isSyncEnabled) {
this.enqueueSync(async () =>
this.client.syncLocallyCreatedFile(path)
);
}
}
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);
this.files.set(path, contentBytes);
this.enqueueSync(async () =>
this.client.syncLocallyUpdatedFile({ relativePath: path })
);
if (this.isSyncEnabled) {
this.enqueueSync(async () =>
this.client.syncLocallyUpdatedFile({ relativePath: path })
);
}
}
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}`
);
}
if (oldPath !== newPath && this.files.has(newPath)) {
this.log(
`Target path ${newPath} already exists, will be overwritten (ensureClearPath)`
);
}
this.files.set(newPath, file);
if (oldPath !== newPath) {
this.files.delete(oldPath);
@ -140,18 +141,47 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
public async waitForSync(): Promise<void> {
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(
this.client.waitUntilFinished(),
WAIT_TIMEOUT_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");
}
public async disableSync(): Promise<void> {
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);
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> {
@ -161,44 +191,6 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
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[]> {
return this.listFilesRecursively();
}
@ -217,6 +209,7 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
return;
}
try {
await this.drainPendingSyncOperations();
await withTimeout(
this.client.waitUntilFinished(),
WAIT_TIMEOUT_MS,
@ -233,6 +226,49 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
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> {
const deadline = Date.now() + WEBSOCKET_CONNECT_TIMEOUT_MS;
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 {
void this.executeSyncOperation(operation).catch((error) => {
this.log(
`Background sync failed (will retry on reconnect): ${error}`
);
const promise = this.executeSyncOperation(operation).catch(
(error: unknown) => {
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> {
const pingUrl = `${this.remoteUri}/vaults/test/ping`;
for (let i = 0; i < maxAttempts; i++) {
if (this.process === null || this.process.exitCode !== null) {
if (this.process?.exitCode !== null) {
throw new Error(
"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";
export class ServerManager {

View file

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

View file

@ -1,86 +1,49 @@
import type { TestDefinition } from "./test-definition";
import { writeWriteConflictTest } from "./tests/write-write-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 { serverPauseResumeTest } from "./tests/server-pause-resume.test";
import { createMergeDeleteTest } from "./tests/create-merge-delete.test";
import { renameUpdateConflictTest } from "./tests/rename-update-conflict.test";
import { deleteRenameConflictTest } from "./tests/delete-rename-conflict.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 { 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 { 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 { 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 { 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 { concurrentCreateSamePathMergeTest } from "./tests/concurrent-create-same-path-merge.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 { mcMultiDeleteOfflineRenameTest } from "./tests/mc-multi-delete-offline-rename.test";
import { mcCrossCreateRenameSameTargetTest } from "./tests/mc-cross-create-rename-same-target.test";
import { mcDeleteThenOfflineRenameTest } from "./tests/mc-delete-then-offline-rename.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 { offlineMultipleEditsTest } from "./tests/offline-multiple-edits.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 { renameSwapTest } from "./tests/rename-swap.test";
import { renameCircularTest } from "./tests/rename-circular.test";
import { renameNestedPathTest } from "./tests/rename-nested-path.test";
import { renameRoundtripTest } from "./tests/rename-roundtrip.test";
import { offlineRenameRemoteCreateOldPathTest } from "./tests/offline-rename-remote-create-old-path.test";
import { offlineEditRemoteRenameTest } from "./tests/offline-edit-remote-rename.test";
import { renameChainThenDeleteTest } from "./tests/rename-chain-then-delete.test";
import { offlineDeleteRemoteRenameTest } from "./tests/offline-delete-remote-rename.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 { 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 { moveAndConcurrentRemoteUpdateTest } from "./tests/move-and-concurrent-remote-update.test";
import { offlineDeleteVsRemoteUpdateTest } from "./tests/offline-delete-vs-remote-update.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 { renameTrackedToOccupiedPendingPathTest } from "./tests/rename-tracked-to-occupied-pending-path.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 { deleteDuringPendingCreateTest } from "./tests/delete-during-pending-create.test";
import { threeClientRenameCreateDeleteTest } from "./tests/three-client-rename-create-delete.test";
import { keyMigrationEventDropTest } from "./tests/key-migration-event-drop.test";
import { renameToPathOfUnconfirmedDeleteTest } from "./tests/rename-to-path-of-unconfirmed-delete.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 { 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 { moveChainThreeFilesTest } from "./tests/move-chain-three-files.test";
import { updateDuringCreateProcessingTest } from "./tests/update-during-create-processing.test";
import { offlineMoveThenRemoteDeleteTest } from "./tests/offline-move-then-remote-delete.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 { recentlyDeletedClearedOnReconnectTest } from "./tests/recently-deleted-cleared-on-reconnect.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 { 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 { renameEmptyFileLosesIdentityTest } from "./tests/rename-empty-file-loses-identity.test";
import { queueResetLosesCoalescedLocalEditTest } from "./tests/queue-reset-loses-coalesced-local-edit.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 { createMergePreservesRenamedUpdateTest } from "./tests/create-merge-preserves-renamed-update.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 { 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>> = {
"write-write-conflict": writeWriteConflictTest,
"rename-create-conflict": renameCreateConflictTest,
"create-delete-noop": createDeleteNoopTest,
"rename-chain": renameChainTest,
"server-pause-resume": serverPauseResumeTest,
"create-merge-delete": createMergeDeleteTest,
"rename-update-conflict": renameUpdateConflictTest,
"delete-rename-conflict": deleteRenameConflictTest,
"multi-file-operations": multiFileOperationsTest,
"duplicate-content-files": duplicateContentFilesTest,
"delete-recreate-same-path": deleteRecreateSamePathTest,
"rapid-sync-toggle": rapidSyncToggleTest,
"concurrent-delete-update": concurrentDeleteUpdateTest,
"offline-rename-and-edit": offlineRenameAndEditTest,
"three-client-convergence": threeClientConvergenceTest,
"update-during-server-pause": updateDuringServerPauseTest,
"empty-file-sync": emptyFileSyncTest,
"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,
"large-file-count": largeFileCountTest,
"offline-operations-both-clients": offlineOperationsBothClientsTest,
"update-then-rename": updateThenRenameTest,
"idempotency-after-server-pause": idempotencyAfterServerPauseTest,
"concurrent-create-same-path-merge": concurrentCreateSamePathMergeTest,
"sequential-create-duplicate-content": sequentialCreateDuplicateContentTest,
"offline-multi-update-catchup": offlineMultiUpdateCatchupTest,
"mc-three-client-rename-offline-update": mcThreeClientRenameOfflineUpdateTest,
"mc-multi-delete-offline-rename": mcMultiDeleteOfflineRenameTest,
"mc-cross-create-rename-same-target": mcCrossCreateRenameSameTargetTest,
"mc-delete-then-offline-rename": mcDeleteThenOfflineRenameTest,
"offline-mixed-operations": offlineMixedOperationsTest,
"offline-create-rename-create": offlineCreateRenameCreateTest,
"offline-concurrent-renames": offlineConcurrentRenamesTest,
"offline-multiple-edits": offlineMultipleEditsTest,
"server-pause-both-clients-create": serverPauseBothClientsCreateTest,
"server-pause-rename-propagation": serverPauseRenameTest,
"server-pause-concurrent-creates": serverPauseConcurrentCreatesTest,
"server-pause-update-and-create": serverPauseUpdateAndCreateTest,
"rename-swap": renameSwapTest,
"rename-circular": renameCircularTest,
"rename-nested-path": renameNestedPathTest,
"rename-roundtrip": renameRoundtripTest,
"offline-rename-remote-create-old-path": offlineRenameRemoteCreateOldPathTest,
"offline-edit-remote-rename": offlineEditRemoteRenameTest,
"rename-chain-then-delete": renameChainThenDeleteTest,
"offline-delete-remote-rename": offlineDeleteRemoteRenameTest,
"rename-to-recently-deleted-path": renameToRecentlyDeletedPathTest,
"create-update-coalesce-server-pause": createUpdateCoalesceServerPauseTest,
"overlapping-edits-same-section": overlappingEditsSameSectionTest,
"rapid-updates-after-merge": rapidUpdatesAfterMergeTest,
"offline-rename-pending-create": offlineRenamePendingCreateTest,
"delete-recreate-concurrent-update": deleteRecreateConcurrentUpdateTest,
"move-and-concurrent-remote-update": moveAndConcurrentRemoteUpdateTest,
"double-offline-cycle": doubleOfflineCycleTest,
"create-rename-create-same-path": createRenameCreateSamePathTest,
"concurrent-edit-exact-same-position": concurrentEditExactSamePositionTest,
"server-pause-rename-edit-resume": serverPauseRenameEditResumeTest,
"rename-tracked-to-occupied-pending-path": renameTrackedToOccupiedPendingPathTest,
"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,
"delete-during-pending-create": deleteDuringPendingCreateTest,
"three-client-rename-create-delete": threeClientRenameCreateDeleteTest,
"key-migration-event-drop": keyMigrationEventDropTest,
"rename-to-path-of-unconfirmed-delete": renameToPathOfUnconfirmedDeleteTest,
"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,
"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,
"move-chain-three-files": moveChainThreeFilesTest,
"update-during-create-processing": updateDuringCreateProcessingTest,
"offline-move-then-remote-delete": offlineMoveThenRemoteDeleteTest,
"reset-clears-recently-deleted-resurrection": resetClearsRecentlyDeletedResurrectionTest,
@ -203,24 +121,16 @@ export const TESTS: Partial<Record<string, TestDefinition>> = {
"move-preserves-remote-update": movePreservesRemoteUpdateTest,
"recently-deleted-cleared-on-reconnect": recentlyDeletedClearedOnReconnectTest,
"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,
"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,
"rename-empty-file-loses-identity": renameEmptyFileLosesIdentityTest,
"queue-reset-loses-coalesced-local-edit": queueResetLosesCoalescedLocalEditTest,
"rename-to-pending-path-fallback": renameToPendingPathFallbackTest,
"coalesced-remote-update-watermark-loss": coalescedRemoteUpdateWatermarkLossTest,
"move-remote-update-reverts-rename": moveRemoteUpdateRevertsRenameTest,
"create-merge-preserves-renamed-update": createMergePreservesRenamedUpdateTest,
"local-edit-lost-during-create-merge": localEditLostDuringCreateMergeTest,
"concurrent-binary-create-deconfliction": concurrentBinaryCreateDeconflictionTest,
"rename-pending-create-before-response": renamePendingCreateBeforeResponseTest,
"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 {
TestDefinition,
TestResult,
TestStep,
ClientState
TestStep
} from "./test-definition";
import { DeterministicAgent } from "./deterministic-agent";
import type { ServerControl } from "./server-control";
import type { SyncSettings, Logger } from "sync-client";
import { assert } from "./utils/assert";
import { AssertableState } from "./utils/assertable-state";
import { sleep } from "./utils/sleep";
import { withTimeout } from "./utils/with-timeout";
import {
@ -37,9 +37,12 @@ export class TestRunner {
this.remoteUri = remoteUri;
}
public async runTest(test: TestDefinition): Promise<TestResult> {
public async runTest(
name: string,
test: TestDefinition
): Promise<TestResult> {
const startTime = Date.now();
this.logger.info(`Running test: ${test.name}`);
this.logger.info(`Running test: ${name}`);
if (test.description !== undefined && test.description !== "") {
this.logger.info(`Description: ${test.description}`);
}
@ -65,7 +68,7 @@ export class TestRunner {
await this.cleanup();
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 {
success: true,
@ -75,7 +78,7 @@ export class TestRunner {
const duration = Date.now() - startTime;
const errorMessage =
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}`);
await this.cleanup();
@ -192,21 +195,6 @@ export class TestRunner {
await this.waitForConvergence();
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":
await this.assertConsistent(step.verify);
break;
@ -263,17 +251,21 @@ export class TestRunner {
}
/**
* Wait for all agents to be simultaneously idle. Two full rounds are
* needed because completing work on agent A can trigger a server
* broadcast that enqueues new work on agent B, and vice versa.
*
* However, the 2nd sync may result in merges which can trigger another
* round of syncs, so this function should be called in a loop with a
* timeout to ensure true convergence rather than just waiting for the
* current round of syncs to complete.
* Wait for all agents to be simultaneously idle.
*
* Completing work on agent A can trigger a server broadcast that
* enqueues new work on agent B, which can cascade further. With N
* agents the worst-case cascade depth is N (a chain ABCA),
* so we run N+1 sequential passes to drain it. Extra passes are
* essentially free when there is no outstanding work.
*
* 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> {
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) {
await agent.waitForSync();
}
@ -281,47 +273,52 @@ export class TestRunner {
}
private async assertConsistent(
verify?: (state: ClientState) => void
verify?: (state: AssertableState) => void
): Promise<void> {
this.logger.info("Asserting all clients are consistent...");
assert(this.agents.length >= 2, "Need at least 2 agents for consistency check");
const [referenceAgent] = this.agents;
const referenceFiles = (await referenceAgent.getFiles()).sort();
const referenceState: ClientState = { files: new Map() };
for (const file of referenceFiles) {
const content = await referenceAgent.getFileContent(file);
referenceState.files.set(file, content);
// Snapshot all agents' file states upfront to minimize the window
// where background sync could mutate state between reads.
const clientFiles: Map<string, string>[] = [];
for (const agent of this.agents) {
const sortedFiles = (await agent.getFiles()).sort();
const fileMap = new Map<string, string>();
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(
`Reference client has ${referenceFiles.length} files: ${referenceFiles.join(", ")}`
);
for (let i = 1; i < this.agents.length; i++) {
const agent = this.agents[i];
const files = (await agent.getFiles()).sort();
for (let i = 1; i < clientFiles.length; i++) {
const agentFileKeys = Array.from(clientFiles[i].keys());
this.logger.info(
`Client ${i} has ${files.length} files: ${files.join(", ")}`
`Client ${i} has ${agentFileKeys.length} files: ${agentFileKeys.join(", ")}`
);
assert(
files.length === referenceFiles.length,
`File count mismatch: client 0 has ${referenceFiles.length} files, client ${i} has ${files.length} files`
agentFileKeys.length === referenceFiles.length,
`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(
files[j] === referenceFiles[j],
`File list mismatch at index ${j}: client 0 has "${referenceFiles[j]}", client ${i} has "${files[j]}"`
agentFileKeys[j] === referenceFiles[j],
`File list mismatch at index ${j}: client 0 has "${referenceFiles[j]}", client ${i} has "${agentFileKeys[j]}"`
);
}
for (const file of referenceFiles) {
const referenceContent = referenceState.files.get(file);
const agentContent = await agent.getFileContent(file);
const referenceContent = clientFiles[0].get(file);
const agentContent = clientFiles[i].get(file);
assert(
referenceContent === agentContent,
@ -335,7 +332,12 @@ export class TestRunner {
if (verify) {
this.logger.info("Running custom verification...");
try {
verify(referenceState);
verify(
new AssertableState({
files: clientFiles[0],
clientFiles
})
);
} catch (error) {
const msg =
error instanceof Error ? error.message : String(error);

View file

@ -0,0 +1,28 @@
import type { TestDefinition } from "../test-definition";
export const textPendingCreateNotDisplacedTest: TestDefinition = {
description:
"Two clients each create a text file at the same path while offline. " +
"After syncing, the file should contain merged content from both clients.",
clients: 2,
steps: [
{
type: "create",
client: 0,
path: "data.txt",
content: "text data from client 0"
},
{
type: "create",
client: 1,
path: "data.txt",
content: "text data from client 1"
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertFileExists("data.txt").assertAnyFileContains("data from client 0", "data from client 1") }
]
};

View file

@ -0,0 +1,40 @@
import type { TestDefinition } from "../test-definition";
export const concurrentUpdateDiffConsistencyTest: TestDefinition = {
description:
"Both clients edit different sections of the same file while offline. " +
"After syncing, the merged file should contain both edits.",
clients: 2,
steps: [
{
type: "create",
client: 0,
path: "doc.md",
content: "header\nmiddle\nfooter"
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
{
type: "update",
client: 0,
path: "doc.md",
content: "header by 0\nmiddle\nfooter"
},
{
type: "update",
client: 1,
path: "doc.md",
content: "header\nmiddle\nfooter by 1"
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "assert-consistent", verify: (state) => state.assertFileCount(1).assertContent("doc.md", "header by 0\nmiddle\nfooter by 1") }
]
};

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

@ -0,0 +1,21 @@
import type { TestDefinition } from "../test-definition";
export const createDeleteNoopTest: TestDefinition = {
description:
"A client creates a file, updates it multiple times, then deletes it, all while " +
"offline. After syncing, neither client should have the file.",
clients: 2,
steps: [
{ type: "enable-sync", client: 1 },
{ type: "create", client: 0, path: "temp.md", content: "version 1" },
{ type: "update", client: 0, path: "temp.md", content: "version 2" },
{ type: "update", client: 0, path: "temp.md", content: "version 3" },
{ type: "delete", client: 0, path: "temp.md" },
{ type: "enable-sync", client: 0 },
{ type: "barrier" },
{ type: "assert-consistent", verify: (s) => s.assertFileNotExists("temp.md") }
]
};

View file

@ -0,0 +1,27 @@
import type { TestDefinition } from "../test-definition";
export const createMergeDeleteTest: TestDefinition = {
description:
"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 " +
"converge on an empty state.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "A.md", content: "from-zero" },
{ type: "create", client: 1, path: "A.md", content: "from-one" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state) => state.assertFileCount(1).assertContains("A.md", "from-zero", "from-one")
},
{ type: "delete", client: 0, path: "A.md" },
{ type: "barrier" },
{ type: "assert-consistent", verify: (s) => s.assertFileCount(0).assertFileNotExists("A.md") }
]
};

View file

@ -0,0 +1,43 @@
import type { TestDefinition } from "../test-definition";
export const moveIdenticalContentAmbiguityTest: TestDefinition = {
description:
"Two files with identical content exist. One is deleted and the other renamed " +
"while offline. The system should still converge correctly despite the ambiguity.",
clients: 2,
steps: [
{
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: "barrier" },
{ type: "disable-sync", client: 1 },
{ type: "delete", client: 1, path: "A.md" },
{ type: "rename", client: 1, oldPath: "B.md", newPath: "C.md" },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state) => {
state
.assertFileCount(1)
.assertFileNotExists("A.md")
.assertFileNotExists("B.md")
.assertContent("C.md", "identical content");
}
}
]
};

View file

@ -0,0 +1,24 @@
import type { TestDefinition } from "../test-definition";
export const createUpdateCoalesceServerPauseTest: TestDefinition = {
description:
"Client creates a file and immediately updates it while the server is " +
"paused. When the server resumes, both clients should have the final " +
"updated content.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "pause-server" },
{ type: "create", client: 0, path: "doc.md", content: "initial" },
{ type: "update", client: 0, path: "doc.md", content: "final version" },
{ type: "resume-server" },
{ type: "barrier" },
{ type: "assert-consistent", verify: (state) => state.assertFileCount(1).assertContent("doc.md", "final version") }
]
};

View file

@ -0,0 +1,49 @@
import type { TestDefinition } from "../test-definition";
export const createDuringReconciliationTest: TestDefinition = {
description:
"Client creates two files while offline, reconnects, then immediately " +
"creates a third file. All three files should sync to the other client.",
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: "A.md",
content: "offline A"
},
{
type: "create",
client: 0,
path: "B.md",
content: "offline B"
},
{ type: "enable-sync", client: 0 },
{
type: "create",
client: 0,
path: "C.md",
content: "post-reconnect C"
},
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state) => {
state
.assertFileCount(3)
.assertContent("A.md", "offline A")
.assertContent("B.md", "offline B")
.assertContent("C.md", "post-reconnect C");
}
}
]
};

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

@ -0,0 +1,28 @@
import type { TestDefinition } from "../test-definition";
export const binaryPendingCreateNotDisplacedTest: TestDefinition = {
description:
"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.",
clients: 2,
steps: [
{
type: "create",
client: 0,
path: "data.bin",
content: "binary data from client 0"
},
{
type: "create",
client: 1,
path: "data.bin",
content: "binary data from client 1"
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ 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") }
]
};

View file

@ -0,0 +1,48 @@
import type { TestDefinition } from "../test-definition";
export const coalesceUpdateRemoteUpdateDataLossTest: TestDefinition = {
description:
"Client 0 edits a file while client 1 is offline. Client 1 reconnects " +
"and immediately edits the same file. Both edits should be preserved.",
clients: 2,
steps: [
{
type: "create",
client: 0,
path: "doc.md",
content: "line 1\nline 2\nline 3"
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "disable-sync", client: 1 },
{
type: "update",
client: 0,
path: "doc.md",
content: "line 1\nline 2\nline 3\nclient 0 addition"
},
{ type: "sync", client: 0 },
{
type: "update",
client: 1,
path: "doc.md",
content: "client 1 addition\nline 1\nline 2\nline 3"
},
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state) => {
state
.assertFileCount(1)
.assertContains("doc.md", "client 0 addition", "client 1 addition");
}
}
]
};

View file

@ -0,0 +1,38 @@
import type { TestDefinition } from "../test-definition";
export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = {
description:
"Client 0 sends three rapid updates. After syncing, both clients " +
"disconnect and reconnect twice. Content should remain correct " +
"after each reconnect.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "doc.md", content: "original" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "update", client: 0, path: "doc.md", content: "update 1" },
{ type: "update", client: 0, path: "doc.md", content: "update 2" },
{ type: "update", client: 0, path: "doc.md", content: "final update" },
{ type: "sync", client: 0 },
{ type: "barrier" },
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("doc.md", "final update") },
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("doc.md", "final update") },
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("doc.md", "final update") }
]
};

View file

@ -0,0 +1,27 @@
import type { TestDefinition } from "../test-definition";
export const concurrentDeleteDuringRemoteUpdateTest: TestDefinition = {
description:
"One client updates a file while the other deletes it at the same " +
"time. Both clients should converge without errors.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "doc.md", content: "original" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
{ type: "update", client: 0, path: "doc.md", content: "updated by 0" },
{ type: "delete", client: 1, path: "doc.md" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "assert-consistent", verify: (state) => state.assertFileCount(0) }
]
};

View file

@ -0,0 +1,48 @@
import type { TestDefinition } from "../test-definition";
export const concurrentEditExactSamePositionTest: TestDefinition = {
description:
"Both clients replace the same word in a file with different text " +
"while offline. After syncing, the merged result should contain " +
"both replacements.",
clients: 2,
steps: [
{
type: "create",
client: 0,
path: "doc.md",
content: "the quick brown fox"
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
{
type: "update",
client: 0,
path: "doc.md",
content: "the slow brown fox"
},
{
type: "update",
client: 1,
path: "doc.md",
content: "the fast brown fox"
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state) => {
state
.assertFileCount(1)
.assertContains("doc.md", "slow", "fast", "brown fox");
}
}
]
};

View file

@ -0,0 +1,47 @@
import type { TestDefinition } from "../test-definition";
export const concurrentRenameAndCreateAtTargetTest: TestDefinition = {
description:
"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 the renamed file and the newly created file.",
clients: 2,
steps: [
{
type: "create",
client: 0,
path: "X.md",
content: "original file X"
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
{ type: "rename", client: 0, oldPath: "X.md", newPath: "Y.md" },
{
type: "create",
client: 1,
path: "Y.md",
content: "brand new Y content"
},
{ type: "enable-sync", client: 0 },
{ type: "sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state) => {
state
.assertFileNotExists("X.md")
.assertContains("Y.md", "original file X", "brand new Y content");
}
}
]
};

View file

@ -0,0 +1,48 @@
import type { TestDefinition } from "../test-definition";
export const concurrentRenameAndCreateAtTargetTest: TestDefinition = {
description:
"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 the renamed file and the newly created file.",
clients: 2,
steps: [
{
type: "create",
client: 0,
path: "X.md",
content: "original file X"
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
{ type: "rename", client: 0, oldPath: "X.md", newPath: "Y.md" },
{
type: "create",
client: 1,
path: "Y.md",
content: "brand new Y content"
},
{ type: "enable-sync", client: 1 },
{ type: "sync", client: 1 },
{ type: "enable-sync", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state) => {
state
.assertFileCount(2)
.assertContains("Y (1).md", "original file X")
.assertContains("Y.md", "brand new Y content");
}
}
]
};

View file

@ -0,0 +1,39 @@
import type { TestDefinition } from "../test-definition";
export const concurrentRenameSameTargetTest: TestDefinition = {
description:
"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.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "A.md", content: "content-a" },
{ type: "create", client: 0, path: "B.md", content: "content-b" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "disable-sync", client: 1 },
{ type: "rename", client: 0, oldPath: "A.md", newPath: "C.md" },
{ type: "sync", client: 0 },
{ type: "rename", client: 1, oldPath: "B.md", newPath: "C.md" },
{ type: "enable-sync", client: 1 },
{ type: "sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state) => {
state
.assertFileCount(2)
.assertFileNotExists("A.md")
.assertFileNotExists("B.md")
.assertFileExists("C.md")
.assertFileExists("C (1).md")
.assertAnyFileContains("content-a", "content-b");
}
}
]
};

View file

@ -1,67 +0,0 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
function verifyBothFilesExist(state: ClientState): void {
assert(
state.files.size === 2,
`Expected 2 files, got ${state.files.size}: ${[...state.files.keys()].join(", ")}`
);
assert(
state.files.has("data.bin"),
"Expected data.bin to exist"
);
assert(
state.files.has("data (1).bin"),
"Expected data (1).bin to exist"
);
const contents = new Set(state.files.values());
assert(
contents.has("binary data from client 0"),
`Expected one file to contain "binary data from client 0"`
);
assert(
contents.has("binary data from client 1"),
`Expected one file to contain "binary data from client 1"`
);
}
export const binaryPendingCreateNotDisplacedTest: TestDefinition = {
name: "Binary Pending Create Not Displaced By Remote Create",
description:
"When both clients create a binary file at the same path, the " +
"server deconflicts them into separate documents. Both files " +
"should exist on both clients after sync.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
// Both go offline
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
// Both create binary file at same path (use .bin extension)
{
type: "create",
client: 0,
path: "data.bin",
content: "binary data from client 0"
},
{
type: "create",
client: 1,
path: "data.bin",
content: "binary data from client 1"
},
// Both come online
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
// Both files should exist (server deconflicted them)
{ type: "assert-consistent", verify: verifyBothFilesExist }
]
};

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

@ -1,97 +0,0 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
/**
* BUG: Local edit can be lost when coalesced with a remote-update.
*
* The coalescing table maps: update + remote-update remote-update.
* This means a local edit that was queued but not yet sent to the server
* gets replaced by a remote-update action. The remote-update fetches
* the server's content via executeSyncUpdateFull(force=true), which
* compares the local hash with the server hash and sends changes if
* they differ.
*
* However, the issue is that the content cache for the document may
* be stale: the local edit changed the file on disk, but the cache
* still has the old content. When the force-update path computes the
* diff, it uses the CACHED content (server content from a previous
* version) as the base, which may produce incorrect results.
*
* Simplified scenario to trigger the coalescing:
* 1. Both clients have A.md = "line 1\nline 2"
* 2. Client 1 goes offline
* 3. Client 0 updates A.md triggers broadcast
* 4. Client 1 comes online, receives the broadcast (remote-update queued)
* 5. Client 1 immediately edits A.md (local-update queued for same doc)
* 6. The local-update coalesces with the queued remote-update
* 7. The coalesced action is remote-update only fetches from server
*
* KNOWN BUG: Client 1's edit may be lost. This test documents the bug.
* If the bug is fixed, the test passes. If not, the test still passes
* because the system eventually reconciles via runFinalConsistencyCheck.
*
* We verify both edits eventually appear (possibly after a final scan).
*/
function verifyBothEditsPresent(state: ClientState): void {
assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`);
assert(state.files.has("doc.md"), "Expected doc.md to exist");
const content = state.files.get("doc.md") ?? "";
assert(
content.includes("client 0 addition"),
`Expected content to include "client 0 addition", got: "${content}"`
);
assert(
content.includes("client 1 addition"),
`Expected content to include "client 1 addition", got: "${content}"`
);
}
export const coalesceUpdateRemoteUpdateDataLossTest: TestDefinition = {
name: "Coalesce Update + Remote Update — Both Edits Preserved",
description:
"Client 0 edits a file while Client 1 is offline. Client 1 comes " +
"online (gets remote-update) and immediately edits the same file " +
"(local-update). Both edits should be preserved after sync.",
clients: 2,
steps: [
// Setup: both have the file
{
type: "create",
client: 0,
path: "doc.md",
content: "line 1\nline 2\nline 3"
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
// Client 1 goes offline
{ type: "disable-sync", client: 1 },
// Client 0 edits (appends a line)
{
type: "update",
client: 0,
path: "doc.md",
content: "line 1\nline 2\nline 3\nclient 0 addition"
},
{ type: "sync", client: 0 },
// Client 1 edits the same file while offline (prepends a line)
{
type: "update",
client: 1,
path: "doc.md",
content: "client 1 addition\nline 1\nline 2\nline 3"
},
// Client 1 comes back online — remote-update + local changes
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
// Both edits should be merged
{ type: "assert-consistent", verify: verifyBothEditsPresent }
]
};

View file

@ -1,85 +0,0 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
/**
* BUG: When remote-update events coalesce, the first vaultUpdateId is lost.
*
* In sync-events.ts coalesceFromRemoteUpdate (line 274-275):
* case "remote-update":
* return { action: "remote-update", version: event.version };
*
* When two remote-update events for the same document coalesce, the first
* version object (with its vaultUpdateId) is completely replaced by the
* second. The first vaultUpdateId is never recorded in CoveredValues.
*
* This also affects other coalescing paths that discard remote versions:
* - remote-update + local-create = create (version lost entirely)
* - remote-update + local-delete = delete (version lost entirely)
* - move + remote-update = move-and-update (version lost from action)
*
* The watermark gap causes unnecessary replays on every reconnect.
*
* This test creates multiple rapid updates and verifies convergence
* is maintained across a disconnect/reconnect cycle. The watermark
* gap means the server replays stale updates, but the client should
* still converge correctly (just less efficiently).
*/
function verifyContent(state: ClientState): void {
assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`);
assert(state.files.has("doc.md"), "Expected doc.md to exist");
const content = state.files.get("doc.md")!;
assert(
content === "final update",
`Expected "final update", got: "${content}"`
);
}
export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = {
name: "Coalesced Remote Updates Lose Earlier vaultUpdateIds",
description:
"When multiple remote-update events for the same document coalesce, " +
"only the last vaultUpdateId is recorded. Earlier IDs create " +
"permanent watermark gaps that cause unnecessary server replays " +
"on every reconnect.",
clients: 2,
steps: [
// Setup: both clients have doc.md
{ type: "create", client: 0, path: "doc.md", content: "original" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
// Client 0 sends three rapid updates
{ type: "update", client: 0, path: "doc.md", content: "update 1" },
{ type: "update", client: 0, path: "doc.md", content: "update 2" },
{ type: "update", client: 0, path: "doc.md", content: "final update" },
{ type: "sync", client: 0 },
// Client 1 processes — some remote-updates may coalesce
{ type: "sync", client: 1 },
{ type: "barrier" },
{ type: "assert-consistent", verify: verifyContent },
// Disconnect and reconnect both clients
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
// After reconnect, convergence should be maintained
// (even if the watermark caused unnecessary replays)
{ type: "assert-consistent", verify: verifyContent },
// Second reconnect cycle — should still be stable
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "assert-consistent", verify: verifyContent }
]
};

View file

@ -1,77 +0,0 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
/**
* BUG: Concurrent binary creates at the same path lose one file.
*
* Scenario:
* 1. Both clients create a binary file at the same path while offline
* 2. Client 0 syncs first server creates `data.bin`
* 3. Client 1 syncs server deconflicts to `data (1).bin` (binary
* files can't be 3-way merged)
* 4. Client 1 renames its local `data.bin` to `data (1).bin`
* (ensureClearPath in FileOperations)
* 5. Client 1 never downloads client 0's `data.bin` because it had
* a pending create at that path and the sync code skips remote
* downloads for paths with pending creates
*
* Expected: both clients should have 2 files `data.bin` (client 0's
* content) and `data (1).bin` (client 1's content).
*
* Related: CLAUDE.md "Known Concurrency Pitfalls" path deconfliction
* can create apparent duplicates.
*/
function verifyBothFilesExist(state: ClientState): void {
// Both binary files must exist (possibly at deconflicted paths)
assert(
state.files.size === 2,
`Expected 2 files, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
);
// Both original contents must be present somewhere
const allContent = Array.from(state.files.values()).join("\n");
assert(
allContent.includes("BINARY:content-from-client-0"),
`Expected content from client 0 in some file, got files: ${Array.from(state.files.entries()).map(([k, v]) => `${k}=${v}`).join(", ")}`
);
assert(
allContent.includes("BINARY:content-from-client-1"),
`Expected content from client 1 in some file, got files: ${Array.from(state.files.entries()).map(([k, v]) => `${k}=${v}`).join(", ")}`
);
}
export const concurrentBinaryCreateDeconflictionTest: TestDefinition = {
name: "Concurrent Binary Creates Deconflict Without Losing File",
description:
"Two clients create a binary file at the same path while offline. " +
"The server deconflicts one to a (1) path. Both clients must end " +
"up with both files.",
clients: 2,
steps: [
// Both clients create at the same binary path while offline
{
type: "create",
client: 0,
path: "data.bin",
content: "BINARY:content-from-client-0"
},
{
type: "create",
client: 1,
path: "data.bin",
content: "BINARY:content-from-client-1"
},
// Client 0 syncs first — server creates data.bin
{ type: "enable-sync", client: 0 },
{ type: "sync", client: 0 },
// Client 1 syncs — server deconflicts to data (1).bin
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
// Both files must be present on both clients
{ type: "assert-consistent", verify: verifyBothFilesExist }
]
};

View file

@ -1,60 +0,0 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
function verifyMergedContent(state: ClientState): void {
// Both clients created at the same path with different-length content.
// The server should 3-way merge them (empty parent). Both "short"
// and "a]much]longer]piece]of]content]here" should appear in the merged
// result (using ] as visual separator — actual content uses spaces).
assert(
state.files.size === 1,
`Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
);
assert(
state.files.has("shared.md"),
`Expected shared.md to exist, got: ${Array.from(state.files.keys()).join(", ")}`
);
const content = state.files.get("shared.md") ?? "";
assert(
content.includes("short note"),
`Expected merged content to include "short note", got: "${content}"`
);
assert(
content.includes("a much longer piece of content that one client wrote"),
`Expected merged content to include the longer text, got: "${content}"`
);
}
export const concurrentCreateSamePathMergeTest: TestDefinition = {
name: "Concurrent Creates at Same Path Merge Content",
description:
"Two clients both create a file at the same path while offline. " +
"Client 0 writes a short string, Client 1 writes a much longer " +
"string. When both sync, the server merges them (empty parent) " +
"and both clients converge to the merged content.",
clients: 2,
steps: [
// Both clients create at the same path while offline
{
type: "create",
client: 0,
path: "shared.md",
content: "short note"
},
{
type: "create",
client: 1,
path: "shared.md",
content: "a much longer piece of content that one client wrote"
},
// Enable sync on both
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
// Both clients should have merged content containing both pieces
{ type: "assert-consistent", verify: verifyMergedContent }
]
};

View file

@ -1,49 +0,0 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
/**
* BUG FIX: Concurrent delete must not crash remote update processing.
*
* Scenario:
* 1. Both clients have doc.md
* 2. Client 0 updates doc.md (triggers remote-update on client 1)
* 3. Client 1 deletes doc.md at the same time
* 4. Client 1's remote update processing should not crash
* 5. The delete should win (user intent)
*/
function verifyNoFiles(state: ClientState): void {
assert(state.files.size === 0, `Expected 0 files, got ${state.files.size}`);
}
export const concurrentDeleteDuringRemoteUpdateTest: TestDefinition = {
name: "Concurrent Delete During Remote Update Does Not Crash",
description:
"Deleting a file while a remote update is being processed " +
"should not cause an unhandled exception.",
clients: 2,
steps: [
// Setup
{ type: "create", client: 0, path: "doc.md", content: "original" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
// Both go offline
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
// Client 0 updates, client 1 deletes
{ type: "update", client: 0, path: "doc.md", content: "updated by 0" },
{ type: "delete", client: 1, path: "doc.md" },
// Both come online — remote update and local delete race
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
// After convergence, the file state should be consistent
{ type: "assert-consistent" }
]
};

View file

@ -1,48 +0,0 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
function verifyConflictResolution(state: ClientState): void {
// Either the delete wins (no files) or the update wins (A.md with
// updated content). Both are valid outcomes — the key invariant is
// that both clients agree (checked by assert-consistent).
if (state.files.has("A.md")) {
assert(
state.files.get("A.md") === "updated offline",
`If A.md survived, it should have "updated offline", got: "${state.files.get("A.md")}"`
);
}
}
export const concurrentDeleteUpdateTest: TestDefinition = {
name: "Concurrent Delete and Update",
description:
"Client 0 and Client 1 have A.md synced. Client 0 deletes A.md while " +
"Client 1 (offline) updates A.md. When both sync, they must converge to " +
"the same state — either the file exists or it doesn't, but both agree.",
clients: 2,
steps: [
// Setup: create and sync A.md
{ type: "create", client: 0, path: "A.md", content: "original" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
// Client 1 goes offline, updates the file
{ type: "disable-sync", client: 1 },
{ type: "update", client: 1, path: "A.md", content: "updated offline" },
// Client 0 deletes and syncs
{ type: "delete", client: 0, path: "A.md" },
{ type: "sync", client: 0 },
// Client 1 reconnects with pending update
{ type: "enable-sync", client: 1 },
{ type: "sync", client: 1 },
{ type: "barrier" },
// Key invariant: both clients must agree on the state.
// If A.md survived the conflict, it must have the updated content.
{ type: "assert-consistent", verify: verifyConflictResolution }
]
};

View file

@ -1,91 +0,0 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
function verifyMergedEdits(state: ClientState): void {
assert(
state.files.size === 1,
`Expected 1 file, got ${state.files.size}`
);
assert(
state.files.has("doc.md"),
`Expected doc.md to exist`
);
const content = state.files.get("doc.md") ?? "";
// Both clients replaced the same word. The 3-way merge with
// parent "the quick brown fox" should detect that both sides
// changed "quick" — one to "slow" and one to "fast".
// reconcile-text does word-level tokenization, so both
// replacements should appear (though order may vary).
assert(
content.includes("slow") && content.includes("fast"),
`Expected merged content to contain both "slow" and "fast", got: "${content}"`
);
assert(
content.includes("brown fox"),
`Expected merged content to preserve unchanged text "brown fox", got: "${content}"`
);
}
/**
* Tests 3-way merge when both clients edit the exact same word in a
* document. Client 0 replaces "quick" with "slow", Client 1 replaces
* "quick" with "fast". The merge should detect the conflicting edits
* and preserve both (the merge algorithm does not silently drop one).
*
* This is a stress test for the reconcile-text library's word-level
* tokenizer when operating on overlapping changes at the same offset.
*/
export const concurrentEditExactSamePositionTest: TestDefinition = {
name: "Concurrent Edit at Exact Same Position",
description:
"Both clients edit the exact same word in a file. Client 0 changes " +
"'quick' to 'slow', Client 1 changes 'quick' to 'fast'. The 3-way " +
"merge should detect the overlapping edit and produce a result that " +
"preserves both changes.",
clients: 2,
steps: [
// Setup: shared document
{
type: "create",
client: 0,
path: "doc.md",
content: "the quick brown fox"
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-content",
client: 1,
path: "doc.md",
content: "the quick brown fox"
},
// Both clients go offline and edit the same word
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
{
type: "update",
client: 0,
path: "doc.md",
content: "the slow brown fox"
},
{
type: "update",
client: 1,
path: "doc.md",
content: "the fast brown fox"
},
// Both come online
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
// Both should converge to a merged result
{ type: "assert-consistent", verify: verifyMergedEdits }
]
};

View file

@ -1,90 +0,0 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
/**
* EDGE CASE: Client A renames XY while Client B creates at Y.
*
* This tests a tricky scenario where:
* 1. Both clients know about X.md
* 2. Client A renames XY (offline)
* 3. Client B creates a NEW file at Y (offline)
* 4. Both reconnect
*
* The server should handle this by:
* - Client A's rename succeeds (XY)
* - Client B's create at Y triggers a smart merge with A's renamed document
* - Both documents' content should be preserved
*/
function verifyFinalState(state: ClientState): void {
// X should not exist (renamed by A)
assert(
!state.files.has("X.md"),
`X.md should not exist, files: ${Array.from(state.files.keys()).join(", ")}`
);
// Y should exist with merged content
assert(
state.files.has("Y.md"),
`Y.md should exist, files: ${Array.from(state.files.keys()).join(", ")}`
);
const content = state.files.get("Y.md") ?? "";
// Both pieces of content should be preserved through merge
assert(
content.includes("original file X"),
`Expected content to include "original file X", got: "${content}"`
);
assert(
content.includes("brand new Y content"),
`Expected content to include "brand new Y content", got: "${content}"`
);
}
export const concurrentRenameAndCreateAtTargetTest: TestDefinition = {
name: "Concurrent Rename to Path + Create at Same Path",
description:
"Client 0 renames X→Y while Client 1 creates a new file at Y. " +
"Both operations happen offline. On reconnect, the server should " +
"merge the renamed document with the created document.",
clients: 2,
steps: [
// Setup: create X.md on Client 0
{
type: "create",
client: 0,
path: "X.md",
content: "original file X"
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
// Both go offline
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
// Client 0: rename X→Y
{ type: "rename", client: 0, oldPath: "X.md", newPath: "Y.md" },
// Client 1: create Y with different content
// (Client 1 still has X.md locally)
{
type: "create",
client: 1,
path: "Y.md",
content: "brand new Y content"
},
// Client 0 reconnects first (rename goes through)
{ type: "enable-sync", client: 0 },
{ type: "sync", client: 0 },
// Client 1 reconnects (create at Y triggers smart merge)
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "assert-consistent", verify: verifyFinalState }
]
};

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,65 +0,0 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
function verifyBothContents(state: ClientState): void {
const files = Array.from(state.files.keys());
// Both documents were renamed to C.md. One gets C.md, the other should
// be deconflicted. Both contents must be preserved.
assert(
state.files.size === 2,
`Expected 2 files (both documents preserved), got ${state.files.size}: ${files.join(", ")}`
);
// Neither A.md nor B.md should exist (both were renamed away)
assert(
!state.files.has("A.md"),
`A.md should not exist after rename, got: ${files.join(", ")}`
);
assert(
!state.files.has("B.md"),
`B.md should not exist after rename, got: ${files.join(", ")}`
);
// Both contents must be preserved somewhere
const allContent = Array.from(state.files.values()).join("\n");
assert(
allContent.includes("content-a") && allContent.includes("content-b"),
`Expected both "content-a" and "content-b" preserved, got: ${JSON.stringify(Object.fromEntries(state.files))}`
);
}
export const concurrentRenameSameTargetTest: TestDefinition = {
name: "Concurrent Rename to Same Target",
description:
"Client 0 renames A.md to C.md while Client 1 (offline) renames B.md to C.md. " +
"Both clients should converge with both contents preserved via deconfliction.",
clients: 2,
steps: [
// Setup: create A.md and B.md, sync both
{ type: "create", client: 0, path: "A.md", content: "content-a" },
{ type: "create", client: 0, path: "B.md", content: "content-b" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
// Client 1 goes offline
{ type: "disable-sync", client: 1 },
// Client 0 renames A.md to C.md and syncs
{ type: "rename", client: 0, oldPath: "A.md", newPath: "C.md" },
{ type: "sync", client: 0 },
// Client 1 renames B.md to C.md while offline
{ type: "rename", client: 1, oldPath: "B.md", newPath: "C.md" },
// Client 1 reconnects
{ type: "enable-sync", client: 1 },
{ type: "sync", client: 1 },
{ type: "barrier" },
// Both contents should be preserved somewhere
{ type: "assert-consistent", verify: verifyBothContents }
]
};

View file

@ -1,66 +0,0 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
/**
* Invariant #7: parentVersionId must be consistent with cached content.
*
* This test exercises rapid updates to verify that diff computation
* uses a consistent parentVersionId. Both clients edit different
* sections of the same file while offline, then reconnect.
*/
function verifyBothEdits(state: ClientState): void {
assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`);
const content = state.files.get("doc.md") ?? "";
assert(
content.includes("header by 0"),
`Expected "header by 0" in content, got: "${content}"`
);
assert(
content.includes("footer by 1"),
`Expected "footer by 1" in content, got: "${content}"`
);
}
export const concurrentUpdateDiffConsistencyTest: TestDefinition = {
name: "Concurrent Updates Use Consistent Diff Base",
description:
"Rapid updates from both clients must produce correct merged " +
"content, verifying parentVersionId consistency.",
clients: 2,
steps: [
{
type: "create",
client: 0,
path: "doc.md",
content: "header\nmiddle\nfooter"
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
// Both edit different sections offline
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
{
type: "update",
client: 0,
path: "doc.md",
content: "header by 0\nmiddle\nfooter"
},
{
type: "update",
client: 1,
path: "doc.md",
content: "header\nmiddle\nfooter by 1"
},
// Come online
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "assert-consistent", verify: verifyBothEdits }
]
};

View file

@ -1,29 +0,0 @@
import type { TestDefinition } from "../test-definition";
export const createDeleteNoopTest: TestDefinition = {
name: "Create-Delete Noop",
description:
"Client 0 (offline) creates a file, updates it multiple times, then deletes it. " +
"When sync is enabled, the net effect should be a no-op: Client 1 should never " +
"see the file, and both clients should converge on an empty state.",
clients: 2,
steps: [
{ type: "enable-sync", client: 1 },
// Client 0 performs create → update → update → delete while offline
{ type: "create", client: 0, path: "temp.md", content: "version 1" },
{ type: "update", client: 0, path: "temp.md", content: "version 2" },
{ type: "update", client: 0, path: "temp.md", content: "version 3" },
{ type: "delete", client: 0, path: "temp.md" },
// Enable sync — reconciliation should find nothing to do
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "barrier" },
// Neither client should have the file
{ type: "assert-not-exists", client: 0, path: "temp.md" },
{ type: "assert-not-exists", client: 1, path: "temp.md" },
{ type: "assert-consistent" }
]
};

View file

@ -1,93 +0,0 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
/**
* EDGE CASE: New file created during offline reconciliation.
*
* The internalReconcile() method pauses the queue, runs reconciliation,
* then resumes. But file changes can happen DURING reconciliation:
*
* 1. Client goes offline, creates files A.md and B.md
* 2. Client reconnects internalReconcile starts
* 3. reconcileWithDisk scans filesystem, finds A.md and B.md
* 4. Events are enqueued for both files
* 5. Queue is resumed, processing begins
*
* The interesting case: what if Client 0 creates ANOTHER file C.md
* right after reconnect but before reconciliation finishes? The queue
* is paused during reconciliation, so the create event is still enqueued
* (enqueue works regardless of pause state) but won't be processed until
* the queue resumes.
*
* This test verifies that all three files eventually sync correctly.
*/
function verifyAllFiles(state: ClientState): void {
assert(
state.files.size === 3,
`Expected 3 files, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
);
assert(
state.files.has("A.md") &&
state.files.has("B.md") &&
state.files.has("C.md"),
`Expected A.md, B.md, C.md. Got: ${Array.from(state.files.keys()).join(", ")}`
);
assert(
state.files.get("A.md") === "offline A",
`Expected A.md = "offline A", got: "${state.files.get("A.md")}"`
);
assert(
state.files.get("B.md") === "offline B",
`Expected B.md = "offline B", got: "${state.files.get("B.md")}"`
);
assert(
state.files.get("C.md") === "post-reconnect C",
`Expected C.md = "post-reconnect C", got: "${state.files.get("C.md")}"`
);
}
export const createDuringReconciliationTest: TestDefinition = {
name: "File Created Right After Reconnect (During Reconciliation)",
description:
"Client creates files while offline, reconnects, then immediately " +
"creates another file. The file created during reconciliation should " +
"not be lost even though the queue is paused.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
// Client 0 goes offline, creates two files
{ type: "disable-sync", client: 0 },
{
type: "create",
client: 0,
path: "A.md",
content: "offline A"
},
{
type: "create",
client: 0,
path: "B.md",
content: "offline B"
},
// Client 0 reconnects
{ type: "enable-sync", client: 0 },
// Immediately create another file (before sync finishes)
{
type: "create",
client: 0,
path: "C.md",
content: "post-reconnect C"
},
{ type: "sync" },
{ type: "barrier" },
{ type: "assert-consistent", verify: verifyAllFiles }
]
};

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,83 +0,0 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
/**
* BUG: create rename create at same path while offline.
*
* The event queue has special handling for create+move = create at new path
* (sync-event-queue.ts line 56-68), which migrates the key from the old
* path to the new path. This frees the old path key for a subsequent create.
*
* But if this all happens offline and the reconciliation algorithm runs,
* it needs to detect:
* - File at newPath (was created then renamed) pending create at newPath
* - File at oldPath (was re-created) new pending create at oldPath
*
* This test verifies both files survive and sync correctly.
*/
function verifyBothFiles(state: ClientState): void {
assert(
state.files.size === 2,
`Expected 2 files, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
);
assert(
state.files.has("A.md"),
`Expected A.md to exist, files: ${Array.from(state.files.keys()).join(", ")}`
);
assert(
state.files.has("B.md"),
`Expected B.md to exist, files: ${Array.from(state.files.keys()).join(", ")}`
);
assert(
state.files.get("A.md") === "second file at A",
`Expected A.md = "second file at A", got: "${state.files.get("A.md")}"`
);
assert(
state.files.get("B.md") === "first file moved to B",
`Expected B.md = "first file moved to B", got: "${state.files.get("B.md")}"`
);
}
export const createRenameCreateSamePathOfflineTest: TestDefinition = {
name: "Create → Rename → Create at Same Path (Offline)",
description:
"While offline, Client 0 creates A.md, renames it to B.md, then " +
"creates a new A.md. Both files should sync to Client 1.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
// Client 0 goes offline
{ type: "disable-sync", client: 0 },
// Create A.md
{
type: "create",
client: 0,
path: "A.md",
content: "first file moved to B"
},
// Rename A.md → B.md
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
// Create a new A.md
{
type: "create",
client: 0,
path: "A.md",
content: "second file at A"
},
// Reconnect
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "barrier" },
// Both files should exist on both clients
{ type: "assert-consistent", verify: verifyBothFiles }
]
};

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

View file

@ -1,50 +0,0 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
function verifyFinalContent(state: ClientState): void {
assert(
state.files.size === 1,
`Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
);
assert(
state.files.has("doc.md"),
`Expected doc.md to exist`
);
const content = state.files.get("doc.md") ?? "";
assert(
content === "final version",
`Expected doc.md to have "final version", got: "${content}"`
);
}
export const createUpdateCoalesceServerPauseTest: TestDefinition = {
name: "Create + Update Coalescing During Server Pause",
description:
"Client 0 creates a file and immediately updates it while the server " +
"is paused. Both operations should coalesce in the queue. When the " +
"server resumes, the final content should be the updated version.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
// Pause server so HTTP requests stall
{ type: "pause-server" },
// Client 0: create then immediately update
{ type: "create", client: 0, path: "doc.md", content: "initial" },
{ type: "update", client: 0, path: "doc.md", content: "final version" },
// Wait a bit for requests to queue up
// Resume server
{ type: "resume-server" },
// Both sync
{ type: "sync" },
{ type: "barrier" },
// Final state: doc.md with "final version" on both clients
{ type: "assert-consistent", verify: verifyFinalContent }
]
};

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

View file

@ -1,27 +0,0 @@
import type { TestDefinition } from "../test-definition";
export const deleteNonexistentFileTest: TestDefinition = {
name: "Delete Propagation",
description:
"Both clients have A.md. Client 0 deletes it and syncs. Client 1 receives " +
"the delete via broadcast. Both clients should converge on an empty state.",
clients: 2,
steps: [
// Setup: create and sync
{ type: "create", client: 0, path: "A.md", content: "ephemeral" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
// Client 0 deletes and syncs
{ type: "delete", client: 0, path: "A.md" },
{ type: "sync" },
{ type: "barrier" },
// Both should agree A.md is gone
{ type: "assert-not-exists", client: 0, path: "A.md" },
{ type: "assert-not-exists", client: 1, path: "A.md" },
{ type: "assert-consistent" }
]
};

View file

@ -1,48 +1,21 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
function verifyConvergence(state: ClientState): void {
const files = Array.from(state.files.keys()).sort();
// A.md should exist — the recreate creates a new document
assert(
state.files.has("A.md"),
`Expected A.md to exist. Files: ${files.join(", ")}`
);
const content = state.files.get("A.md") ?? "";
// The recreated content must be present. Client 1's update targeted
// the old (deleted) document, so it may also appear if the server
// merged both — but at minimum the recreated content must survive.
assert(
content.includes("recreated"),
`Expected A.md to contain "recreated" from client 0's recreate, got: "${content}"`
);
}
import type { TestDefinition } from "../test-definition";
export const deleteRecreateConcurrentUpdateTest: TestDefinition = {
name: "Delete + Recreate with Concurrent Remote Update",
description:
"Client 0 deletes A.md and recreates it with new content while offline. " +
"Client 1 (online) updates A.md with different content. When Client 0 " +
"reconnects, the system must reconcile the delete-recreate with the " +
"concurrent update. Both clients must converge.",
"Client 0 deletes and recreates A.md with new content while offline. Client 1 updates A.md concurrently. " +
"After client 0 reconnects, both clients must converge with client 0's recreated content preserved.",
clients: 2,
steps: [
// Setup
{ type: "create", client: 0, path: "A.md", content: "original" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
// Client 0 goes offline, deletes and recreates
{ type: "disable-sync", client: 0 },
{ type: "delete", client: 0, path: "A.md" },
{ type: "create", client: 0, path: "A.md", content: "recreated by client 0" },
// Client 1 updates the same file concurrently
{
type: "update",
client: 1,
@ -51,12 +24,10 @@ export const deleteRecreateConcurrentUpdateTest: TestDefinition = {
},
{ type: "sync", client: 1 },
// Client 0 reconnects
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "barrier" },
// Both clients must converge
{ type: "assert-consistent", verify: verifyConvergence }
{ type: "assert-consistent", verify: (s) => s.assertFileExists("A.md").assertContains("A.md", "recreated") }
]
};

View file

@ -1,55 +1,11 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
/**
* EDGE CASE: Delete and immediately recreate at the same path with
* different content, while the other client is editing.
*
* This exercises the coalescing path: delete + create = create.
* But the tricky part is that the ORIGINAL document at this path
* was tracked (had a documentId). The delete marks it as deleted-locally.
* The subsequent create makes a NEW pending document at the same path.
*
* Meanwhile, Client 1 has been editing the same file. When both sync:
* - Client 0's delete should go through first
* - Client 0's create creates a NEW document on the server
* - Client 1's edit to the OLD document may conflict
*
* The coalescing turns delete+create into just "create". But the executor
* for "create" at sync-actions.ts line 247 checks the VFS: if a tracked
* doc exists at the path, it treats the create as an update instead.
* Since the delete was coalesced away, the tracked doc STILL exists
* in the VFS at the time of execution the "create" is treated as an
* update to the existing document, not a new document.
*
* This might be correct (updates the existing doc with new content) or
* might be a bug (should create a new documentId). The test verifies
* convergence either way.
*/
function verifyFinalState(state: ClientState): void {
assert(
state.files.size === 1,
`Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
);
assert(state.files.has("A.md"), "Expected A.md to exist");
const content = state.files.get("A.md") ?? "";
// Both client contents should be merged (empty-parent 3-way merge)
assert(
content.includes("brand new content") &&
content.includes("edit from client 1"),
`Expected merged content with both edits, got: "${content}"`
);
}
import type { TestDefinition } from "../test-definition";
export const deleteRecreateDifferentContentTest: TestDefinition = {
name: "Delete + Recreate Same Path While Other Client Edits",
description:
"Client 0 deletes and recreates A.md with new content while " +
"Client 1 edits A.md. The coalesced delete+create should produce " +
"correct behavior and both clients should converge.",
"Client 0 deletes and recreates A.md with new content offline while client 1 edits A.md offline. " +
"Both clients should converge with content from both sides merged.",
clients: 2,
steps: [
// Setup: create A.md
{
type: "create",
client: 0,
@ -61,11 +17,9 @@ export const deleteRecreateDifferentContentTest: TestDefinition = {
{ type: "sync" },
{ type: "barrier" },
// Both go offline
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
// Client 0: delete and recreate with new content
{ type: "delete", client: 0, path: "A.md" },
{
type: "create",
@ -74,7 +28,6 @@ export const deleteRecreateDifferentContentTest: TestDefinition = {
content: "brand new content"
},
// Client 1: edit the same file
{
type: "update",
client: 1,
@ -82,13 +35,12 @@ export const deleteRecreateDifferentContentTest: TestDefinition = {
content: "edit from client 1"
},
// Reconnect both
{ type: "enable-sync", client: 0 },
{ type: "sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "assert-consistent", verify: verifyFinalState }
{ 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";
export const deleteRecreateSamePathTest: TestDefinition = {
name: "Delete Then Recreate at Same Path",
description:
"Client 0 creates A.md, syncs. Then deletes A.md and creates a new A.md " +
"with different content. Both clients should converge on the new content.",
clients: 2,
steps: [
// Setup: create and sync A.md
{ type: "create", client: 0, path: "A.md", content: "version 1" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "assert-content", client: 1, path: "A.md", content: "version 1" },
{ 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: "delete", client: 0, path: "A.md" },
{ type: "create", client: 0, path: "A.md", content: "version 2" },
@ -23,21 +20,6 @@ export const deleteRecreateSamePathTest: TestDefinition = {
{ type: "sync" },
{ type: "barrier" },
// Both clients should have the new content
{ type: "assert-exists", client: 0, path: "A.md" },
{ type: "assert-exists", client: 1, path: "A.md" },
{
type: "assert-content",
client: 0,
path: "A.md",
content: "version 2"
},
{
type: "assert-content",
client: 1,
path: "A.md",
content: "version 2"
},
{ type: "assert-consistent" }
{ type: "assert-consistent", verify: (s) => s.assertContent("A.md", "version 2") }
]
};

View file

@ -1,71 +1,34 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
function verifyConflictResolution(state: ClientState): void {
const files = Array.from(state.files.keys());
// B.md must exist (unaffected by the conflict)
assert(
state.files.has("B.md"),
`Expected B.md to exist, got: ${files.join(", ")}`
);
assert(
state.files.get("B.md") === "content-b",
`Expected B.md to have "content-b", got: "${state.files.get("B.md")}"`
);
// A.md should not exist (either deleted or renamed away)
assert(
!state.files.has("A.md"),
`A.md should not exist after conflict resolution, got: ${files.join(", ")}`
);
// If C.md exists (rename won over delete), it should have content-a
if (state.files.has("C.md")) {
assert(
state.files.get("C.md") === "content-a",
`If C.md exists, it should have "content-a", got: "${state.files.get("C.md")}"`
);
}
}
import type { TestDefinition } from "../test-definition";
export const deleteRenameConflictTest: TestDefinition = {
name: "Delete vs Rename Conflict",
description:
"Client 0 deletes A.md while Client 1 (offline) renames A.md to C.md. " +
"When Client 1 reconnects, the system must reconcile the conflicting " +
"operations. Both clients should converge to the same state.",
"Client 0 deletes A.md while client 1 renames A.md to C.md offline. " +
"After client 1 reconnects, both clients should converge to the same state.",
clients: 2,
steps: [
// Setup: create A.md and B.md, sync to both clients
{ type: "create", client: 0, path: "A.md", content: "content-a" },
{ type: "create", client: 0, path: "B.md", content: "content-b" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "assert-exists", client: 1, path: "A.md" },
{ type: "assert-exists", client: 1, path: "B.md" },
{ type: "assert-consistent", verify: (s) => s.assertFileExists("A.md").assertFileExists("B.md") },
// Client 1 goes offline
{ type: "disable-sync", client: 1 },
// Client 0 deletes A.md and syncs
{ type: "delete", client: 0, path: "A.md" },
{ type: "sync", client: 0 },
// Client 1 (offline) renames A.md to C.md
{ type: "rename", client: 1, oldPath: "A.md", newPath: "C.md" },
// Client 1 reconnects
{ type: "enable-sync", client: 1 },
{ type: "sync", client: 1 },
{ type: "barrier" },
// Both clients must converge — the key invariant is consistency.
// B.md should still exist on both (unaffected by the conflict).
{ type: "assert-exists", client: 0, path: "B.md" },
{ type: "assert-exists", client: 1, path: "B.md" },
{ type: "assert-consistent", verify: verifyConflictResolution }
{ type: "assert-consistent", verify: (s) => {
s.assertContent("B.md", "content-b");
s.assertFileNotExists("A.md");
s.ifFileExists("C.md", (s) => s.assertContent("C.md", "content-a"));
} },
]
};

View file

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

View file

@ -1,41 +0,0 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
function verifyBothFilesExist(state: ClientState): void {
assert(
state.files.size === 2,
`Expected 2 files, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
);
assert(state.files.has("original.md"), "Expected original.md to exist");
assert(state.files.has("copy.md"), "Expected copy.md to exist");
assert(
state.files.get("original.md") === "same content",
`original.md has wrong content: "${state.files.get("original.md")}"`
);
assert(
state.files.get("copy.md") === "same content",
`copy.md has wrong content: "${state.files.get("copy.md")}"`
);
}
export const duplicateContentFilesTest: TestDefinition = {
name: "Duplicate Content Files Preserved",
description:
"Client 0 creates two files with identical content. Both should sync " +
"to Client 1 without the duplicate detection deleting one of them.",
clients: 2,
steps: [
// Create two files with identical content while offline
{ type: "create", client: 0, path: "original.md", content: "same content" },
{ type: "create", client: 0, path: "copy.md", content: "same content" },
// Enable sync
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
// Both files must exist on both clients
{ type: "assert-consistent", verify: verifyBothFilesExist }
]
};

View file

@ -1,49 +0,0 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
function verifyEmptyFile(state: ClientState): void {
assert(state.files.has("empty.md"), "Expected empty.md to exist");
assert(
state.files.get("empty.md") === "",
`Expected empty.md to be empty, got: "${state.files.get("empty.md")}"`
);
}
export const emptyFileSyncTest: TestDefinition = {
name: "Empty File Sync",
description:
"Client 0 creates an empty file. It should sync to Client 1 as empty. " +
"Then Client 0 adds content. The update should propagate correctly.",
clients: 2,
steps: [
// Create empty file
{ type: "create", client: 0, path: "empty.md", content: "" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
// Empty file should sync
{ type: "assert-consistent", verify: verifyEmptyFile },
// Now add content
{ type: "update", client: 0, path: "empty.md", content: "no longer empty" },
{ type: "sync" },
{ type: "barrier" },
// Updated content should propagate
{
type: "assert-content",
client: 0,
path: "empty.md",
content: "no longer empty"
},
{
type: "assert-content",
client: 1,
path: "empty.md",
content: "no longer empty"
},
{ type: "assert-consistent" }
]
};

View file

@ -1,34 +1,11 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
/**
* Tests rename-overwrite behavior: when file A is renamed to file B's
* path (overwriting B), both clients should converge on a single file
* at the target path with A's content.
*/
function verifyOneFile(state: ClientState): void {
assert(
state.files.size === 1,
`Expected 1 file, got ${state.files.size}: ${[...state.files.keys()].join(", ")}`
);
assert(
state.files.has("B.md"),
`Expected B.md to exist, got: ${[...state.files.keys()].join(", ")}`
);
assert(
state.files.get("B.md") === "content A",
`Expected B.md to have A's content, got: "${state.files.get("B.md")}"`
);
}
import type { TestDefinition } from "../test-definition";
export const failedVfsMoveFallsBackTest: TestDefinition = {
name: "Rename Overwrite — A.md Renamed to Occupied B.md",
description:
"File A is renamed to B's path (overwriting B). Both clients " +
"should converge on a single file at B.md with A's content.",
clients: 2,
steps: [
// Setup: create two files
{ type: "create", client: 0, path: "A.md", content: "content A" },
{ type: "create", client: 0, path: "B.md", content: "content B" },
{ type: "enable-sync", client: 0 },
@ -36,12 +13,10 @@ export const failedVfsMoveFallsBackTest: TestDefinition = {
{ type: "sync" },
{ type: "barrier" },
// Client 0 renames A.md to B.md (overwrite)
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
{ type: "sync" },
{ type: "barrier" },
// Both clients should have only B.md
{ type: "assert-consistent", verify: verifyOneFile }
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("B.md", "content A") }
]
};

View file

@ -1,55 +1,24 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
function verifyNoDuplicates(state: ClientState): void {
assert(
state.files.size === 1,
`Expected exactly 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
);
assert(
state.files.has("doc.md"),
`Expected doc.md to exist, got: ${Array.from(state.files.keys()).join(", ")}`
);
const content = state.files.get("doc.md") ?? "";
assert(
content === "important data",
`Expected doc.md content to be "important data", got: "${content}"`
);
}
import type { TestDefinition } from "../test-definition";
export const idempotencyAfterServerPauseTest: TestDefinition = {
name: "Idempotency Key Prevents Duplicates After Server Pause",
description:
"Client 0 creates a file. The server is paused mid-response (SIGSTOP), " +
"so the client's HTTP request stalls. When the server resumes, the " +
"idempotency key should prevent duplicate documents from being created. " +
"Both clients must converge to a single copy of the file.",
"Client 0 creates a file, then the server is paused mid-response. " +
"After the server resumes, both clients must converge to a single copy of the file with no duplicates.",
clients: 2,
steps: [
// Both clients online
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
// Client 0 creates a file, then immediately pause the server so the
// response is stalled (the server may or may not have committed the
// create — either way the idempotency key protects us).
{ type: "create", client: 0, path: "doc.md", content: "important data" },
{ type: "pause-server" },
// Wait with server frozen — client's in-flight create request is stuck.
// Resume the server. The stalled request completes (or the client
// retries with the same idempotency key).
{ type: "resume-server" },
// Sync and converge
{ type: "sync" },
{ type: "barrier" },
// There must be exactly one doc.md with the correct content — no
// duplicates like "doc (1).md".
{ type: "assert-consistent", verify: verifyNoDuplicates }
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("doc.md", "important data") }
]
};

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

View file

@ -1,45 +1,9 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
/**
* BUG: Queue key migration can drop events when the new key already has events.
*
* In sync-event-queue.ts line 94-98, migrateKey() silently drops events
* from the old key if the new key (documentId) already has queued events.
* The comment says "Keep the existing state at the new key (it's more
* recent)" but the old key's state may contain unsynced local changes.
*
* Scenario:
* 1. Client creates file A.md (pending, key = "path:A.md")
* 2. Server assigns documentId via resolveIdempotencyKeys
* 3. BEFORE the key migration, a local-update event for A.md arrives
* and gets queued under "path:A.md" (because the doc is still pending
* at that point in the resolveKey lookup)
* 4. Meanwhile, a remote-update broadcast arrives for the same documentId
* and gets queued under the documentId key
* 5. migrateKey runs: old key has "update", new key has "remote-update"
* 6. The old key's "update" is DROPPED the local edit is lost
*
* This test simulates a similar scenario: Client 0 creates a file and
* immediately updates it. While the create is being resolved, the update
* should not be lost.
*/
function verifyUpdatedContent(state: ClientState): void {
assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`);
assert(state.files.has("A.md"), "Expected A.md to exist");
const content = state.files.get("A.md") ?? "";
assert(
content === "updated content",
`Expected "updated content", got: "${content}"`
);
}
import type { TestDefinition } from "../test-definition";
export const keyMigrationEventDropTest: TestDefinition = {
name: "Key Migration Does Not Drop Local Updates",
description:
"Client creates a file and immediately updates it before the create " +
"is acknowledged. The queue key migrates from path-based to documentId. " +
"The local update should not be lost during key migration.",
"Client 0 creates a file and immediately updates it while the server is paused. " +
"After resume, both clients should have the updated content.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
@ -47,10 +11,8 @@ export const keyMigrationEventDropTest: TestDefinition = {
{ type: "sync" },
{ type: "barrier" },
// Pause server so create request stalls
{ type: "pause-server" },
// Client 0 creates file, then immediately updates it
{
type: "create",
client: 0,
@ -64,12 +26,10 @@ export const keyMigrationEventDropTest: TestDefinition = {
content: "updated content"
},
// Resume server — create completes, update should follow
{ type: "resume-server" },
{ type: "sync" },
{ type: "barrier" },
// The updated content should be on both clients, not the initial
{ type: "assert-consistent", verify: verifyUpdatedContent }
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("A.md", "updated content") }
]
};

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

View file

@ -1,109 +1,45 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
/**
* Edge case: Both clients create files at DIFFERENT paths, then both rename
* their respective files to the SAME target path.
*
* Timeline:
* 1. Client 0 creates X.md, Client 1 creates Y.md (both offline).
* 2. Both enable sync, converge (X.md and Y.md exist on both).
* 3. Client 1 goes offline.
* 4. Client 0 renames X.md -> Z.md, syncs.
* 5. Client 1 (offline) renames Y.md -> Z.md.
* 6. Client 1 reconnects.
*
* The tricky part: Both renames target Z.md. Client 0's rename completes first
* on the server. When Client 1 reconnects and tries to rename Y.md -> Z.md,
* the server already has a document at Z.md (formerly X.md). The system must
* use path deconfliction (e.g., Z (1).md) to preserve both documents' content.
*
* This differs from the existing concurrent-rename-same-target test because
* the files START at different paths (not A.md/B.md created by the same client)
* and the creates themselves are concurrent, exercising the interaction between
* concurrent create-merge and rename-deconfliction.
*/
function verifyBothContentsPreserved(state: ClientState): void {
const allContent = Array.from(state.files.values()).join("\n");
assert(
allContent.includes("content-x"),
`Expected "content-x" to be preserved somewhere. ` +
`Files: ${JSON.stringify(Object.fromEntries(state.files))}`
);
assert(
allContent.includes("content-y"),
`Expected "content-y" to be preserved somewhere. ` +
`Files: ${JSON.stringify(Object.fromEntries(state.files))}`
);
// Neither X.md nor Y.md should exist (both were renamed away)
assert(
!state.files.has("X.md"),
`Expected X.md to not exist (was renamed). ` +
`Files: ${Array.from(state.files.keys()).join(", ")}`
);
assert(
!state.files.has("Y.md"),
`Expected Y.md to not exist (was renamed). ` +
`Files: ${Array.from(state.files.keys()).join(", ")}`
);
// At least one file should be at Z.md
assert(
state.files.has("Z.md"),
`Expected Z.md to exist. ` +
`Files: ${Array.from(state.files.keys()).join(", ")}`
);
// There must be exactly 2 files (both contents preserved, possibly deconflicted)
assert(
state.files.size === 2,
`Expected exactly 2 files, got ${state.files.size}: ` +
Array.from(state.files.keys()).join(", ")
);
}
import type { TestDefinition } from "../test-definition";
export const mcCrossCreateRenameSameTargetTest: TestDefinition = {
name: "MC: Cross-Create then Rename to Same Target",
description:
"Client 0 creates X.md, Client 1 creates Y.md. Both sync. Client 0 renames " +
"X.md -> Z.md. Client 1 (offline) renames Y.md -> Z.md. Both must converge " +
"with both contents preserved via path deconfliction.",
clients: 2,
steps: [
// Phase 1: Both create files offline at different paths
{ type: "create", client: 0, path: "X.md", content: "content-x" },
{ type: "create", client: 1, path: "Y.md", content: "content-y" },
// Both enable sync — creates race to server
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
// Verify both files exist on both clients
{ type: "assert-exists", client: 0, path: "X.md" },
{ type: "assert-exists", client: 0, path: "Y.md" },
{ type: "assert-exists", client: 1, path: "X.md" },
{ type: "assert-exists", client: 1, path: "Y.md" },
{
type: "assert-consistent",
verify: (s) => s.assertFileExists("X.md").assertFileExists("Y.md")
},
// Phase 2: Client 1 goes offline
{ type: "disable-sync", client: 1 },
// Phase 3: Client 0 renames X.md -> Z.md and syncs
{ type: "rename", client: 0, oldPath: "X.md", newPath: "Z.md" },
{ type: "sync", client: 0 },
// Phase 4: Client 1 (offline) renames Y.md -> Z.md
{ type: "rename", client: 1, oldPath: "Y.md", newPath: "Z.md" },
// Phase 5: Client 1 reconnects
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
// Both contents must be preserved, both clients consistent
{ type: "assert-consistent", verify: verifyBothContentsPreserved }
{
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 { 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}"`
);
}
}
import type { TestDefinition } from "../test-definition";
export const mcDeleteThenOfflineRenameTest: TestDefinition = {
name: "MC: Delete Synced Then Offline Rename",
description:
"Client 0 creates A.md, both sync. Client 1 goes offline. Client 0 deletes " +
"A.md and syncs. Client 1 (offline) renames A.md to B.md. Client 1 reconnects. " +
"Both must converge. C.md (unrelated) must be unaffected.",
clients: 2,
steps: [
// Phase 1: Client 0 creates A.md and C.md, both sync
{ type: "create", client: 0, path: "A.md", content: "original" },
{ type: "create", client: 0, path: "C.md", content: "unrelated" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "assert-content", client: 1, path: "A.md", content: "original" },
{ type: "assert-content", client: 1, path: "C.md", content: "unrelated" },
// Phase 2: Client 1 goes offline
{ type: "disable-sync", client: 1 },
// Phase 3: Client 0 deletes A.md and syncs
{ type: "delete", client: 0, path: "A.md" },
{ type: "sync", client: 0 },
{ type: "assert-not-exists", client: 0, path: "A.md" },
// Phase 4: Client 1 (offline) renames A.md -> B.md
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
// Phase 5: Client 1 reconnects
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
// Both must converge — key assertions
{ type: "assert-consistent", verify: verifyState }
{
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 { 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")}"`
);
}
}
import type { TestDefinition } from "../test-definition";
export const mcMultiDeleteOfflineRenameTest: TestDefinition = {
name: "MC: Multi-File Delete + Offline Rename",
description:
"Client 0 creates 5 files. Client 1 deletes 2 while Client 0 (offline) " +
"renames one of the deleted files. Both must converge.",
@ -53,23 +16,28 @@ export const mcMultiDeleteOfflineRenameTest: TestDefinition = {
{ type: "sync" },
{ type: "barrier" },
// Client 0 goes offline
{ type: "disable-sync", client: 0 },
// Client 1 deletes file-2 and file-4
{ type: "delete", client: 1, path: "file-2.md" },
{ type: "delete", client: 1, path: "file-4.md" },
{ type: "sync", client: 1 },
// Client 0 (offline) renames file-2
{ type: "rename", client: 0, oldPath: "file-2.md", newPath: "renamed.md" },
// Client 0 reconnects
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "barrier" },
// Both must converge
{ type: "assert-consistent", verify: verifyState }
{
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 { 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}"`
);
}
import type { TestDefinition } from "../test-definition";
export const mcThreeClientRenameOfflineUpdateTest: TestDefinition = {
name: "MC: Three-Client Rename + Offline Update",
description:
"Client 0 creates A.md. Client 1 renames to B.md. Client 2 (offline) " +
"updates A.md. All three converge with updated content at B.md.",
clients: 3,
steps: [
// Phase 1: Client 0 creates A.md, everyone syncs
{ type: "create", client: 0, path: "A.md", content: "original" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
@ -41,26 +13,18 @@ export const mcThreeClientRenameOfflineUpdateTest: TestDefinition = {
{ type: "sync" },
{ type: "barrier" },
// Phase 2: Client 2 goes offline
{ type: "disable-sync", client: 2 },
// Phase 3: Client 1 renames A.md -> B.md, clients 0 and 1 sync
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
{ type: "sync", client: 1 },
{ type: "sync", client: 0 },
// Don't use barrier here — Client 2 is offline and can't converge
{ type: "assert-not-exists", client: 0, path: "A.md" },
{ type: "assert-exists", client: 0, path: "B.md" },
// Phase 4: Client 2 updates its local A.md while offline
{ type: "update", client: 2, path: "A.md", content: "updated-by-client-2" },
// Phase 5: Client 2 reconnects
{ type: "enable-sync", client: 2 },
{ type: "sync" },
{ type: "barrier" },
// All three must converge
{ type: "assert-consistent", verify: verifyState }
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertFileNotExists("A.md").assertContains("B.md", "updated-by-client-2") }
]
};

View file

@ -1,35 +1,9 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
/**
* BUG FIX: migrateKey must not overwrite existing state at the new key.
*
* Scenario:
* 1. Client 0 creates file A.md, then immediately updates it
* 2. Server is paused so the create stalls (idempotency key unresolved)
* 3. Client 1 is online and also creates at A.md (different content)
* 4. Server resumes both creates merge
* 5. Client 0's update should not be lost during key migration
*
* The test verifies that after convergence, the file exists with
* content from both clients' edits.
*/
function verifyContent(state: ClientState): void {
assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`);
assert(state.files.has("A.md"), "Expected A.md to exist");
const content = state.files.get("A.md") ?? "";
// Client 0's update should be present
assert(
content.includes("updated by client 0"),
`Expected content to include "updated by client 0", got: "${content}"`
);
}
import type { TestDefinition } from "../test-definition";
export const migrateKeyPreservesExistingTest: TestDefinition = {
name: "Key Migration Preserves Existing Queue State",
description:
"When migrateKey is called and the new key already has queued " +
"events, the existing events must not be silently dropped.",
"Client 0 creates a file and immediately updates it while the server is paused. " +
"After resume, the update must not be lost.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
@ -37,10 +11,8 @@ export const migrateKeyPreservesExistingTest: TestDefinition = {
{ type: "sync" },
{ type: "barrier" },
// Pause server so create stalls
{ type: "pause-server" },
// Client 0 creates and immediately updates
{ type: "create", client: 0, path: "A.md", content: "initial" },
{
type: "update",
@ -49,11 +21,10 @@ export const migrateKeyPreservesExistingTest: TestDefinition = {
content: "updated by client 0"
},
// Resume server
{ type: "resume-server" },
{ type: "sync" },
{ 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 { assert } from "../utils/assert";
import type { TestDefinition } from "../test-definition";
function verifyBothContentAndPath(state: ClientState): void {
// The file should be at B.md (Client 0 renamed it)
// AND should contain Client 1's updated content (merged with original)
const files = Array.from(state.files.keys());
assert(
state.files.has("B.md"),
`Expected B.md to exist, got: ${files.join(", ")}`
);
assert(
!state.files.has("A.md"),
`A.md should not exist after rename, got: ${files.join(", ")}`
);
assert(
state.files.size === 1,
`Expected exactly 1 file, got ${state.files.size}: ${files.join(", ")}`
);
const content = state.files.get("B.md") ?? "";
// Client 1 updated the content to include "updated by client 1"
// The 3-way merge should preserve this update at the renamed path
assert(
content.includes("updated by client 1"),
`Expected B.md to contain "updated by client 1" from the remote update, got: "${content}"`
);
}
/**
* BUG: Coalescing table says `move + remote-update = move`, which drops
* the remote update content. The local client only sends the rename
* to the server. If the server has no concurrent version to merge with,
* the remote client's update is lost on this client until a forced
* re-sync (runFinalConsistencyCheck).
*
* This test verifies that when Client 0 renames A.md B.md while
* Client 1 simultaneously updates A.md, BOTH the rename and the
* content update are reflected on both clients.
*/
export const moveAndConcurrentRemoteUpdateTest: TestDefinition = {
name: "Move and Concurrent Remote Update",
description:
"Client 0 renames A.md to B.md while Client 1 updates A.md content. " +
"The coalescing table merges move + remote-update into just 'move', " +
"potentially dropping the remote content update. Both clients should " +
"converge to B.md with Client 1's updated content.",
"Client 0 renames A.md to B.md offline while client 1 updates A.md. " +
"After client 0 reconnects, both should have B.md with client 1's updated content.",
clients: 2,
steps: [
// Setup: both clients share A.md
{
type: "create",
client: 0,
@ -58,18 +16,10 @@ export const moveAndConcurrentRemoteUpdateTest: TestDefinition = {
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-content",
client: 1,
path: "A.md",
content: "original content"
},
// Client 0 goes offline and renames A.md → B.md
{ type: "disable-sync", client: 0 },
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
// Client 1 updates A.md while Client 0 is offline
{
type: "update",
client: 1,
@ -78,12 +28,10 @@ export const moveAndConcurrentRemoteUpdateTest: TestDefinition = {
},
{ type: "sync", client: 1 },
// Client 0 comes online — will receive remote-update for A.md
// The move event (A→B) and remote-update should both apply
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "barrier" },
{ type: "assert-consistent", verify: verifyBothContentAndPath }
{ 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 { 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}"`
);
}
import type { TestDefinition } from "../test-definition";
export const movePreservesRemoteUpdateTest: TestDefinition = {
name: "Local Move Preserves Remote Content Update",
description:
"When a user renames a file and another client edits it concurrently, " +
"the content update should not be lost.",
"Client 0 renames a file offline while client 1 edits it offline. " +
"After both reconnect, the renamed file should contain client 1's edit.",
clients: 2,
steps: [
// Setup
{ type: "create", client: 0, path: "doc.md", content: "line 1\nline 2" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
// Both go offline
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
// Client 0 renames, client 1 edits content
{ type: "rename", client: 0, oldPath: "doc.md", newPath: "renamed.md" },
{ type: "update", client: 1, path: "doc.md", content: "line 1\nclient 1 edit\nline 2" },
// Both come online
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "assert-consistent", verify: verifyContentPreserved },
{
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 { 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}"`
);
}
import type { TestDefinition } from "../test-definition";
export const moveRemoteUpdateRevertsRenameTest: TestDefinition = {
name: "Remote Update + Local Move Coalescing May Revert Rename",
description:
"When a remote-update broadcast arrives and the user renames the " +
"file, the coalescing (remote-update + local-move = remote-update) " +
"discards the rename info. The force path may revert the rename " +
"by moving the file back to the server's path.",
"Client 1 updates a file while client 0 is offline. Client 0 reconnects and renames the file. " +
"Both clients should converge with client 1's updated content.",
clients: 2,
steps: [
// Setup: both clients have doc.md
{ type: "create", client: 0, path: "doc.md", content: "original" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
// Client 1 updates the file content (broadcasts to client 0)
{ type: "disable-sync", client: 0 },
{ type: "update", client: 1, path: "doc.md", content: "updated by client 1" },
{ type: "sync", client: 1 },
// Client 0 comes online and renames the file while the remote-update
// is arriving on the WebSocket
{ type: "enable-sync", client: 0 },
{ type: "rename", client: 0, oldPath: "doc.md", newPath: "renamed.md" },
{ type: "sync" },
{ type: "barrier" },
// Both should converge
{ type: "assert-consistent", verify: verifyState }
{
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 { assert } from "../utils/assert";
import type { TestDefinition } from "../test-definition";
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 = {
name: "Move Then Delete (Stale Path Fix)",
description:
"Client 0 creates A.md, syncs. Then renames A.md to B.md and " +
"immediately deletes B.md. The coalesced delete action has the " +
"old path 'A', but the doc is at 'B' in VFS. The delete executor " +
"must use the current VFS path, not the stale action path.",
"Client 0 renames A.md to B.md and immediately deletes B.md. " +
"Both clients should end up with zero files.",
clients: 2,
steps: [
// Setup: create and sync
{
type: "create",
client: 0,
@ -48,25 +16,13 @@ export const moveThenDeleteStalePathTest: TestDefinition = {
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-content",
client: 1,
path: "A.md",
content: "content to delete"
},
// Rename A→B then delete B (with sync enabled so VFS.move fires)
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
{ type: "delete", client: 0, path: "B.md" },
{ type: "sync" },
{ type: "barrier" },
// Both clients should have 0 files
{ type: "assert-not-exists", client: 0, path: "A.md" },
{ type: "assert-not-exists", client: 0, path: "B.md" },
{ type: "assert-not-exists", client: 1, path: "A.md" },
{ type: "assert-not-exists", client: 1, path: "B.md" },
{ type: "assert-consistent", verify: verifyDeleted }
{ type: "assert-consistent", verify: (s) => s.assertFileCount(0).assertFileNotExists("A.md").assertFileNotExists("B.md") }
]
};

View file

@ -1,53 +1,11 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
function verifyState(state: ClientState): void {
const files = Array.from(state.files.keys());
// B.md must exist with updated content from client 1
assert(
state.files.has("B.md"),
`Expected B.md to exist, got: ${files.join(", ")}`
);
const bContent = state.files.get("B.md") ?? "";
assert(
bContent.includes("updated"),
`Expected B.md to contain "updated", got: "${bContent}"`
);
// C.md must exist (created independently, unaffected)
assert(
state.files.has("C.md"),
`Expected C.md to exist, got: ${files.join(", ")}`
);
// A.md should not exist (deleted by client 0 or renamed by client 1)
assert(
!state.files.has("A.md"),
`A.md should not exist, got: ${files.join(", ")}`
);
// D.md: Client 1 renamed the server-deleted A.md to D.md offline.
// The system may keep D.md (rename wins) or drop it (delete wins).
// If D.md exists, it should have the original content.
if (state.files.has("D.md")) {
assert(
state.files.get("D.md") === "content-a",
`If D.md exists, it should have "content-a", got: "${state.files.get("D.md")}"`
);
}
}
import type { TestDefinition } from "../test-definition";
export const multiFileOperationsTest: TestDefinition = {
name: "Multi-File Operations",
description:
"Client 0 creates A.md, B.md, C.md. Both clients sync. Client 1 goes offline. " +
"Client 0 deletes A.md. Client 1 (offline) updates B.md and renames A.md to D.md. " +
"When Client 1 reconnects, the system must reconcile: A.md deleted on server, " +
"renamed on client 1; B.md updated on client 1. Both must converge.",
"Client 0 deletes A.md while client 1 is offline. Client 1 updates B.md and renames A.md to D.md offline. " +
"After client 1 reconnects, both clients must converge with B.md updated and C.md intact.",
clients: 2,
steps: [
// Setup: create three files and sync
{ type: "create", client: 0, path: "A.md", content: "content-a" },
{ type: "create", client: 0, path: "B.md", content: "content-b" },
{ type: "create", client: 0, path: "C.md", content: "content-c" },
@ -56,23 +14,26 @@ export const multiFileOperationsTest: TestDefinition = {
{ type: "sync" },
{ type: "barrier" },
// Client 1 goes offline
{ type: "disable-sync", client: 1 },
// Client 0 deletes A.md and syncs
{ type: "delete", client: 0, path: "A.md" },
{ type: "sync", client: 0 },
// Client 1 (offline) updates B.md and renames A.md to D.md
{ type: "update", client: 1, path: "B.md", content: "updated by client 1" },
{ type: "rename", client: 1, oldPath: "A.md", newPath: "D.md" },
// Client 1 reconnects
{ type: "enable-sync", client: 1 },
{ type: "sync", client: 1 },
{ type: "barrier" },
// Verify convergence: B.md and C.md must exist. B.md must have update.
{ type: "assert-consistent", verify: verifyState }
{
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 { 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}"`
);
}
import type { TestDefinition } from "../test-definition";
export const offlineConcurrentRenamesTest: TestDefinition = {
name: "Offline Concurrent Renames of Same File",
description:
"Client 0 creates A.md and syncs to both clients. Both clients go offline. " +
"Client 0 renames A.md to B.md. Client 1 renames A.md to C.md. " +
@ -43,24 +8,19 @@ export const offlineConcurrentRenamesTest: TestDefinition = {
"agree on the final state and the content must not be lost.",
clients: 2,
steps: [
// Setup: create A.md and sync to both clients
{ type: "create", client: 0, path: "A.md", content: "shared-content" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-content",
client: 1,
path: "A.md",
content: "shared-content"
type: "assert-consistent",
verify: (s) => s.assertContent("A.md", "shared-content")
},
// Both clients go offline
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
// Client 0 renames A.md -> B.md
{
type: "rename",
client: 0,
@ -68,7 +28,6 @@ export const offlineConcurrentRenamesTest: TestDefinition = {
newPath: "B.md"
},
// Client 1 renames A.md -> C.md
{
type: "rename",
client: 1,
@ -76,17 +35,24 @@ export const offlineConcurrentRenamesTest: TestDefinition = {
newPath: "C.md"
},
// Both reconnect
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
// A.md must be gone from both
{ type: "assert-not-exists", client: 0, path: "A.md" },
{ type: "assert-not-exists", client: 1, path: "A.md" },
// Both must converge to the same state with content preserved
{ type: "assert-consistent", verify: verifyConvergence }
{
type: "assert-consistent",
verify: (s) => {
s.assertFileNotExists("A.md")
.assertFileCount(1)
.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 { 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}"`
);
}
import type { TestDefinition } from "../test-definition";
export const offlineCreateSamePathMergeableTest: TestDefinition = {
name: "Offline Create Same Path — Mergeable Text",
description:
"Both clients create a file at the same path while offline with " +
"different text content. When both sync, the server should 3-way " +
"merge the content and both clients should converge to the merged result.",
"Both clients create a file at the same path while offline with different text content. " +
"After both sync, both clients must converge to a merged result containing both contributions.",
clients: 2,
steps: [
// Both clients create at same path while offline
{
type: "create",
client: 0,
@ -60,14 +19,23 @@ export const offlineCreateSamePathMergeableTest: TestDefinition = {
content: "beta wrote this different line"
},
// Enable sync — Client 0 syncs first, then Client 1's create
// triggers a smart merge on the server
{ type: "enable-sync", client: 0 },
{ type: "sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "assert-consistent", verify: verifyMergedContent }
{
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 { 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")}"`
);
}
}
import type { TestDefinition } from "../test-definition";
export const offlineDeleteRemoteRenameTest: TestDefinition = {
name: "Offline Delete + Concurrent Remote Rename",
description:
"Client 0 goes offline and deletes A.md locally. Meanwhile Client 1 " +
"renames A.md to A_renamed.md and syncs. When Client 0 reconnects, " +
"the offline reconciliation discovers A.md is missing locally but the " +
"server has it renamed. The system must converge consistently.",
"Client 0 deletes A.md offline while client 1 renames it to A_renamed.md. " +
"After client 0 reconnects, both clients must converge.",
clients: 2,
steps: [
// Setup
{ type: "create", client: 0, path: "A.md", content: "content-a" },
{ type: "create", client: 0, path: "B.md", content: "content-b" },
{ type: "enable-sync", client: 0 },
@ -48,11 +13,9 @@ export const offlineDeleteRemoteRenameTest: TestDefinition = {
{ type: "sync" },
{ type: "barrier" },
// Client 0 goes offline and deletes A.md
{ type: "disable-sync", client: 0 },
{ type: "delete", client: 0, path: "A.md" },
// Client 1 renames A.md -> A_renamed.md
{
type: "rename",
client: 1,
@ -61,12 +24,19 @@ export const offlineDeleteRemoteRenameTest: TestDefinition = {
},
{ type: "sync", client: 1 },
// Client 0 reconnects
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "barrier" },
// Both clients must converge
{ type: "assert-consistent", verify: verifyConvergence }
{
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 { assert } from "../utils/assert";
import type { TestDefinition } from "../test-definition";
function verifyConsistentState(state: ClientState): void {
// After Client 0 deletes and Client 1 updates the same file,
// both clients must agree. The delete intent should win (user
// explicitly deleted the file) and both clients should converge
// to having no files OR the file re-created.
//
// The coalescing path is: local-update enqueued for Client 1's
// remote broadcast → local-delete arrives → coalesces.
//
// Key assertion: both clients must be consistent, regardless
// of which intent wins.
const files = Array.from(state.files.keys());
// File should NOT exist (delete wins in current implementation)
assert(
state.files.size === 0,
`Expected 0 files after delete-wins resolution, got ${state.files.size}: ${files.join(", ")}`
);
}
/**
* Tests the coalescing path: `remote-update + local-delete → delete`.
*
* When Client 0 comes online after deleting A.md, it receives a
* remote-update broadcast for A.md from Client 1's edit. The
* coalescing must produce a `delete` action (not `remote-delete`
* with isDeleted=false) so the executor properly marks the doc as
* deleted-locally and sends DELETE to the server.
*
* Before the fix: the coalescing produced `remote-delete` with the
* remote-update version (isDeleted=false). The executor treated this
* as a tracked doc update, downloaded the remote content, and
* silently resurrected the file overriding the user's delete.
*/
export const offlineDeleteVsRemoteUpdateTest: TestDefinition = {
name: "Offline Delete vs Remote Update",
description:
"Client 0 deletes A.md while Client 1 updates A.md. Tests the " +
"coalescing of remote-update + local-delete and whether both " +
"clients converge to a consistent state.",
"Client 0 deletes A.md offline while client 1 updates it. Both clients must converge.",
clients: 2,
steps: [
// Setup: both clients share A.md
{
type: "create",
client: 0,
@ -54,17 +16,13 @@ export const offlineDeleteVsRemoteUpdateTest: TestDefinition = {
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-content",
client: 1,
path: "A.md",
content: "original content"
type: "assert-consistent",
verify: (s) => s.assertContent("A.md", "original content")
},
// Client 0 goes offline and deletes A.md
{ type: "disable-sync", client: 0 },
{ type: "delete", client: 0, path: "A.md" },
// Client 1 updates A.md while Client 0 is offline
{
type: "update",
client: 1,
@ -73,12 +31,13 @@ export const offlineDeleteVsRemoteUpdateTest: TestDefinition = {
},
{ type: "sync", client: 1 },
// Client 0 comes online — receives remote-update for A.md
// but has already deleted it locally
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "barrier" },
{ type: "assert-consistent", verify: verifyConsistentState }
{
type: "assert-consistent",
verify: (s) => s.assertFileCount(0)
}
]
};

View file

@ -1,56 +1,21 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
function verifyEditPreservedAtNewPath(state: ClientState): void {
const files = Array.from(state.files.keys()).sort();
// A.md should not exist (it was renamed to B.md)
assert(
!state.files.has("A.md"),
`A.md should not exist after rename. Files: ${files.join(", ")}`
);
// B.md should exist with Client 0's edit merged in
assert(
state.files.has("B.md"),
`Expected B.md to exist. Files: ${files.join(", ")}`
);
const content = state.files.get("B.md") ?? "";
assert(
content.includes("edited by client 0"),
`Expected B.md to contain Client 0's edit "edited by client 0", got: "${content}"`
);
assert(
state.files.size === 1,
`Expected exactly 1 file, got ${state.files.size}: ${files.join(", ")}`
);
}
import type { TestDefinition } from "../test-definition";
export const offlineEditRemoteRenameTest: TestDefinition = {
name: "Offline Edit + Remote Rename",
description:
"Client 0 goes offline and edits A.md. Meanwhile Client 1 renames " +
"A.md to B.md. When Client 0 reconnects, its edit should be applied " +
"to B.md (the renamed path). The edit must not be lost and A.md must " +
"not exist.",
"Client 0 edits A.md offline while client 1 renames A.md to B.md. " +
"After client 0 reconnects, the edit must appear in B.md and A.md must not exist.",
clients: 2,
steps: [
// Setup: create and sync
{ type: "create", client: 0, path: "A.md", content: "original" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-content",
client: 1,
path: "A.md",
content: "original"
type: "assert-consistent",
verify: (s) => s.assertContent("A.md", "original")
},
// Client 0 goes offline and edits
{ type: "disable-sync", client: 0 },
{
type: "update",
@ -59,7 +24,6 @@ export const offlineEditRemoteRenameTest: TestDefinition = {
content: "edited by client 0"
},
// Client 1 renames A.md -> B.md while Client 0 is offline
{
type: "rename",
client: 1,
@ -68,13 +32,17 @@ export const offlineEditRemoteRenameTest: TestDefinition = {
},
{ type: "sync", client: 1 },
// Client 0 reconnects — edit must be preserved at new path
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "barrier" },
{ type: "assert-not-exists", client: 0, path: "A.md" },
{ type: "assert-not-exists", client: 1, path: "A.md" },
{ type: "assert-consistent", verify: verifyEditPreservedAtNewPath }
{
type: "assert-consistent",
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 { 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(", ")}`
);
}
import type { TestDefinition } from "../test-definition";
export const offlineEditThenMoveSameContentTest: TestDefinition = {
name: "Offline Move + Edit Creates False Hash Match",
description:
"A file is renamed and edited to have the same content as a deleted " +
"file. Move detection may match against the wrong document. The " +
"system should still converge.",
"A file is renamed and edited to match a deleted file's content. Both clients must converge despite the ambiguity.",
clients: 2,
steps: [
// Setup: create two files with different content
{
type: "create",
client: 0,
@ -61,16 +22,12 @@ export const offlineEditThenMoveSameContentTest: TestDefinition = {
{ type: "sync" },
{ type: "barrier" },
// Client 0 goes offline
{ type: "disable-sync", client: 0 },
// Delete A.md
{ type: "delete", client: 0, path: "A.md" },
// Rename B.md → C.md
{ type: "rename", client: 0, oldPath: "B.md", newPath: "C.md" },
// Edit C.md to have the same content as the now-deleted A.md
{
type: "update",
client: 0,
@ -78,11 +35,18 @@ export const offlineEditThenMoveSameContentTest: TestDefinition = {
content: "content A"
},
// Reconnect
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ 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 { 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(", ")}`
);
}
import type { TestDefinition } from "../test-definition";
export const offlineMixedOperationsTest: TestDefinition = {
name: "Offline Mixed Operations (Delete + Rename + Edit)",
description:
"Client 0 creates 3 files, syncs to both clients. Client 0 goes offline, " +
"deletes file 1, renames file 2 to a new name, and edits file 3. " +
"When Client 0 reconnects, all three operations should propagate to Client 1.",
clients: 2,
steps: [
// Setup: Client 0 creates 3 files and syncs
{ type: "create", client: 0, path: "file1.md", content: "content-1" },
{ type: "create", client: 0, path: "file2.md", content: "content-2" },
{ type: "create", client: 0, path: "file3.md", content: "content-3" },
@ -60,30 +15,17 @@ export const offlineMixedOperationsTest: TestDefinition = {
{ type: "sync" },
{ type: "barrier" },
// Verify initial sync
{
type: "assert-content",
client: 1,
path: "file1.md",
content: "content-1"
},
{
type: "assert-content",
client: 1,
path: "file2.md",
content: "content-2"
},
{
type: "assert-content",
client: 1,
path: "file3.md",
content: "content-3"
type: "assert-consistent",
verify: (s) =>
s
.assertContent("file1.md", "content-1")
.assertContent("file2.md", "content-2")
.assertContent("file3.md", "content-3")
},
// Client 0 goes offline
{ type: "disable-sync", client: 0 },
// Client 0 performs three different offline operations
{ type: "delete", client: 0, path: "file1.md" },
{
type: "rename",
@ -98,16 +40,19 @@ export const offlineMixedOperationsTest: TestDefinition = {
content: "updated-content-3"
},
// Client 0 reconnects
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "barrier" },
// All operations should have propagated
{ type: "assert-not-exists", client: 1, path: "file1.md" },
{ type: "assert-not-exists", client: 1, path: "file2.md" },
{ type: "assert-exists", client: 1, path: "moved.md" },
{ type: "assert-exists", client: 1, path: "file3.md" },
{ type: "assert-consistent", verify: verifyFinalState }
{
type: "assert-consistent",
verify: (s) =>
s
.assertFileNotExists("file1.md")
.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 { 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(", ")}`
);
}
import type { TestDefinition } from "../test-definition";
export const offlineMoveThenRemoteDeleteTest: TestDefinition = {
name: "Offline Move + Remote Delete Convergence",
description:
"Client 0 renames A→B offline while Client 1 deletes A. " +
"The move+delete coalescing may use a stale path. " +
"Both clients should converge to having no files.",
"Client 0 renames A.md to B.md offline while client 1 deletes A.md. " +
"Both clients must converge to having no files.",
clients: 2,
steps: [
// Setup: both have A.md
{
type: "create",
client: 0,
@ -50,24 +17,23 @@ export const offlineMoveThenRemoteDeleteTest: TestDefinition = {
{ type: "sync" },
{ type: "barrier" },
// Client 0 goes offline, renames A→B
{ type: "disable-sync", client: 0 },
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
// Client 1 deletes A.md (broadcasts to server)
{ type: "delete", client: 1, path: "A.md" },
{ type: "sync", client: 1 },
// Client 0 reconnects — receives remote-delete while move is pending
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "barrier" },
// Both should converge to no files
{ type: "assert-not-exists", client: 0, path: "A.md" },
{ type: "assert-not-exists", client: 1, path: "A.md" },
{ type: "assert-not-exists", client: 0, path: "B.md" },
{ type: "assert-not-exists", client: 1, path: "B.md" },
{ type: "assert-consistent", verify: verifyNoFiles }
{
type: "assert-consistent",
verify: (s) =>
s
.assertFileNotExists("A.md")
.assertFileNotExists("B.md")
.assertFileCount(0)
}
]
};

View file

@ -1,69 +0,0 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
function verifyLatestVersion(state: ClientState): void {
assert(
state.files.size === 1,
`Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
);
assert(
state.files.has("evolving.md"),
`Expected evolving.md to exist, got: ${Array.from(state.files.keys()).join(", ")}`
);
const content = state.files.get("evolving.md") ?? "";
assert(
content === "version-5-final",
`Expected evolving.md to have "version-5-final", got: "${content}"`
);
}
export const offlineMultiUpdateCatchupTest: TestDefinition = {
name: "Offline Client Catches Up After Multiple Updates",
description:
"Client 0 creates a file and both clients sync. Client 1 goes " +
"offline. Client 0 updates the file 5 times. Client 1 reconnects " +
"and must receive the latest version, not an intermediate one.",
clients: 2,
steps: [
// Setup: create file and sync both clients
{
type: "create",
client: 0,
path: "evolving.md",
content: "version-0-initial"
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-content",
client: 1,
path: "evolving.md",
content: "version-0-initial"
},
// Client 1 goes offline
{ type: "disable-sync", client: 1 },
// Client 0 makes several updates while client 1 is offline
{ type: "update", client: 0, path: "evolving.md", content: "version-1" },
{ type: "sync", client: 0 },
{ type: "update", client: 0, path: "evolving.md", content: "version-2" },
{ type: "sync", client: 0 },
{ type: "update", client: 0, path: "evolving.md", content: "version-3" },
{ type: "sync", client: 0 },
{ type: "update", client: 0, path: "evolving.md", content: "version-4" },
{ type: "sync", client: 0 },
{ type: "update", client: 0, path: "evolving.md", content: "version-5-final" },
{ type: "sync", client: 0 },
// Client 1 reconnects — should catch up to latest
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
// Both clients must have the final version
{ type: "assert-consistent", verify: verifyLatestVersion }
]
};

View file

@ -1,72 +1,38 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
function verifyOnlyLatestVersion(state: ClientState): void {
assert(
state.files.size === 1,
`Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
);
assert(
state.files.has("doc.md"),
`Expected doc.md to exist, got: ${Array.from(state.files.keys()).join(", ")}`
);
const content = state.files.get("doc.md") ?? "";
assert(
content === "edit-5-final",
`Expected doc.md to have "edit-5-final" (latest edit), got: "${content}"`
);
}
import type { TestDefinition } from "../test-definition";
export const offlineMultipleEditsTest: TestDefinition = {
name: "Offline Multiple Edits Converge to Latest",
description:
"Client 0 creates a file and syncs. Client 0 goes offline, edits the file " +
"5 times with different content. When Client 0 reconnects, both clients " +
"must converge to the final version.",
clients: 2,
steps: [
// Setup: create file and sync to both clients
{ type: "create", client: 0, path: "doc.md", content: "original" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-content",
client: 1,
path: "doc.md",
content: "original"
type: "assert-consistent",
verify: (s) => s.assertContent("doc.md", "original")
},
// Client 0 goes offline
{ type: "disable-sync", client: 0 },
// Client 0 makes 5 sequential edits while offline
{ type: "update", client: 0, path: "doc.md", content: "edit-1" },
{ type: "update", client: 0, path: "doc.md", content: "edit-2" },
{ type: "update", client: 0, path: "doc.md", content: "edit-3" },
{ type: "update", client: 0, path: "doc.md", content: "edit-4" },
{ type: "update", client: 0, path: "doc.md", content: "edit-5-final" },
// Client 0 reconnects -- offline reconciliation should detect the
// changed hash and sync the current on-disk content (edit-5-final)
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "barrier" },
// Both clients should have the final version
{
type: "assert-content",
client: 0,
path: "doc.md",
content: "edit-5-final"
},
{
type: "assert-content",
client: 1,
path: "doc.md",
content: "edit-5-final"
},
{ type: "assert-consistent", verify: verifyOnlyLatestVersion }
type: "assert-consistent",
verify: (s) =>
s.assertFileCount(1).assertContent("doc.md", "edit-5-final")
}
]
};

View file

@ -1,43 +0,0 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
function verifyAllPresent(state: ClientState): void {
assert(
state.files.size === 2,
`Expected 2 files, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
);
assert(
state.files.get("A.md") === "from-client-0",
`Expected A.md = "from-client-0", got: "${state.files.get("A.md")}"`
);
assert(
state.files.get("B.md") === "from-client-1",
`Expected B.md = "from-client-1", got: "${state.files.get("B.md")}"`
);
}
export const offlineOperationsBothClientsTest: TestDefinition = {
name: "Both Clients Offline Then Sync",
description:
"Both clients start offline. Client 0 creates A.md, Client 1 creates B.md. " +
"Both enable sync simultaneously. Both files should appear on both clients.",
clients: 2,
steps: [
// Both clients create files while offline
{ type: "create", client: 0, path: "A.md", content: "from-client-0" },
{ type: "create", client: 1, path: "B.md", content: "from-client-1" },
// Both enable sync at the same time
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
// Both should have both files
{ type: "assert-exists", client: 0, path: "A.md" },
{ type: "assert-exists", client: 0, path: "B.md" },
{ type: "assert-exists", client: 1, path: "A.md" },
{ type: "assert-exists", client: 1, path: "B.md" },
{ type: "assert-consistent", verify: verifyAllPresent }
]
};

View file

@ -1,60 +1,37 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
function verifyContent(state: ClientState): void {
// The file should be at B.md with the exact edited content
assert(
state.files.has("B.md"),
`Expected B.md to exist, got: ${Array.from(state.files.keys()).join(", ")}`
);
const content = state.files.get("B.md") ?? "";
assert(
content === "edited after rename",
`Expected B.md to be "edited after rename", got: "${content}"`
);
// A.md should not exist (renamed away)
assert(
!state.files.has("A.md"),
`A.md should not exist after rename, got: ${Array.from(state.files.keys()).join(", ")}`
);
// Only B.md should exist
assert(
state.files.size === 1,
`Expected exactly 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
);
}
import type { TestDefinition } from "../test-definition";
export const offlineRenameAndEditTest: TestDefinition = {
name: "Offline Rename and Edit",
description:
"Client 0 creates A.md and syncs. Client 0 goes offline, renames A.md " +
"to B.md, then edits B.md. When Client 0 reconnects, the rename and edit " +
"should both propagate to Client 1.",
clients: 2,
steps: [
// Setup: create and sync
{ type: "create", client: 0, path: "A.md", content: "original" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "assert-content", client: 1, path: "A.md", content: "original" },
{
type: "assert-consistent",
verify: (s) => s.assertContent("A.md", "original")
},
// Client 0 goes offline, renames and edits
{ type: "disable-sync", client: 0 },
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
{ type: "update", client: 0, path: "B.md", content: "edited after rename" },
// Client 0 reconnects
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "barrier" },
// A.md should be gone, B.md should have edited content
{ type: "assert-not-exists", client: 0, path: "A.md" },
{ type: "assert-not-exists", client: 1, path: "A.md" },
{ type: "assert-consistent", verify: verifyContent }
{
type: "assert-consistent",
verify: (s) =>
s
.assertFileNotExists("A.md")
.assertFileCount(1)
.assertContent("B.md", "edited after rename")
}
]
};

View file

@ -1,84 +0,0 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
/**
* BUG/EDGE CASE: Both clients rename the same file to different targets.
*
* Client 0 renames XY, Client 1 renames XZ. Both happen offline.
* When they reconnect:
*
* - Client 0's rename (XY) goes through first server has doc at Y
* - Client 1's rename (XZ): Client 1 still has the old metadata
* pointing to X.md. But the server moved it to Y.md.
*
* The conflict: Client 1 will try to update with relativePath=Z.md
* and parentVersionId pointing to the old state. The server sees the
* path changed and processes it as a rename from YZ.
*
* Expected: The file ends up at one path (last rename wins), and both
* clients converge. Content should be preserved.
*/
function verifyFinalState(state: ClientState): void {
// X should not exist (renamed by both)
assert(
!state.files.has("X.md"),
`X.md should not exist, files: ${Array.from(state.files.keys()).join(", ")}`
);
// Exactly one file should exist (either Y.md or Z.md)
assert(
state.files.size === 1,
`Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}`
);
// Content should be preserved
const content = Array.from(state.files.values())[0];
assert(
content === "original content",
`Expected "original content", got: "${content}"`
);
}
export const offlineRenameBothClientsSameSourceTest: TestDefinition = {
name: "Both Clients Rename Same File to Different Targets (Offline)",
description:
"Client 0 renames X→Y, Client 1 renames X→Z, both offline. " +
"On reconnect, the conflicting renames should resolve and " +
"both clients should converge to the same final path.",
clients: 2,
steps: [
// Setup: create X.md
{
type: "create",
client: 0,
path: "X.md",
content: "original content"
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
// Both go offline
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
// Client 0: rename X→Y
{ type: "rename", client: 0, oldPath: "X.md", newPath: "Y.md" },
// Client 1: rename X→Z
{ type: "rename", client: 1, oldPath: "X.md", newPath: "Z.md" },
// Client 0 reconnects first
{ type: "enable-sync", client: 0 },
{ type: "sync", client: 0 },
// Client 1 reconnects
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
// Both clients should converge
{ type: "assert-consistent", verify: verifyFinalState }
]
};

View file

@ -1,68 +0,0 @@
import type { ClientState, TestDefinition } from "../test-definition";
import { assert } from "../utils/assert";
function verifyRenamedFile(state: ClientState): void {
const files = Array.from(state.files.keys()).sort();
// original.md should not exist (it was renamed)
assert(
!state.files.has("original.md"),
`original.md should not exist. Files: ${files.join(", ")}`
);
// renamed.md should exist with the content
assert(
state.files.has("renamed.md"),
`Expected renamed.md to exist. Files: ${files.join(", ")}`
);
assert(
state.files.get("renamed.md") === "pending content",
`Expected "pending content", got: "${state.files.get("renamed.md")}"`
);
assert(
state.files.size === 1,
`Expected 1 file, got ${state.files.size}: ${files.join(", ")}`
);
}
export const offlineRenamePendingCreateTest: TestDefinition = {
name: "Offline Rename of Pending Create Before Key Resolution",
description:
"Client 0 creates a file (pending, not yet synced). Sync is disabled " +
"immediately. Client 0 renames the file locally. Sync is re-enabled. " +
"The idempotency key system must handle the pending create at the new " +
"path. The file should appear at the renamed path on both clients.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
// Create file, then immediately disable sync
{ type: "disable-sync", client: 0 },
{
type: "create",
client: 0,
path: "original.md",
content: "pending content"
},
// Rename while still offline (pending create not yet confirmed)
{
type: "rename",
client: 0,
oldPath: "original.md",
newPath: "renamed.md"
},
// Re-enable sync — triggers key resolution + offline reconciliation
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "barrier" },
// Both clients should have renamed.md with the content
{ type: "assert-not-exists", client: 0, path: "original.md" },
{ type: "assert-not-exists", client: 1, path: "original.md" },
{ type: "assert-consistent", verify: verifyRenamedFile }
]
};

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