split: deterministic-tests, obsidian-plugin, local-cli, test-client, frontend root

New deterministic-tests workspace: scripted multi-client harness against
a real server (~110 scenario tests, server-control, managed-websocket,
test-runner). Updates to existing workspaces: obsidian-plugin (settings,
cursors, plugin entrypoint), local-client-cli (args, cli, file-watcher,
node-filesystem, path-utils + tests), test-client (mock-agent/client,
cli, error tracker). Bumps frontend root package.json/lock and adds
eslint config tweaks.
This commit is contained in:
Andras Schmelczer 2026-05-08 21:37:51 +01:00
parent 5a070340f1
commit 0daeaf6382
162 changed files with 10687 additions and 4051 deletions

View file

@ -0,0 +1,103 @@
# Deterministic Tests
Scripted multi-client (with an in-memory filesystem) sync tests that run against a real server. Each test defines a sequence of file operations, sync/server controls, and assertions to exercise a specific conflict or edge case.
Complements the fuzz-based E2E tests (`test-client`): fuzz tests discover bugs through random operations; deterministic tests pin down exact reproduction sequences for known scenarios.
## How it works
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.
All tests run in parallel up to a concurrency limit.
## Step types
Clients always start with syncing disabled.
**File operations** (per-client, fire-and-forget — sync is enqueued but not awaited):
- `create`, `update`, `rename`, `delete`
**Sync control:**
- `sync` — wait for a specific client or all clients to finish pending operations
- `barrier` — retry until all clients converge to identical file state (60s timeout)
- `enable-sync` / `disable-sync` — simulate going online/offline
**WebSocket control** (per-client):
- `pause-websocket` / `resume-websocket` — buffer/release WebSocket messages for a specific client
**Server control:**
- `pause-server` / `resume-server` — SIGSTOP/SIGCONT the server process
**Assertions:**
- `assert-consistent` — all clients have identical files; optionally takes a custom `verify(state: AssertableState)` callback
## Running
```sh
# Build server first
cd sync-server && cargo build --release && cd -
# Run all tests
cd frontend && npm run build -w sync-client && npm run test -w deterministic-tests
# Filter by name
npm run test -w deterministic-tests -- --filter=rename
# Control parallelism (default: number of CPU cores)
npm run test -w deterministic-tests -- -j 4
```
## Adding a test
1. Create `src/tests/my-scenario.test.ts`:
```typescript
import type { TestDefinition } from "../test-definition";
export const myScenarioTest: TestDefinition = {
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: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
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
import { myScenarioTest } from "./tests/my-scenario.test";
const TESTS = {
// ...
"my-scenario": myScenarioTest
};
```

View file

@ -0,0 +1,22 @@
{
"name": "deterministic-tests",
"version": "0.14.0",
"private": true,
"bin": {
"deterministic-tests": "./dist/cli.js"
},
"scripts": {
"dev": "webpack watch --mode development",
"build": "webpack --mode production",
"test": "npm run build && node dist/cli.js"
},
"devDependencies": {
"@types/node": "^25.0.2",
"sync-client": "file:../sync-client",
"ts-loader": "^9.5.4",
"tslib": "2.8.1",
"typescript": "5.9.3",
"webpack": "^5.103.0",
"webpack-cli": "^6.0.1"
}
}

View file

@ -0,0 +1,229 @@
import { TestRunner } from "./test-runner";
import { ServerControl } from "./server-control";
import { ServerManager } from "./server-manager";
import { PrefixedLogger } from "./prefixed-logger";
import { TESTS } from "./test-registry";
import type { TestDefinition, TestResult } from "./test-definition";
import { parseConcurrency } from "./parse-concurrency";
import { runWithConcurrency } from "./run-with-concurrency";
import { TOKEN, SERVER_BINARY_PATH, CONFIG_PATH } from "./consts";
import * as path from "node:path";
import * as fs from "node:fs";
import { debugging, Logger } from "sync-client";
const logger = new Logger();
debugging.logToConsole(logger, { useColors: true });
process.on("unhandledRejection", (reason) => {
logger.error(`Unhandled Rejection: ${reason}`);
process.exit(1);
});
process.on("uncaughtException", (error) => {
logger.error(`Uncaught Exception: ${error}`);
process.exit(1);
});
const serverManager = new ServerManager(logger);
serverManager.installSignalHandlers();
function testUsesPauseServer(test: TestDefinition): boolean {
return test.steps.some(
(step) => step.type === "pause-server" || step.type === "resume-server"
);
}
interface NamedTestResult {
name: string;
result: TestResult;
}
async function runSharedServerTest(
name: string,
test: TestDefinition,
sharedServer: ServerControl
): Promise<NamedTestResult> {
const testLogger = new PrefixedLogger(logger, name);
const runner = new TestRunner(
sharedServer,
testLogger,
TOKEN,
sharedServer.remoteUri
);
const result = await runner.runTest(name, test);
if (result.success) {
logger.info(`PASSED: ${name} (${result.duration}ms)`);
} else {
logger.error(`FAILED: ${name} - ${result.error}`);
}
return { name, result };
}
/**
* Run a test with its own dedicated server (for tests that use pause-server).
* SIGSTOP/SIGCONT affects the entire server process, so these tests need
* 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, name);
const server = new ServerControl(serverPath, configPath, testLogger);
serverManager.track(server);
try {
await server.start();
const runner = new TestRunner(
server,
testLogger,
TOKEN,
server.remoteUri
);
const result = await runner.runTest(name, test);
if (result.success) {
logger.info(`PASSED: ${name} (${result.duration}ms)`);
} else {
logger.error(`FAILED: ${name} - ${result.error}`);
}
return { name, result };
} finally {
try {
await server.stop();
} catch {
// best-effort cleanup
}
serverManager.untrack(server);
}
}
async function main(): Promise<void> {
const cwd = process.cwd();
let projectRoot = cwd;
if (cwd.endsWith("frontend/deterministic-tests")) {
projectRoot = path.resolve(cwd, "../..");
} else if (cwd.endsWith("frontend")) {
projectRoot = path.resolve(cwd, "..");
}
const serverPath = path.join(projectRoot, SERVER_BINARY_PATH);
if (!fs.existsSync(serverPath)) {
logger.error(`Server binary not found at: ${serverPath}`);
process.exit(1);
}
const configPath = path.join(projectRoot, CONFIG_PATH);
if (!fs.existsSync(configPath)) {
logger.error(`Config file not found at: ${configPath}`);
process.exit(1);
}
const filterArg = process.argv.find((a) => a.startsWith("--filter="));
const filter = filterArg?.slice("--filter=".length);
const testsToRun: [string, TestDefinition][] = [];
for (const [key, test] of Object.entries(TESTS)) {
if (test) {
if (
filter !== undefined &&
filter.length > 0 &&
!key.includes(filter)
) {
continue;
}
testsToRun.push([key, test]);
}
}
if (testsToRun.length === 0) {
logger.error(
filter !== undefined && filter.length > 0
? `No tests matched filter "${filter}"`
: "No tests found"
);
process.exit(1);
}
const concurrency = parseConcurrency();
const regularTests = testsToRun.filter(([, t]) => !testUsesPauseServer(t));
const pauseTests = testsToRun.filter(([, t]) => testUsesPauseServer(t));
logger.info(`Server: ${serverPath}`);
logger.info(`Config: ${configPath}`);
logger.info(
`Tests: ${testsToRun.length} total (${regularTests.length} regular, ${pauseTests.length} server-pause)`
);
logger.info(`Concurrency: ${concurrency}`);
const allResults: NamedTestResult[] = [];
if (regularTests.length > 0) {
logger.info(
`\n--- Running ${regularTests.length} regular tests (shared server, concurrency ${concurrency}) ---`
);
const sharedServer = new ServerControl(serverPath, configPath, logger);
serverManager.track(sharedServer);
try {
await sharedServer.start();
const results = await runWithConcurrency(
regularTests,
concurrency,
async ([name, test]) =>
runSharedServerTest(name, test, sharedServer)
);
allResults.push(...results);
} finally {
try {
await sharedServer.stop();
} catch (error) {
logger.warn(
`Error stopping shared server: ${error instanceof Error ? error.message : String(error)}`
);
}
serverManager.untrack(sharedServer);
}
}
if (pauseTests.length > 0) {
logger.info(
`\n--- Running ${pauseTests.length} server-pause tests (dedicated servers, concurrency ${concurrency}) ---`
);
const results = await runWithConcurrency(
pauseTests,
concurrency,
async ([name, test]) =>
runDedicatedServerTest(name, test, serverPath, configPath)
);
allResults.push(...results);
}
const passed = allResults.filter((r) => r.result.success);
const failed = allResults.filter((r) => !r.result.success);
logger.info(
`\n--- Results: ${passed.length}/${allResults.length} passed ---`
);
if (failed.length > 0) {
for (const { name, result } of failed) {
logger.error(` FAILED: ${name}: ${result.error}`);
}
process.exit(1);
} else {
logger.info("All tests passed!");
process.exit(0);
}
}
main().catch((err: unknown) => {
logger.error(`Unexpected error: ${err}`);
process.exit(1);
});

View file

@ -0,0 +1,13 @@
export const TOKEN = "test-token-change-me";
export const SERVER_BINARY_PATH = "sync-server/target/release/sync_server";
export const CONFIG_PATH = "sync-server/config-e2e.yml";
export const STOP_TIMEOUT_MS = 5_000;
export const CONVERGENCE_TIMEOUT_MS = 60_000;
export const CONVERGENCE_RETRY_DELAY_MS = 500;
export const AGENT_INIT_TIMEOUT_MS = 30_000;
export const IS_SYNC_ENABLED_BY_DEFAULT = false;
export const WAIT_TIMEOUT_MS = 60_000;
export const WEBSOCKET_CONNECT_TIMEOUT_MS = 10_000;
export const WEBSOCKET_POLL_INTERVAL_MS = 50;

View file

@ -0,0 +1,464 @@
import type {
HistoryEntry,
StoredDatabase,
SyncSettings,
RelativePath,
TextWithCursors
} from "sync-client";
import {
SyncClient,
SyncResetError,
debugging,
LogLevel,
utils
} from "sync-client";
import { assert } from "./utils/assert";
import { sleep } from "./utils/sleep";
import { withTimeout } from "./utils/with-timeout";
import {
IS_SYNC_ENABLED_BY_DEFAULT,
WAIT_TIMEOUT_MS,
WEBSOCKET_CONNECT_TIMEOUT_MS,
WEBSOCKET_POLL_INTERVAL_MS
} from "./consts";
import { ManagedWebSocketFactory } from "./managed-websocket";
export class DeterministicAgent extends debugging.InMemoryFileSystem {
public readonly clientId: number;
private readonly logger: (msg: string) => void;
private client!: SyncClient;
private data: Partial<{
settings: Partial<SyncSettings>;
database: Partial<StoredDatabase>;
}> = {};
private isSyncEnabled = IS_SYNC_ENABLED_BY_DEFAULT;
private readonly syncErrors: Error[] = [];
private readonly pendingSyncOperations = new Set<Promise<void>>();
private readonly wsFactory = new ManagedWebSocketFactory();
private nextWriteRename:
| {
oldPath: RelativePath;
newPath: RelativePath;
}
| undefined;
private nextCreateResponseDrop:
| {
dropped: Promise<void>;
resolveDropped: () => void;
}
| undefined;
public constructor(
clientId: number,
initialSettings: Partial<SyncSettings>,
logger: (msg: string) => void
) {
super();
this.clientId = clientId;
this.logger = logger;
this.data.settings = { ...initialSettings };
}
public async init(
fetchImplementation: typeof globalThis.fetch
): Promise<void> {
this.client = await SyncClient.create({
fs: this,
persistence: {
load: async () => this.data,
save: async (data) => void (this.data = data)
},
fetch: this.wrapFetch(fetchImplementation),
webSocket: this.wsFactory.constructorFn
});
this.client.logger.onLogEmitted.add((line) => {
const prefix = `[Client ${this.clientId}]`;
switch (line.level) {
case LogLevel.ERROR:
this.logger(`${prefix} ERROR: ${line.message}`);
break;
case LogLevel.WARNING:
this.logger(`${prefix} WARN: ${line.message}`);
break;
case LogLevel.INFO:
this.logger(`${prefix} ${line.message}`);
break;
case LogLevel.DEBUG:
// Skip debug logs to reduce noise
break;
}
});
await this.client.start();
const connectionCheck = await this.client.checkConnection();
assert(
connectionCheck.isSuccessful,
`Client ${this.clientId} connection check failed`
);
if (this.isSyncEnabled) {
await this.waitForWebSocket();
}
}
public pauseWebSocket(): void {
this.log("Pausing WebSocket message delivery");
this.wsFactory.pause();
}
public resumeWebSocket(): void {
this.log("Resuming WebSocket message delivery");
this.wsFactory.resume();
}
public dropNextCreateResponse(): void {
assert(
this.nextCreateResponseDrop === undefined,
`Client ${this.clientId} already has a create response drop armed`
);
let resolveDropped!: () => void;
const dropped = new Promise<void>((resolve) => {
resolveDropped = resolve;
});
this.nextCreateResponseDrop = {
dropped,
resolveDropped
};
this.log("Armed next create response drop");
}
public async waitForDroppedCreateResponse(): Promise<void> {
assert(
this.nextCreateResponseDrop !== undefined,
`Client ${this.clientId} has no create response drop armed`
);
await withTimeout(
this.nextCreateResponseDrop.dropped,
WAIT_TIMEOUT_MS,
`Client ${this.clientId} timed out waiting for create response drop`
);
this.log("Create response was dropped after server commit");
}
public async waitForHistoryEntry(
matches: (entry: HistoryEntry) => boolean,
onMatch?: (entry: HistoryEntry) => void
): Promise<void> {
const existing = this.client.getHistoryEntries().find(matches);
if (existing !== undefined) {
onMatch?.(existing);
return;
}
await withTimeout(
new Promise<void>((resolve) => {
const unsubscribe = this.client.onSyncHistoryUpdated.add(() => {
const entry = this.client
.getHistoryEntries()
.find(matches);
if (entry === undefined) {
return;
}
unsubscribe();
onMatch?.(entry);
resolve();
});
}),
WAIT_TIMEOUT_MS,
`Client ${this.clientId} timed out waiting for history entry`
);
}
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 reset(): Promise<void> {
this.log("Resetting client (clears tracked state, keeps disk files)");
await this.drainPendingSyncOperations();
await this.client.reset();
if (this.isSyncEnabled) {
await this.waitForWebSocket();
}
}
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> {
this.log("Enabling sync");
await this.client.setSetting("isSyncEnabled", true);
this.isSyncEnabled = true;
await this.waitForWebSocket();
}
public async getFileContent(path: string): Promise<string> {
const bytes = await this.read(path);
return new TextDecoder().decode(bytes);
}
public renameNextWrite(oldPath: RelativePath, newPath: RelativePath): void {
assert(
this.nextWriteRename === undefined,
`Client ${this.clientId} already has a next-write rename armed`
);
this.nextWriteRename = { oldPath, newPath };
this.log(`Armed next write rename: ${oldPath} -> ${newPath}`);
}
public async cleanup(): Promise<void> {
this.log("Cleaning up...");
// Guard against uninitialized client (init() failed partway).
// The class field uses `!:` so TS thinks this is always defined,
// but at runtime it can be undefined when init() throws partway.
const maybeClient = this.client as SyncClient | undefined;
if (maybeClient === undefined) {
this.log("Client not initialized, nothing to clean up");
return;
}
try {
await this.drainPendingSyncOperations();
await withTimeout(
this.client.waitUntilFinished(),
WAIT_TIMEOUT_MS,
`Client ${this.clientId} cleanup waitUntilFinished timed out`
);
} catch (error) {
if (error instanceof Error && error.name === "SyncResetError") {
this.log(`Cleanup interrupted by reset (expected): ${error}`);
} else {
this.log(`Cleanup waitUntilFinished failed: ${error}`);
}
}
await this.client.destroy();
this.log("Cleanup complete");
}
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();
const isNew = !this.files.has(path);
await super.write(path, content);
if (this.isSyncEnabled && isNew) {
this.enqueueSync(async () => {
this.client.syncLocallyCreatedFile(path);
});
}
const nextWriteRename = this.nextWriteRename;
if (
nextWriteRename !== undefined &&
nextWriteRename.oldPath === path
) {
this.nextWriteRename = undefined;
await super.rename(
nextWriteRename.oldPath,
nextWriteRename.newPath
);
if (this.isSyncEnabled) {
this.enqueueSync(async () => {
this.client.syncLocallyUpdatedFile({
oldPath: nextWriteRename.oldPath,
relativePath: nextWriteRename.newPath
});
});
}
}
if (!this.isSyncEnabled) {
return;
}
if (!isNew) {
this.enqueueSync(async () => {
this.client.syncLocallyUpdatedFile({ relativePath: path });
});
}
}
public override async atomicUpdateText(
path: RelativePath,
updater: (current: TextWithCursors) => TextWithCursors
): Promise<string> {
const result = await super.atomicUpdateText(path, updater);
if (this.isSyncEnabled) {
this.enqueueSync(async () => {
this.client.syncLocallyUpdatedFile({ relativePath: path });
});
}
return result;
}
public override async delete(path: RelativePath): Promise<void> {
await super.delete(path);
if (this.isSyncEnabled) {
this.enqueueSync(async () => {
this.client.syncLocallyDeletedFile(path);
});
}
}
public override async rename(
oldPath: RelativePath,
newPath: RelativePath
): Promise<void> {
await super.rename(oldPath, newPath);
if (this.isSyncEnabled) {
this.enqueueSync(async () => {
this.client.syncLocallyUpdatedFile({
oldPath,
relativePath: newPath
});
});
}
}
private async waitForWebSocket(): Promise<void> {
const deadline = Date.now() + WEBSOCKET_CONNECT_TIMEOUT_MS;
while (!this.client.isWebSocketConnected && Date.now() < deadline) {
await sleep(WEBSOCKET_POLL_INTERVAL_MS);
}
assert(
this.client.isWebSocketConnected,
`Client ${this.clientId} WebSocket failed to connect within ${WEBSOCKET_CONNECT_TIMEOUT_MS}ms`
);
}
/**
* 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 utils.awaitAll([...this.pendingSyncOperations]);
}
}
private enqueueSync(operation: () => Promise<void>): void {
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);
});
}
private async executeSyncOperation(
operation: () => Promise<void>
): Promise<void> {
try {
await operation();
} catch (error) {
if (error instanceof Error && error.name === "SyncResetError") {
this.log(`Sync operation interrupted by reset: ${error}`);
return;
}
if (
error instanceof Error &&
error.message.includes("has been destroyed")
) {
this.log(`Sync operation interrupted by destroy: ${error}`);
return;
}
throw error;
}
}
private log(message: string): void {
this.logger(`[Client ${this.clientId}] ${message}`);
}
private wrapFetch(
fetchImplementation: typeof globalThis.fetch
): typeof globalThis.fetch {
return async (input, init) => {
const response = await fetchImplementation(input, init);
const drop = this.nextCreateResponseDrop;
if (
drop !== undefined &&
DeterministicAgent.isCreateDocumentRequest(input, init)
) {
this.nextCreateResponseDrop = undefined;
drop.resolveDropped();
throw new SyncResetError();
}
return response;
};
}
private static isCreateDocumentRequest(
input: RequestInfo | URL,
init: RequestInit | undefined
): boolean {
const method =
init?.method ??
(typeof Request !== "undefined" && input instanceof Request
? input.method
: "GET");
if (method.toUpperCase() !== "POST") {
return false;
}
const url =
input instanceof URL
? input
: new URL(typeof input === "string" ? input : input.url);
return /\/documents\/?$/.test(url.pathname);
}
}

View file

@ -0,0 +1,219 @@
/**
* A WebSocket wrapper that can pause and resume message delivery.
* When paused, incoming messages are buffered. When resumed, buffered
* messages are delivered in order via the onmessage handler.
*
* Member layout follows typescript-eslint default member-ordering: all
* accessor properties are declared with `declare` and wired through the
* constructor using Object.defineProperty so we don't need conflicting
* get/set accessor pairs.
*/
class ManagedWebSocket implements WebSocket {
public static readonly CONNECTING = WebSocket.CONNECTING;
public static readonly OPEN = WebSocket.OPEN;
public static readonly CLOSING = WebSocket.CLOSING;
public static readonly CLOSED = WebSocket.CLOSED;
public readonly CONNECTING = WebSocket.CONNECTING;
public readonly OPEN = WebSocket.OPEN;
public readonly CLOSING = WebSocket.CLOSING;
public readonly CLOSED = WebSocket.CLOSED;
declare public readonly readyState: number;
declare public readonly url: string;
declare public readonly protocol: string;
declare public readonly extensions: string;
declare public readonly bufferedAmount: number;
declare public binaryType: BinaryType;
declare public onopen: ((this: WebSocket, ev: Event) => unknown) | null;
declare public onclose:
| ((this: WebSocket, ev: CloseEvent) => unknown)
| null;
declare public onerror: ((this: WebSocket, ev: Event) => unknown) | null;
declare public onmessage:
| ((this: WebSocket, ev: MessageEvent) => unknown)
| null;
private readonly ws: WebSocket;
private readonly bufferedMessages: MessageEvent[] = [];
private paused = false;
private externalOnMessage: ((event: MessageEvent) => unknown) | null = null;
public constructor(url: string | URL, protocols?: string | string[]) {
this.ws = new WebSocket(url, protocols);
const { ws } = this;
Object.defineProperties(this, {
readyState: {
get: (): number => ws.readyState,
enumerable: true,
configurable: true
},
url: {
get: (): string => ws.url,
enumerable: true,
configurable: true
},
protocol: {
get: (): string => ws.protocol,
enumerable: true,
configurable: true
},
extensions: {
get: (): string => ws.extensions,
enumerable: true,
configurable: true
},
bufferedAmount: {
get: (): number => ws.bufferedAmount,
enumerable: true,
configurable: true
},
binaryType: {
get: (): BinaryType => ws.binaryType,
set: (v: BinaryType): void => {
ws.binaryType = v;
},
enumerable: true,
configurable: true
},
onopen: {
get: (): ((this: WebSocket, ev: Event) => unknown) | null =>
ws.onopen,
set: (
h: ((this: WebSocket, ev: Event) => unknown) | null
): void => {
ws.onopen = h;
},
enumerable: true,
configurable: true
},
onclose: {
get: ():
| ((this: WebSocket, ev: CloseEvent) => unknown)
| null => ws.onclose,
set: (
h: ((this: WebSocket, ev: CloseEvent) => unknown) | null
): void => {
ws.onclose = h;
},
enumerable: true,
configurable: true
},
onerror: {
get: (): ((this: WebSocket, ev: Event) => unknown) | null =>
ws.onerror,
set: (
h: ((this: WebSocket, ev: Event) => unknown) | null
): void => {
ws.onerror = h;
},
enumerable: true,
configurable: true
},
onmessage: {
get: ():
| ((this: WebSocket, ev: MessageEvent) => unknown)
| null => this.externalOnMessage,
set: (
h: ((this: WebSocket, ev: MessageEvent) => unknown) | null
): void => {
this.externalOnMessage = h;
},
enumerable: true,
configurable: true
}
});
this.ws.onmessage = (event: MessageEvent): void => {
if (this.paused) {
this.bufferedMessages.push(event);
} else {
this.externalOnMessage?.(event);
}
};
}
public pause(): void {
this.paused = true;
}
public resume(): void {
this.paused = false;
const messages = this.bufferedMessages.splice(0);
for (const msg of messages) {
this.externalOnMessage?.(msg);
}
}
public send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
this.ws.send(data);
}
public close(code?: number, reason?: string): void {
this.ws.close(code, reason);
}
public addEventListener(
...args: Parameters<WebSocket["addEventListener"]>
): void {
this.ws.addEventListener(...args);
}
public removeEventListener(
...args: Parameters<WebSocket["removeEventListener"]>
): void {
this.ws.removeEventListener(...args);
}
public dispatchEvent(event: Event): boolean {
return this.ws.dispatchEvent(event);
}
}
/**
* Factory that creates ManagedWebSocket instances and tracks them
* for pause/resume control from the test harness
*/
export class ManagedWebSocketFactory {
private readonly instances: ManagedWebSocket[] = [];
// Sticky pause state: applied to current instances on `pause()` AND
// to any new instance created later (e.g. WS reconnect after a
// `disable-sync` / `reset` cycle). Without this, a test pausing the
// WS before the agent reconnects would silently see the new socket
// start un-paused and miss the messages it meant to buffer.
private currentlyPaused = false;
public get constructorFn(): typeof globalThis.WebSocket {
const trackInstance = (instance: ManagedWebSocket): void => {
this.instances.push(instance);
if (this.currentlyPaused) {
instance.pause();
}
};
class TrackedManagedWebSocket extends ManagedWebSocket {
public constructor(
url: string | URL,
protocols?: string | string[]
) {
super(url, protocols);
trackInstance(this);
}
}
return TrackedManagedWebSocket;
}
public pause(): void {
this.currentlyPaused = true;
for (const ws of this.instances) {
ws.pause();
}
}
public resume(): void {
this.currentlyPaused = false;
for (const ws of this.instances) {
ws.resume();
}
}
}

View file

@ -0,0 +1,17 @@
import * as os from "node:os";
export function parseConcurrency(): number {
const args = process.argv.slice(2);
for (let i = 0; i < args.length; i++) {
if (
(args[i] === "--concurrency" || args[i] === "-j") &&
i + 1 < args.length
) {
const n = parseInt(args[i + 1], 10);
if (!isNaN(n) && n > 0) {
return n;
}
}
}
return os.cpus().length;
}

View file

@ -0,0 +1,28 @@
import { Logger } from "sync-client";
export class PrefixedLogger extends Logger {
private readonly base: Logger;
private readonly prefix: string;
public constructor(base: Logger, prefix: string) {
super();
this.base = base;
this.prefix = prefix;
}
public override debug(message: string): void {
this.base.debug(`[${this.prefix}] ${message}`);
}
public override info(message: string): void {
this.base.info(`[${this.prefix}] ${message}`);
}
public override warn(message: string): void {
this.base.warn(`[${this.prefix}] ${message}`);
}
public override error(message: string): void {
this.base.error(`[${this.prefix}] ${message}`);
}
}

View file

@ -0,0 +1,33 @@
export async function runWithConcurrency<T, R>(
items: T[],
concurrency: number,
fn: (item: T) => Promise<R>
): Promise<R[]> {
const results: R[] = [];
const errors: unknown[] = [];
const executing = new Set<Promise<void>>();
for (let i = 0; i < items.length; i++) {
const index = i;
const p = fn(items[index])
.then((result) => {
results[index] = result;
})
.catch((error: unknown) => {
errors.push(error);
})
.finally(() => executing.delete(p));
executing.add(p);
if (executing.size >= concurrency) {
await Promise.race(executing);
}
}
// eslint-disable-next-line no-restricted-properties
await Promise.all(executing);
if (errors.length > 0) {
throw errors[0];
}
return results;
}

View file

@ -0,0 +1,235 @@
import { spawn, type ChildProcess } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { sleep } from "./utils/sleep";
import { findFreePort } from "./utils/find-free-port";
import type { Logger } from "sync-client";
import { STOP_TIMEOUT_MS } from "./consts";
export class ServerControl {
private process: ChildProcess | null = null;
private readonly serverPath: string;
private readonly baseConfigPath: string;
private readonly logger: Logger;
private _port: number | undefined;
private tempDir: string | undefined;
private _isPaused = false;
public constructor(serverPath: string, configPath: string, logger: Logger) {
this.serverPath = serverPath;
this.baseConfigPath = configPath;
this.logger = logger;
}
public get port(): number {
if (this._port === undefined) {
throw new Error("Server has not been started yet");
}
return this._port;
}
public get remoteUri(): string {
return `http://localhost:${this.port}`;
}
public async start(): Promise<void> {
if (this.process !== null) {
throw new Error("Server is already running");
}
const reservation = await findFreePort();
this._port = reservation.port;
// Prefer tmpfs (/host/tmp) over disk-backed /tmp for faster SQLite I/O
const tmpBase = fs.existsSync("/host/tmp") ? "/host/tmp" : os.tmpdir();
this.tempDir = fs.mkdtempSync(path.join(tmpBase, "vault-link-test-"));
const tempConfigPath = path.join(this.tempDir, "config.yml");
const dbDir = path.join(this.tempDir, "databases");
this.writeConfigFile(tempConfigPath, dbDir);
this.logger.info(
`Starting server: ${this.serverPath} (port ${this._port})`
);
// Release the port reservation right before spawning to minimize
// the TOCTOU window between port discovery and server binding.
reservation.release();
this.process = spawn(this.serverPath, [tempConfigPath], {
stdio: ["ignore", "pipe", "pipe"],
detached: false
});
this.process.stdout?.on("data", (data: Buffer) => {
this.logger.info(`[SERVER] ${data.toString().trim()}`);
});
this.process.stderr?.on("data", (data: Buffer) => {
this.logger.info(`[SERVER] ${data.toString().trim()}`);
});
this.process.on("error", (err) => {
this.logger.error(`[SERVER] Process error: ${err.message}`);
});
const currentProcess = this.process;
currentProcess.on("exit", (code, signal) => {
this.logger.info(
`Server exited with code ${code}, signal ${signal}`
);
// Only clear state if this handler is for the current process.
// A fast stop→start cycle could create a new process before this
// handler fires — clearing state here would corrupt the new one.
if (this.process === currentProcess) {
this.process = null;
this._isPaused = false;
}
});
try {
await this.waitForReady();
} catch (error) {
// Kill the spawned process if it failed to become ready,
// preventing a zombie process from lingering.
try {
await this.stop();
} catch {
// Best-effort cleanup
}
throw error;
}
}
public async waitForReady(maxAttempts = 50): Promise<void> {
const pingUrl = `${this.remoteUri}/vaults/test/ping`;
for (let i = 0; i < maxAttempts; i++) {
if (this.process?.exitCode !== null) {
throw new Error(
"Server process died while waiting for it to become ready"
);
}
try {
const response = await fetch(pingUrl);
if (response.ok) {
this.logger.info("[SERVER] Ready");
return;
}
} catch {
// Server not ready yet, continue polling
}
await sleep(100);
}
throw new Error("Server failed to start within timeout");
}
public pause(): void {
if (this.process?.pid === undefined) {
throw new Error("Server is not running");
}
if (this._isPaused) {
this.logger.warn("Server is already paused, skipping double-pause");
return;
}
this.logger.info("Server pausing...");
try {
process.kill(this.process.pid, "SIGSTOP");
this._isPaused = true;
this.logger.info("Server paused (SIGSTOP sent)");
} catch (error) {
throw new Error(
`Failed to pause server (pid ${this.process.pid}): ${error instanceof Error ? error.message : String(error)}`
);
}
}
public resume(): void {
if (this.process?.pid === undefined) {
throw new Error("Server is not running");
}
if (!this._isPaused) {
return;
}
this.logger.info("Server resuming...");
try {
process.kill(this.process.pid, "SIGCONT");
this._isPaused = false;
this.logger.info("Server resumed (SIGCONT sent)");
} catch (error) {
throw new Error(
`Failed to resume server (pid ${this.process.pid}): ${error instanceof Error ? error.message : String(error)}`
);
}
}
public async stop(): Promise<void> {
const proc = this.process;
if (proc?.pid === undefined) {
this.cleanupTempDir();
return;
}
// Resume if paused — a SIGSTOP'd process ignores SIGKILL
if (this._isPaused) {
try {
process.kill(proc.pid, "SIGCONT");
} catch {
// Process may already be gone
}
this._isPaused = false;
}
this.logger.info("Server stopping...");
// Set up a promise that resolves when the process actually exits.
const exitPromise = new Promise<void>((resolve) => {
if (proc.exitCode !== null) {
resolve();
return;
}
proc.on("exit", () => {
resolve();
});
});
try {
process.kill(proc.pid, "SIGKILL");
} catch {
// Process already gone
}
// Wait for the process to actually exit before cleaning up,
// with a 5s safety timeout to avoid hanging forever.
await Promise.race([exitPromise, sleep(STOP_TIMEOUT_MS)]);
this.process = null;
this._isPaused = false;
this.cleanupTempDir();
}
public isRunning(): boolean {
return this.process?.pid !== undefined;
}
private writeConfigFile(destPath: string, dbDir: string): void {
const baseConfig = fs.readFileSync(this.baseConfigPath, "utf-8");
const config = baseConfig
.replace(/^\s*port:\s*\d+/m, ` port: ${this._port}`)
.replace(
/^\s*databases_directory_path:\s*.+/m,
` databases_directory_path: ${dbDir}`
);
fs.writeFileSync(destPath, config);
}
private cleanupTempDir(): void {
if (this.tempDir !== undefined) {
try {
fs.rmSync(this.tempDir, { recursive: true, force: true });
} catch {
// Best-effort cleanup
}
this.tempDir = undefined;
}
}
}

View file

@ -0,0 +1,59 @@
import type { ServerControl } from "./server-control";
import type { Logger } from "sync-client";
export class ServerManager {
private readonly activeServers = new Set<ServerControl>();
private readonly logger: Logger;
private isShuttingDown = false;
public constructor(logger: Logger) {
this.logger = logger;
}
public track(server: ServerControl): void {
this.activeServers.add(server);
}
public untrack(server: ServerControl): void {
this.activeServers.delete(server);
}
public async stopAll(): Promise<void> {
if (this.isShuttingDown) {
return;
}
this.isShuttingDown = true;
const servers = Array.from(this.activeServers);
// eslint-disable-next-line no-restricted-properties
await Promise.all(
servers.map(async (server) => {
try {
await server.stop();
} catch {
// Best-effort cleanup during shutdown
}
})
);
}
public installSignalHandlers(): void {
process.on("SIGINT", () => {
this.logger.info("Received SIGINT, shutting down...");
void this.stopAll()
.catch(() => {
/* no-op */
})
.then(() => process.exit(130));
});
process.on("SIGTERM", () => {
this.logger.info("Received SIGTERM, shutting down...");
void this.stopAll()
.catch(() => {
/* no-op */
})
.then(() => process.exit(143));
});
}
}

View file

@ -0,0 +1,49 @@
import type { AssertableState } from "./utils/assertable-state";
export interface ClientState {
files: Map<string, string>;
clientFiles: Map<string, string>[];
}
export type TestStep =
| { type: "create"; client: number; path: string; content: string }
| { type: "update"; client: number; path: string; content: string }
| { type: "rename"; client: number; oldPath: string; newPath: string }
| {
type: "rename-next-write";
client: number;
oldPath: string;
newPath: string;
}
| { type: "delete"; client: number; path: string }
| { type: "sync"; client?: number }
| { type: "disable-sync"; client: number }
| { type: "enable-sync"; client: number }
| { type: "pause-server" }
| { type: "resume-server" }
| {
type: "resume-server-until-history-then-pause";
client: number;
syncType: "CREATE" | "UPDATE" | "DELETE";
path: string;
}
| { type: "barrier" }
| { type: "assert-consistent"; verify?: (state: AssertableState) => void }
| { type: "pause-websocket"; client: number }
| { type: "resume-websocket"; client: number }
| { type: "drop-next-create-response"; client: number }
| { type: "wait-for-dropped-create-response"; client: number }
| { type: "sleep"; ms: number }
| { type: "reset"; client: number };
export interface TestDefinition {
description?: string;
clients: number;
steps: TestStep[];
}
export interface TestResult {
success: boolean;
error?: string;
duration?: number;
}

View file

@ -0,0 +1,249 @@
import type { TestDefinition } from "./test-definition";
import { renameCreateConflictTest } from "./tests/rename-create-conflict.test";
import { renameChainTest } from "./tests/rename-chain.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 { deleteRecreateSamePathTest } from "./tests/delete-recreate-same-path.test";
import { offlineRenameAndEditTest } from "./tests/offline-rename-and-edit.test";
import { simultaneousCreateDeleteSamePathTest } from "./tests/simultaneous-create-delete-same-path.test";
import { idempotencyAfterServerPauseTest } from "./tests/idempotency-after-server-pause.test";
import { sequentialCreateDuplicateContentTest } from "./tests/sequential-create-duplicate-content.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 { 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 { serverPauseUpdateAndCreateTest } from "./tests/server-pause-update-and-create.test";
import { renameSwapTest } from "./tests/rename-swap.test";
import { renameCircularTest } from "./tests/rename-circular.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 { overlappingEditsSameSectionTest } from "./tests/overlapping-edits-same-section.test";
import { rapidUpdatesAfterMergeTest } from "./tests/rapid-updates-after-merge.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 { serverPauseRenameEditResumeTest } from "./tests/server-pause-rename-edit-resume.test";
import { offlineUpdateBothThenDeleteOneTest } from "./tests/offline-update-both-then-delete-one.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 { rapidCreateUpdateDeleteCycleTest } from "./tests/rapid-create-update-delete-cycle.test";
import { serverPauseBothEditSameFileTest } from "./tests/server-pause-both-edit-same-file.test";
import { deleteRecreateDifferentContentTest } from "./tests/delete-recreate-different-content.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";
import { moveThenDeleteStalePathTest } from "./tests/move-then-delete-stale-path.test";
import { interruptedDeleteRetryTest } from "./tests/interrupted-delete-retry.test";
import { updateDoesNotSurvivesRemoteDeleteTest } from "./tests/update-survives-remote-delete.test";
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 { watermarkAdvancesOnSkipTest } from "./tests/watermark-advances-on-skip.test";
import { watermarkGapRemoteUpdateNotRecordedTest } from "./tests/watermark-gap-remote-update-not-recorded.test";
import { queueResetLosesCoalescedLocalEditTest } from "./tests/queue-reset-loses-coalesced-local-edit.test";
import { renameToPendingPathFallbackTest } from "./tests/rename-to-pending-path-fallback.test";
import { moveRemoteUpdateRevertsRenameTest } from "./tests/move-remote-update-reverts-rename.test";
import { localEditLostDuringCreateMergeTest } from "./tests/local-edit-lost-during-create-merge.test";
import { renamePendingCreateBeforeResponseTest } from "./tests/rename-pending-create-before-response.test";
import { createRenameResponseSkipsFileTest } from "./tests/create-rename-response-skips-file.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";
import { textPendingCreateNotDisplacedTest } from "./tests/1-text-pending-create-not-displaced.test";
import { binaryPendingCreateNotDisplacedTest } from "./tests/2-binary-pending-create-not-displaced.test";
import { coalesceUpdateRemoteUpdateDataLossTest } from "./tests/3-coalesce-update-remote-update-data-loss.test";
import { coalescedRemoteUpdateWatermarkLossTest } from "./tests/4-coalesced-remote-update-watermark-loss.test";
import { concurrentDeleteDuringRemoteUpdateTest } from "./tests/5-concurrent-delete-during-remote-update.test";
import { concurrentEditExactSamePositionTest } from "./tests/6-concurrent-edit-exact-same-position.test";
import { concurrentRenameAndCreateAtTargetTest as concurrentRenameAndCreateAtTargetRenameFirstTest } from "./tests/7-concurrent-rename-and-create-at-target.test";
import { concurrentRenameAndCreateAtTargetTest as concurrentRenameAndCreateAtTargetCreateFirstTest } from "./tests/8-concurrent-rename-and-create-at-target.test";
import { concurrentRenameSameTargetTest } from "./tests/9-concurrent-rename-same-target.test";
import { concurrentUpdateDiffConsistencyTest } from "./tests/10-concurrent-update-diff-consistency.test";
import { userParenthesizedFileNotDeletedTest } from "./tests/10-user-parenthesized-file-not-deleted.test";
import { createDeleteNoopTest } from "./tests/11-create-delete-noop.test";
import { createMergeDeleteTest } from "./tests/12-create-merge-delete.test";
import { moveIdenticalContentAmbiguityTest } from "./tests/13-move-identical-content-ambiguity.test";
import { createUpdateCoalesceServerPauseTest } from "./tests/15-create-update-coalesce-server-pause.test";
import { createDuringReconciliationTest } from "./tests/16-create-during-reconciliation.test";
import { createMergePreservesRenamedUpdateTest } from "./tests/17-create-merge-preserves-renamed-update.test";
import { createRenameCreateSamePathTest } from "./tests/18-create-rename-create-same-path.test";
import { moveChainThreeFilesTest } from "./tests/19-move-chain-three-files.test";
import { deleteByOtherClientThenRecreateTest } from "./tests/delete-by-other-client-then-recreate.test";
import { onlineDeleteRecreateRapidCycleTest } from "./tests/online-delete-recreate-rapid-cycle.test";
import { onlineEditVsDeleteConvergenceTest } from "./tests/online-edit-vs-delete-convergence.test";
import { rapidEditDeleteOnlineConvergenceTest } from "./tests/rapid-edit-delete-online-convergence.test";
import { serverPauseDeleteRecreateTest } from "./tests/server-pause-delete-recreate.test";
import { onlineBothCreateSamePathDeconflictTest } from "./tests/online-both-create-same-path-deconflict.test";
import { onlineCreateUpdateWhileOtherCreatesSamePathTest } from "./tests/online-create-update-while-other-creates-same-path.test";
import { displacedFileNotMarkedDeletedTest } from "./tests/displaced-file-not-marked-deleted.test";
import { remoteUpdateResurrectsDeletedDocTest } from "./tests/remote-update-resurrects-deleted-doc.test";
import { localUpdateSurvivesRemoteRenameTest } from "./tests/local-update-survives-remote-rename.test";
import { mergingUpdateResponseSurvivesUserRenameTest } from "./tests/merging-update-response-survives-user-rename.test";
import { catchupCreateAndUpdateNotSkippedTest } from "./tests/catchup-create-and-update-not-skipped.test";
import { localRenameSurvivesRemoteRenameTest } from "./tests/local-rename-survives-remote-rename.test";
import { renameChainDuringPendingCreateTest } from "./tests/rename-chain-during-pending-create.test";
import { remoteRenameCollidesWithPendingLocalCreateTest } from "./tests/remote-rename-collides-with-pending-local-create.test";
import { remoteUpdateSurvivesUserRenameTest } from "./tests/remote-update-survives-user-rename.test";
import { sameDocIdCollapseOnLocalCreateAfterRemoteCreateTest } from "./tests/same-doc-id-collapse-on-local-create-after-remote-create.test";
import { sameDocIdCollapseAfterRemoteQuickWriteAndPendingRenameTest } from "./tests/same-doc-id-collapse-after-remote-quick-write-and-pending-rename.test";
import { renameOverwritesPendingCreateThenDeleteTest } from "./tests/rename-overwrites-pending-create-then-delete.test";
import { deleteRecreatedPendingCreateWithStaleDeletingRecordTest } from "./tests/delete-recreated-pending-create-with-stale-deleting-record.test";
import { queuedCreateDeleteDoesNotHijackReusedPathTest } from "./tests/queued-create-delete-does-not-hijack-reused-path.test";
import { renamedPendingCreateReusedPathThenDeleteTest } from "./tests/renamed-pending-create-reused-path-then-delete.test";
import { renamePendingCreateOntoPendingDeletePathTest } from "./tests/rename-pending-create-onto-pending-delete-path.test";
import { remoteQuickWriteRenameBeforeRecordTest } from "./tests/remote-quick-write-rename-before-record.test";
import { selfMergePendingRenameAliasesSecondCreateTest } from "./tests/self-merge-pending-rename-aliases-second-create.test";
export const TESTS: Partial<Record<string, TestDefinition>> = {
"rename-create-conflict": renameCreateConflictTest,
"rename-chain": renameChainTest,
"rename-update-conflict": renameUpdateConflictTest,
"delete-rename-conflict": deleteRenameConflictTest,
"multi-file-operations": multiFileOperationsTest,
"delete-recreate-same-path": deleteRecreateSamePathTest,
"offline-rename-and-edit": offlineRenameAndEditTest,
"simultaneous-create-delete-same-path":
simultaneousCreateDeleteSamePathTest,
"idempotency-after-server-pause": idempotencyAfterServerPauseTest,
"sequential-create-duplicate-content": sequentialCreateDuplicateContentTest,
"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-concurrent-renames": offlineConcurrentRenamesTest,
"offline-multiple-edits": offlineMultipleEditsTest,
"server-pause-both-clients-create": serverPauseBothClientsCreateTest,
"server-pause-update-and-create": serverPauseUpdateAndCreateTest,
"rename-swap": renameSwapTest,
"rename-circular": renameCircularTest,
"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,
"overlapping-edits-same-section": overlappingEditsSameSectionTest,
"rapid-updates-after-merge": rapidUpdatesAfterMergeTest,
"delete-recreate-concurrent-update": deleteRecreateConcurrentUpdateTest,
"move-and-concurrent-remote-update": moveAndConcurrentRemoteUpdateTest,
"double-offline-cycle": doubleOfflineCycleTest,
"server-pause-rename-edit-resume": serverPauseRenameEditResumeTest,
"offline-update-both-then-delete-one": offlineUpdateBothThenDeleteOneTest,
"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,
"rapid-create-update-delete-cycle": rapidCreateUpdateDeleteCycleTest,
"server-pause-both-edit-same-file": serverPauseBothEditSameFileTest,
"delete-recreate-different-content": deleteRecreateDifferentContentTest,
"update-during-create-processing": updateDuringCreateProcessingTest,
"offline-move-then-remote-delete": offlineMoveThenRemoteDeleteTest,
"reset-clears-recently-deleted-resurrection":
resetClearsRecentlyDeletedResurrectionTest,
"move-then-delete-stale-path": moveThenDeleteStalePathTest,
"offline-delete-vs-remote-update": offlineDeleteVsRemoteUpdateTest,
"interrupted-delete-retry": interruptedDeleteRetryTest,
"update-survives-remote-delete": updateDoesNotSurvivesRemoteDeleteTest,
"move-preserves-remote-update": movePreservesRemoteUpdateTest,
"recently-deleted-cleared-on-reconnect":
recentlyDeletedClearedOnReconnectTest,
"migrate-key-preserves-existing": migrateKeyPreservesExistingTest,
"watermark-advances-on-skip": watermarkAdvancesOnSkipTest,
"watermark-gap-remote-update-not-recorded":
watermarkGapRemoteUpdateNotRecordedTest,
"queue-reset-loses-coalesced-local-edit":
queueResetLosesCoalescedLocalEditTest,
"rename-to-pending-path-fallback": renameToPendingPathFallbackTest,
"move-remote-update-reverts-rename": moveRemoteUpdateRevertsRenameTest,
"local-edit-lost-during-create-merge": localEditLostDuringCreateMergeTest,
"rename-pending-create-before-response":
renamePendingCreateBeforeResponseTest,
"create-rename-response-skips-file": createRenameResponseSkipsFileTest,
"online-create-rename-concurrent-create-orphan":
onlineCreateRenameConcurrentCreateOrphanTest,
"concurrent-rename-first-wins": concurrentRenameFirstWinsTest,
"binary-to-text-transition": binaryToTextTransitionTest,
"text-pending-create-not-displaced": textPendingCreateNotDisplacedTest,
"binary-pending-create-not-displaced": binaryPendingCreateNotDisplacedTest,
"coalesce-update-remote-update-data-loss":
coalesceUpdateRemoteUpdateDataLossTest,
"coalesced-remote-update-watermark-loss":
coalescedRemoteUpdateWatermarkLossTest,
"concurrent-delete-during-remote-update":
concurrentDeleteDuringRemoteUpdateTest,
"concurrent-edit-exact-same-position": concurrentEditExactSamePositionTest,
"concurrent-rename-and-create-at-target-rename-first":
concurrentRenameAndCreateAtTargetRenameFirstTest,
"concurrent-rename-and-create-at-target-create-first":
concurrentRenameAndCreateAtTargetCreateFirstTest,
"concurrent-rename-same-target": concurrentRenameSameTargetTest,
"concurrent-update-diff-consistency": concurrentUpdateDiffConsistencyTest,
"user-parenthesized-file-not-deleted": userParenthesizedFileNotDeletedTest,
"create-delete-noop": createDeleteNoopTest,
"create-merge-delete": createMergeDeleteTest,
"move-identical-content-ambiguity": moveIdenticalContentAmbiguityTest,
"create-update-coalesce-server-pause": createUpdateCoalesceServerPauseTest,
"create-during-reconciliation": createDuringReconciliationTest,
"create-merge-preserves-renamed-update":
createMergePreservesRenamedUpdateTest,
"create-rename-create-same-path": createRenameCreateSamePathTest,
"move-chain-three-files": moveChainThreeFilesTest,
"delete-by-other-client-then-recreate": deleteByOtherClientThenRecreateTest,
"online-delete-recreate-rapid-cycle": onlineDeleteRecreateRapidCycleTest,
"online-edit-vs-delete-convergence": onlineEditVsDeleteConvergenceTest,
"rapid-edit-delete-online-convergence":
rapidEditDeleteOnlineConvergenceTest,
"server-pause-delete-recreate": serverPauseDeleteRecreateTest,
"online-both-create-same-path-deconflict":
onlineBothCreateSamePathDeconflictTest,
"online-create-update-while-other-creates-same-path":
onlineCreateUpdateWhileOtherCreatesSamePathTest,
"displaced-file-not-marked-deleted": displacedFileNotMarkedDeletedTest,
"remote-update-resurrects-deleted-doc":
remoteUpdateResurrectsDeletedDocTest,
"local-update-survives-remote-rename": localUpdateSurvivesRemoteRenameTest,
"merging-update-response-survives-user-rename":
mergingUpdateResponseSurvivesUserRenameTest,
"catchup-create-and-update-not-skipped":
catchupCreateAndUpdateNotSkippedTest,
"local-rename-survives-remote-rename": localRenameSurvivesRemoteRenameTest,
"rename-chain-during-pending-create": renameChainDuringPendingCreateTest,
"remote-rename-collides-with-pending-local-create":
remoteRenameCollidesWithPendingLocalCreateTest,
"remote-update-survives-user-rename": remoteUpdateSurvivesUserRenameTest,
"same-doc-id-collapse-on-local-create-after-remote-create":
sameDocIdCollapseOnLocalCreateAfterRemoteCreateTest,
"renamed-pending-create-reused-path-then-delete":
renamedPendingCreateReusedPathThenDeleteTest,
"rename-pending-create-onto-pending-delete-path":
renamePendingCreateOntoPendingDeletePathTest,
"rename-overwrites-pending-create-then-delete":
renameOverwritesPendingCreateThenDeleteTest,
"same-doc-id-collapse-after-remote-quick-write-and-pending-rename":
sameDocIdCollapseAfterRemoteQuickWriteAndPendingRenameTest,
"delete-recreated-pending-create-with-stale-deleting-record":
deleteRecreatedPendingCreateWithStaleDeletingRecordTest,
"queued-create-delete-does-not-hijack-reused-path":
queuedCreateDeleteDoesNotHijackReusedPathTest,
"remote-quick-write-rename-before-record":
remoteQuickWriteRenameBeforeRecordTest,
"self-merge-pending-rename-aliases-second-create":
selfMergePendingRenameAliasesSecondCreateTest
};

View file

@ -0,0 +1,407 @@
import type { TestDefinition, TestResult, 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 {
CONVERGENCE_TIMEOUT_MS,
CONVERGENCE_RETRY_DELAY_MS,
AGENT_INIT_TIMEOUT_MS,
IS_SYNC_ENABLED_BY_DEFAULT
} from "./consts";
import { randomUUID } from "node:crypto";
export class TestRunner {
private agents: DeterministicAgent[] = [];
private readonly serverControl: ServerControl;
private readonly token: string;
private readonly remoteUri: string;
private readonly logger: Logger;
public constructor(
serverControl: ServerControl,
logger: Logger,
token: string,
remoteUri: string
) {
this.serverControl = serverControl;
this.logger = logger;
this.token = token;
this.remoteUri = remoteUri;
}
public async runTest(
name: string,
test: TestDefinition
): Promise<TestResult> {
const startTime = Date.now();
this.logger.info(`Running test: ${name}`);
if (test.description !== undefined && test.description !== "") {
this.logger.info(`Description: ${test.description}`);
}
this.logger.info(`Clients: ${test.clients}`);
this.logger.info(`Steps: ${test.steps.length}`);
try {
assert(
this.serverControl.isRunning(),
"Server is not running before test start"
);
await this.initializeAgents(test.clients);
for (let i = 0; i < test.steps.length; i++) {
const step = test.steps[i];
this.logger.info(
`Step ${i + 1}/${test.steps.length}: ${JSON.stringify(step)}`
);
await this.executeStep(step);
}
await this.cleanup();
const duration = Date.now() - startTime;
this.logger.info(`\n✓ Test passed: ${name} (${duration}ms)`);
return {
success: true,
duration
};
} catch (error) {
const duration = Date.now() - startTime;
const errorMessage =
error instanceof Error ? error.message : String(error);
this.logger.info(`\n✗ Test failed: ${name}`);
this.logger.info(`Error: ${errorMessage}`);
await this.cleanup();
return {
success: false,
error: errorMessage,
duration
};
}
}
private async initializeAgents(count: number): Promise<void> {
assert(count > 0, `Client count must be positive, got ${count}`);
const vaultName = `test-${randomUUID()}`;
this.logger.info(
`Initializing ${count} agents with vault: ${vaultName}`
);
for (let i = 0; i < count; i++) {
const settings: Partial<SyncSettings> = {
isSyncEnabled: IS_SYNC_ENABLED_BY_DEFAULT,
token: this.token,
vaultName,
remoteUri: this.remoteUri
};
const agent = new DeterministicAgent(i, settings, (msg) => {
this.logger.info(msg);
});
// Push before init so cleanup() handles this agent if init fails
this.agents.push(agent);
await withTimeout(
agent.init(fetch),
AGENT_INIT_TIMEOUT_MS,
`Client ${i} init timed out after ${AGENT_INIT_TIMEOUT_MS}ms`
);
this.logger.info(`Initialized client ${i}`);
}
this.logger.info("All agents initialized");
}
private getAgent(index: number): DeterministicAgent {
assert(
index >= 0 && index < this.agents.length,
`Client index ${index} out of bounds (have ${this.agents.length} agents)`
);
return this.agents[index];
}
private async executeStep(step: TestStep): Promise<void> {
switch (step.type) {
case "create":
case "update":
await this.getAgent(step.client).write(
step.path,
new TextEncoder().encode(step.content)
);
break;
case "rename":
await this.getAgent(step.client).rename(
step.oldPath,
step.newPath
);
break;
case "rename-next-write":
this.getAgent(step.client).renameNextWrite(
step.oldPath,
step.newPath
);
break;
case "delete":
await this.getAgent(step.client).delete(step.path);
break;
case "sync":
if (step.client !== undefined) {
await this.getAgent(step.client).waitForSync();
} else {
for (const agent of this.agents) {
await agent.waitForSync();
}
}
break;
case "disable-sync":
await this.getAgent(step.client).disableSync();
break;
case "enable-sync":
await this.getAgent(step.client).enableSync();
break;
case "pause-server":
this.serverControl.pause();
break;
case "resume-server":
this.serverControl.resume();
// Verify the server is actually responsive before proceeding.
// This replaces relying solely on hardcoded waits.
await this.serverControl.waitForReady();
break;
case "resume-server-until-history-then-pause": {
const agent = this.getAgent(step.client);
const historySeen = agent.waitForHistoryEntry(
(entry) =>
entry.details.type === step.syncType &&
entry.details.relativePath === step.path,
() => this.serverControl.pause()
);
this.serverControl.resume();
await historySeen;
break;
}
case "barrier":
await this.waitForConvergence();
break;
case "assert-consistent":
await this.assertConsistent(step.verify);
break;
case "pause-websocket":
this.getAgent(step.client).pauseWebSocket();
break;
case "resume-websocket":
this.getAgent(step.client).resumeWebSocket();
break;
case "drop-next-create-response":
this.getAgent(step.client).dropNextCreateResponse();
break;
case "wait-for-dropped-create-response":
await this.getAgent(step.client).waitForDroppedCreateResponse();
break;
case "sleep":
await sleep(step.ms);
break;
case "reset":
await this.getAgent(step.client).reset();
break;
default: {
const unknownStep = step as { type: string };
throw new Error(`Unknown step type: ${unknownStep.type}`);
}
}
}
/**
* Wait for all agents to reach a consistent state.
*
* Waiting for agents is done in two full rounds: the first round
* drains in-flight operations, but completing those operations can
* trigger new work on OTHER agents via server broadcasts. The second
* round waits for that cascading work to settle. Deeper cascades
* are handled by the outer retry loop.
*/
private async waitForConvergence(): Promise<void> {
this.logger.info("Barrier: waiting for convergence...");
const deadline = Date.now() + CONVERGENCE_TIMEOUT_MS;
let lastError: Error | undefined = undefined;
while (Date.now() < deadline) {
await this.waitAllAgentsSettled();
try {
await this.assertConsistent();
this.logger.info("Barrier complete: all clients converged");
return;
} catch (error) {
lastError =
error instanceof Error ? error : new Error(String(error));
this.logger.info("Barrier: not yet converged, retrying...");
await sleep(CONVERGENCE_RETRY_DELAY_MS);
}
}
// Final attempt — let the error propagate
await this.waitAllAgentsSettled();
try {
await this.assertConsistent();
this.logger.info("Barrier complete: all clients converged");
} catch (error) {
throw new Error(
`Convergence timed out after ${CONVERGENCE_TIMEOUT_MS}ms: ${error instanceof Error ? error.message : String(error)}`,
{ cause: lastError }
);
}
}
/**
* 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> {
const rounds = this.agents.length + 1;
for (let round = 0; round < rounds; round++) {
for (const agent of this.agents) {
await agent.waitForSync();
}
}
}
private async assertConsistent(
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"
);
// 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.listFilesRecursively()).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 < clientFiles.length; i++) {
const agentFileKeys = Array.from(clientFiles[i].keys());
this.logger.info(
`Client ${i} has ${agentFileKeys.length} files: ${agentFileKeys.join(", ")}`
);
assert(
agentFileKeys.length === referenceFiles.length,
`File count mismatch: client 0 has ${referenceFiles.length} files, client ${i} has ${agentFileKeys.length} files`
);
for (let j = 0; j < agentFileKeys.length; j++) {
assert(
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 = clientFiles[0].get(file);
const agentContent = clientFiles[i].get(file);
assert(
referenceContent === agentContent,
`Content mismatch for ${file}:\nClient 0: "${referenceContent}"\nClient ${i}: "${agentContent}"`
);
}
}
this.logger.info("✓ All clients are consistent");
if (verify) {
this.logger.info("Running custom verification...");
try {
verify(
new AssertableState({
files: clientFiles[0],
clientFiles
})
);
} catch (error) {
const msg =
error instanceof Error ? error.message : String(error);
throw new Error(`Custom verification failed: ${msg}`);
}
this.logger.info("✓ Custom verification passed");
}
}
private async cleanup(): Promise<void> {
// Always resume the server in case a test paused it and then
// failed before reaching the resume step. Without this, all
// subsequent tests would hang because the server process is
// frozen (SIGSTOP) and can't respond to HTTP or WebSocket.
try {
this.serverControl.resume();
} catch {
// Server wasn't paused or isn't running — safe to ignore
}
this.logger.info("\nCleaning up agents...");
for (const agent of this.agents) {
try {
await agent.cleanup();
} catch (error) {
this.logger.warn(
`Agent cleanup error: ${error instanceof Error ? error.message : String(error)}`
);
}
}
this.agents = [];
this.logger.info("Cleanup complete");
}
}

View file

@ -0,0 +1,36 @@
import type { AssertableState } from "../utils/assertable-state";
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: AssertableState): void => {
s.assertFileCount(1)
.assertFileExists("data.txt")
.assertAnyFileContains("client-0", "client-1");
}
}
]
};

View file

@ -0,0 +1,51 @@
import type { AssertableState } from "../utils/assertable-state";
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: AssertableState): void => {
state
.assertFileCount(1)
.assertContent(
"doc.md",
"header by 0\nmiddle\nfooter by 1"
);
}
}
]
};

View file

@ -0,0 +1,47 @@
import type { AssertableState } from "../utils/assertable-state";
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: AssertableState): void => {
state
.assertFileCount(3)
.assertFileExists("Chapter.bin")
.assertFileExists("Chapter (1).bin")
.assertFileExists("Chapter (2).bin");
}
}
]
};

View file

@ -0,0 +1,27 @@
import type { AssertableState } from "../utils/assertable-state";
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: AssertableState): void => {
s.assertFileNotExists("temp.md");
}
}
]
};

View file

@ -0,0 +1,37 @@
import type { AssertableState } from "../utils/assertable-state";
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: AssertableState): void => {
state
.assertFileCount(1)
.assertContains("A.md", "from-zero", "from-one");
}
},
{ type: "delete", client: 0, path: "A.md" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(0).assertFileNotExists("A.md");
}
}
]
};

View file

@ -0,0 +1,44 @@
import type { AssertableState } from "../utils/assertable-state";
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: AssertableState): void => {
state
.assertFileCount(1)
.assertFileNotExists("A.md")
.assertFileNotExists("B.md")
.assertContent("C.md", "identical content");
}
}
]
};

View file

@ -0,0 +1,32 @@
import type { AssertableState } from "../utils/assertable-state";
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: AssertableState): void => {
state
.assertFileCount(1)
.assertContent("doc.md", "final version");
}
}
]
};

View file

@ -0,0 +1,50 @@
import type { AssertableState } from "../utils/assertable-state";
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: AssertableState): void => {
state
.assertFileCount(3)
.assertContent("A.md", "offline A")
.assertContent("B.md", "offline B")
.assertContent("C.md", "post-reconnect C");
}
}
]
};

View file

@ -0,0 +1,59 @@
import type { AssertableState } from "../utils/assertable-state";
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: "assert-consistent",
verify: (state: AssertableState): void => {
state.assertContains("doc.md", "alpha", "beta");
}
},
{ 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: AssertableState): void => {
state
.assertContent("moved.md", "alpha beta extra-update")
.assertContent("doc.md", "new-content");
}
}
]
};

View file

@ -0,0 +1,34 @@
import type { AssertableState } from "../utils/assertable-state";
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: "barrier" },
{
type: "assert-consistent",
verify: (state: AssertableState): void => {
state
.assertFileCount(3)
.assertContent("B.md", "first file")
.assertContent("C.md", "second file")
.assertContent("A.md", "third file");
}
}
]
};

View file

@ -0,0 +1,42 @@
import type { AssertableState } from "../utils/assertable-state";
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: AssertableState): void => {
state
.assertFileCount(3)
.assertContent("A.md", "was C")
.assertContent("B.md", "was A")
.assertContent("C.md", "was B");
}
}
]
};

View file

@ -0,0 +1,40 @@
import type { AssertableState } from "../utils/assertable-state";
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: AssertableState): void => {
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,53 @@
import type { AssertableState } from "../utils/assertable-state";
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: AssertableState): void => {
state
.assertFileCount(1)
.assertContains(
"doc.md",
"client 0 addition",
"client 1 addition"
);
}
}
]
};

View file

@ -0,0 +1,53 @@
import type { AssertableState } from "../utils/assertable-state";
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: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
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: AssertableState): void => {
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: AssertableState): void => {
s.assertFileCount(1).assertContent("doc.md", "final update");
}
}
]
};

View file

@ -0,0 +1,32 @@
import type { AssertableState } from "../utils/assertable-state";
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: AssertableState): void => {
state.assertFileCount(0);
}
}
]
};

View file

@ -0,0 +1,49 @@
import type { AssertableState } from "../utils/assertable-state";
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: AssertableState): void => {
state
.assertFileCount(1)
.assertContains("doc.md", "slow", "fast", "brown fox");
}
}
]
};

View file

@ -0,0 +1,52 @@
import type { AssertableState } from "../utils/assertable-state";
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. We can't merge the create because it would result in a cycle",
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: AssertableState): void => {
state
.assertFileNotExists("X.md")
.assertFileExists("Y.md")
.assertFileExists("Y (1).md")
.assertAnyFileContains(
"original file X",
"brand new Y content"
);
}
}
]
};

View file

@ -0,0 +1,49 @@
import type { AssertableState } from "../utils/assertable-state";
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: AssertableState): void => {
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 { AssertableState } from "../utils/assertable-state";
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: "barrier" },
{
type: "assert-consistent",
verify: (state: AssertableState): void => {
state
.assertFileCount(2)
.assertFileNotExists("A.md")
.assertFileNotExists("B.md")
.assertFileExists("C.md")
.assertFileExists("C (1).md")
.assertAnyFileContains("content-a", "content-b");
}
}
]
};

View file

@ -0,0 +1,97 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const binaryToTextTransitionTest: TestDefinition = {
description:
"A .bin file is created and synced. Both clients edit it offline " +
"(binary last-write-wins), then client 0 renames it to .md and " +
"writes a clean text baseline. Both clients edit different sections " +
"offline. The text 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: AssertableState): void => {
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" },
{ type: "update", client: 1, path: "data.bin", content: "version B" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContainsAny(
"data.bin",
"version A",
"version B"
);
}
},
{ type: "disable-sync", client: 1 },
{ type: "rename", client: 0, oldPath: "data.bin", newPath: "data.md" },
{
type: "update",
client: 0,
path: "data.md",
content: "top line\nmiddle line\nbottom line"
},
{ type: "sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertContent(
"data.md",
"top line\nmiddle line\nbottom line"
);
}
},
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
{
type: "update",
client: 0,
path: "data.md",
content: "alpha\nmiddle line\nbottom line"
},
{
type: "update",
client: 1,
path: "data.md",
content: "top line\nmiddle line\nbeta"
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContains("data.md", "alpha", "beta");
}
}
]
};

View file

@ -0,0 +1,66 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const catchupCreateAndUpdateNotSkippedTest: TestDefinition = {
description:
"Client 1 disconnects (sync disabled). Client 0 creates a doc and " +
"then updates it. When Client 1 reconnects, the server's catch-up " +
"stream sends only the doc's *latest* version (the update), not the " +
"full history. Pre-fix the wire's `is_new_file` was set to " +
"`creation == latest_version`, so the catch-up flagged the doc as " +
"non-new even though Client 1 had never seen its creation. Client " +
"1's `processRemoteChange` then dropped it as a 'stale RemoteChange " +
"for untracked, non-new document' and the doc was silently lost. " +
"Post-fix `is_new_file` in the catch-up stream means 'new relative " +
"to the recipient's watermark' (`creation > last_seen_vault_update_id`).",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
// Establish a baseline so Client 1's last_seen is non-zero before
// we take it offline. This makes the bug genuinely about catch-up
// missing the create rather than just an empty-vault first sync.
{ type: "create", client: 0, path: "warmup.md", content: "w\n" },
{ type: "barrier" },
// Client 1 goes offline.
{ type: "disable-sync", client: 1 },
// Client 0 creates the doc (vault_update_id v_C, after Client 1's
// watermark). Client 1 doesn't see this because it's offline.
{ type: "create", client: 0, path: "doc.md", content: "v1\n" },
// Wait for the create's HTTP to land before the update; otherwise
// both writes are coalesced into a single POST and the server
// never sees the doc as "create followed by update".
{ type: "sync", client: 0 },
// Client 0 updates the doc (vault_update_id v_X > v_C). The
// server's `latest_document_versions` view now returns the
// *update* row — its `creation_vault_update_id != vault_update_id`.
{
type: "update",
client: 0,
path: "doc.md",
content: "v1\nupdate\n"
},
{ type: "sync", client: 0 },
// Client 1 reconnects. Server's catch-up replays docs with
// `vault_update_id > last_seen`. For doc.md it sends v_X with
// `is_new_file` derived from `creation_vault_update_id >
// last_seen_vault_update_id` (post-fix) — so Client 1 treats it
// as a fresh create and downloads the latest content.
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state: AssertableState): void => {
state.assertFileCount(2);
state.assertFileExists("doc.md");
state.assertContent("doc.md", "v1\nupdate\n");
state.assertContent("warmup.md", "w\n");
}
}
]
};

View file

@ -0,0 +1,61 @@
import type { AssertableState } from "../utils/assertable-state";
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: AssertableState): void => {
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: AssertableState): void => {
s.assertFileNotExists("A.md")
.assertFileCount(2)
.assertContent("B.md", "edit from 0\nline 2\nline 3")
.assertContent("C.md", "line 1\nline 2\nedit from 1");
}
}
]
};

View file

@ -0,0 +1,36 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const createRenameResponseSkipsFileTest: TestDefinition = {
description:
"Client 0 creates a file online then immediately renames it. " +
"Client 1 must receive the file content at the renamed path.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{
type: "create",
client: 0,
path: "doc.md",
content: "the-content"
},
{
type: "rename",
client: 0,
oldPath: "doc.md",
newPath: "renamed.md"
},
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertAnyFileContains("the-content");
}
}
]
};

View file

@ -0,0 +1,40 @@
import type { AssertableState } from "../utils/assertable-state";
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: AssertableState): void => {
s.assertFileNotExists("A.md");
}
},
{
type: "create",
client: 0,
path: "A.md",
content: "recreated by client 0"
},
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertContent("A.md", "recreated by client 0");
}
}
]
};

View file

@ -0,0 +1,35 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const deleteDuringPendingCreateTest: TestDefinition = {
description:
"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 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "pause-server" },
{
type: "create",
client: 0,
path: "ephemeral.md",
content: "this will be deleted"
},
{ type: "delete", client: 0, path: "ephemeral.md" },
{ type: "resume-server" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(0).assertFileNotExists("ephemeral.md");
}
}
]
};

View file

@ -0,0 +1,42 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const deleteRecreateConcurrentUpdateTest: TestDefinition = {
description:
"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: [
{ type: "create", client: 0, path: "A.md", content: "original" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "disable-sync", client: 0 },
{ type: "delete", client: 0, path: "A.md" },
{
type: "create",
client: 0,
path: "A.md",
content: "recreated by client 0"
},
{
type: "update",
client: 1,
path: "A.md",
content: "updated by client 1"
},
{ type: "sync", client: 1 },
{ type: "enable-sync", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileExists("A.md").assertContains("A.md", "recreated");
}
}
]
};

View file

@ -0,0 +1,54 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const deleteRecreateDifferentContentTest: TestDefinition = {
description:
"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: [
{
type: "create",
client: 0,
path: "A.md",
content: "original content here"
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
{ type: "delete", client: 0, path: "A.md" },
{
type: "create",
client: 0,
path: "A.md",
content: "brand new content"
},
{
type: "update",
client: 1,
path: "A.md",
content: "edit from client 1"
},
{ type: "enable-sync", client: 0 },
{ type: "sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContains(
"A.md",
"brand new",
"client 1"
);
}
}
]
};

View file

@ -0,0 +1,34 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const deleteRecreateSamePathTest: TestDefinition = {
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: [
{ type: "create", client: 0, path: "A.md", content: "version 1" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertContent("A.md", "version 1");
}
},
{ type: "disable-sync", client: 0 },
{ type: "delete", client: 0, path: "A.md" },
{ type: "create", client: 0, path: "A.md", content: "version 2" },
{ type: "enable-sync", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertContent("A.md", "version 2");
}
}
]
};

View file

@ -0,0 +1,52 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const deleteRecreatedPendingCreateWithStaleDeletingRecordTest: TestDefinition =
{
description:
"A local delete for a recreated pending create must target the " +
"new pending create, not an older same-path record whose server " +
"delete has been acked but whose WebSocket delete receipt is " +
"still paused.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "pause-websocket", client: 0 },
{ type: "pause-server" },
{
type: "create",
client: 0,
path: "binary-14.bin",
content: "BINARY:first"
},
{ type: "sleep", ms: 100 },
{ type: "delete", client: 0, path: "binary-14.bin" },
{ type: "resume-server" },
{ type: "sync", client: 0 },
{ type: "pause-server" },
{
type: "create",
client: 0,
path: "binary-14.bin",
content: "BINARY:second"
},
{ type: "sleep", ms: 100 },
{ type: "delete", client: 0, path: "binary-14.bin" },
{ type: "resume-server" },
{ type: "sync", client: 0 },
{ type: "resume-websocket", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state: AssertableState): void => {
state.assertFileCount(0);
}
}
]
};

View file

@ -0,0 +1,43 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const deleteRenameConflictTest: TestDefinition = {
description:
"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: [
{ 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: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileExists("A.md").assertFileExists("B.md");
}
},
{ type: "disable-sync", client: 1 },
{ type: "delete", client: 0, path: "A.md" },
{ type: "sync", client: 0 },
{ type: "rename", client: 1, oldPath: "A.md", newPath: "C.md" },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertContent("B.md", "content-b");
s.assertFileNotExists("A.md");
s.ifFileExists("C.md", (inner) =>
inner.assertContent("C.md", "content-a")
);
}
}
]
};

View file

@ -0,0 +1,38 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const displacedFileNotMarkedDeletedTest: TestDefinition = {
description:
"Client 0 creates a new file at path B.md while client 1 renames " +
"A.md to B.md. The remote download of B.md displaces client 1's " +
"renamed file. The displaced document must not be permanently " +
"marked as recently deleted, so it can still be synced.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "A.md", content: "content of A" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "disable-sync", client: 1 },
{ type: "create", client: 0, path: "B.md", content: "content of B" },
{ type: "rename", client: 0, oldPath: "A.md", newPath: "C.md" },
{ type: "sync", client: 0 },
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state: AssertableState): void => {
state
.assertFileCount(2)
.assertContent("B.md", "content of B")
.assertContent("C.md", "content of A");
}
}
]
};

View file

@ -0,0 +1,77 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const doubleOfflineCycleTest: TestDefinition = {
description:
"Client 0 goes through three offline-edit-reconnect cycles. " +
"Each offline edit must propagate to client 1 after reconnection.",
clients: 2,
steps: [
{
type: "create",
client: 0,
path: "doc.md",
content: "initial"
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertContent("doc.md", "initial");
}
},
{ type: "disable-sync", client: 0 },
{
type: "update",
client: 0,
path: "doc.md",
content: "first edit"
},
{ type: "enable-sync", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertContent("doc.md", "first edit");
}
},
{ type: "disable-sync", client: 0 },
{
type: "update",
client: 0,
path: "doc.md",
content: "second edit"
},
{ type: "enable-sync", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertContent("doc.md", "second edit");
}
},
{ type: "disable-sync", client: 0 },
{
type: "update",
client: 0,
path: "doc.md",
content: "third edit"
},
{ type: "enable-sync", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContent("doc.md", "third edit");
}
}
]
};

View file

@ -0,0 +1,33 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const idempotencyAfterServerPauseTest: TestDefinition = {
description:
"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: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "create",
client: 0,
path: "doc.md",
content: "important data"
},
{ type: "pause-server" },
{ type: "resume-server" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContent("doc.md", "important data");
}
}
]
};

View file

@ -0,0 +1,29 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const interruptedDeleteRetryTest: TestDefinition = {
description:
"Client 0 deletes a file, then the server is paused. " +
"After the server resumes, both clients should have zero files.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "doc.md", content: "to be deleted" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "delete", client: 0, path: "doc.md" },
{ type: "pause-server" },
{ type: "resume-server" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(0);
}
}
]
};

View file

@ -0,0 +1,39 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const keyMigrationEventDropTest: TestDefinition = {
description:
"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 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "pause-server" },
{
type: "create",
client: 0,
path: "A.md",
content: "initial content"
},
{
type: "update",
client: 0,
path: "A.md",
content: "updated content"
},
{ type: "resume-server" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContent("A.md", "updated content");
}
}
]
};

View file

@ -0,0 +1,41 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const localEditLostDuringCreateMergeTest: TestDefinition = {
description:
"Both clients create doc.md with different content while offline. " +
"Client 0 also edits the file before syncing. After both connect, " +
"the merged result should contain content from both clients.",
clients: 2,
steps: [
{ type: "create", client: 1, path: "doc.md", content: "from-client-1" },
{
type: "create",
client: 0,
path: "doc.md",
content: "from-client-0"
},
{
type: "update",
client: 0,
path: "doc.md",
content: "local-edit-during-create"
},
{ type: "enable-sync", client: 1 },
{ type: "sync", client: 1 },
{ type: "enable-sync", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContains(
"doc.md",
"from-client-1",
"local-edit-during-create"
);
}
}
]
};

View file

@ -0,0 +1,80 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const localRenameSurvivesRemoteRenameTest: TestDefinition = {
description:
"Drain processes a RemoteChange (remote rename for doc D) while a " +
"LocalUpdate (user rename of D) is also queued behind it. " +
"`processRemoteUpdate` moves the disk file and, because there is a " +
"pending LocalUpdate, takes the else branch — but its setDocument " +
"uses the stale `record.path` (= the user-rename target) instead of " +
"the actualPath the file just moved to. The queued LocalUpdate then " +
"reads from `record.path`, throws FileNotFoundError, and is " +
"silently dropped. Setup pins the queue order: a sentinel " +
"LocalUpdate keeps drain busy on a SIGSTOPped HTTP roundtrip while " +
"we resume client 0's WebSocket (enqueues RemoteChange) and then " +
"user-rename D (enqueues LocalUpdate after the RemoteChange). On " +
"server resume the drain pops the sentinel, then RemoteChange, then " +
"LocalUpdate — exactly the order that triggers the bug.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "create", client: 0, path: "doc.md", content: "v1\n" },
{ type: "create", client: 0, path: "sentinel.md", content: "s\n" },
{ type: "barrier" },
// Pause client 0's WebSocket so the upcoming remote rename buffers.
{ type: "pause-websocket", client: 0 },
// Server applies remote rename of doc.md -> remote.md. Broadcast
// is buffered on client 0's WebSocket.
{ type: "rename", client: 1, oldPath: "doc.md", newPath: "remote.md" },
{ type: "sync", client: 1 },
// Pause the server BEFORE arming the sentinel, so the sentinel's
// HTTP request will buffer at the kernel and keep drain occupied.
{ type: "pause-server" },
// Sentinel: a LocalUpdate on a *different* doc that drain pops
// first. Its HTTP roundtrip stalls on SIGSTOP, freezing drain
// until we resume the server. While drain is frozen we can grow
// the queue with additional events whose order we control.
{
type: "update",
client: 0,
path: "sentinel.md",
content: "s\nedit\n"
},
// Resume the WebSocket — buffered remote rename enqueues as a
// RemoteChange. Drain is still stuck on the sentinel HTTP.
{ type: "resume-websocket", client: 0 },
// User renames doc.md -> local.md on client 0. queue.enqueue
// mutates the doc's record.path to "local.md" and pushes a
// LocalUpdate(rename) onto the tail of the queue. Queue is now
// [sentinel-update (in-flight), RemoteChange, LocalUpdate-rename].
{ type: "rename", client: 0, oldPath: "doc.md", newPath: "local.md" },
// Resume the server. Drain pops sentinel-update (succeeds), then
// RemoteChange. Pre-fix: processRemoteUpdate moves disk
// local.md -> remote.md, takes the else branch, and
// setDocument(record.path = "local.md", …) leaves record.path
// stale. Drain pops the LocalUpdate-rename and reads from the
// stale record.path, hits FileNotFoundError, silent skip.
// Post-fix: when a local event is pending, we re-queue the
// remote update without touching disk or record, so the local
// rename drains first and both ends converge.
{ type: "resume-server" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state: AssertableState): void => {
state.assertFileCount(2);
}
}
]
};

View file

@ -0,0 +1,69 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const localUpdateSurvivesRemoteRenameTest: TestDefinition = {
description:
"Client 0 has a local content edit pending while a remote rename for " +
"the same doc arrives over the WebSocket. The remote rename's internal " +
"move relocates the disk file from the old path (where the user wrote) " +
"to the new server path. Previously, the queued LocalUpdate's " +
"`event.path` was left pointing at the now-vacated old path, so " +
"`skipIfOversized`'s `getFileSize(event.path)` threw " +
"`FileNotFoundError`, which `processEvent`'s catch silently swallowed " +
"as 'Skipping sync event 'local-update' because the file no longer " +
"exists' — and the user's edit was lost. The fix routes the size " +
"check through `tracked.path` (the doc's current disk path), " +
"matching the path `processLocalUpdate` itself reads from.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "doc.md", content: "v1\n" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
// Pause client 0's WebSocket so the upcoming remote rename buffers
// there until we've already enqueued client 0's local content
// edit. This guarantees the LocalUpdate sits in client 0's queue
// when the rename's RemoteChange drains.
{ type: "pause-websocket", client: 0 },
{
type: "rename",
client: 1,
oldPath: "doc.md",
newPath: "renamed.md"
},
{ type: "sync", client: 1 },
// Client 0 still believes the file is at `doc.md` (its WebSocket is
// paused, so the rename hasn't reached it). The user edits content
// at `doc.md`. This pushes a LocalUpdate(D, path=doc.md,
// originalPath=doc.md, isUserRename=false) into client 0's queue.
{
type: "update",
client: 0,
path: "doc.md",
content: "v1\nclient 0 edit\n"
},
// Resume the WebSocket. The buffered remote rename (server-broadcast)
// drains. `processRemoteUpdate` does an internal `move(doc.md,
// renamed.md)` and, because there's a pending LocalUpdate for D,
// takes the else branch (re-enqueue v_K, setDocument(renamed.md, …)).
// Then drain reaches the LocalUpdate. Pre-fix: skipped silently.
// Post-fix: PUTs the user's content to the doc (at its new path,
// since this is a content-only edit, not a user rename).
{ type: "resume-websocket", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state: AssertableState): void => {
state.assertFileCount(1);
state.assertFileExists("renamed.md");
state.assertContent("renamed.md", "v1\nclient 0 edit\n");
}
}
]
};

View file

@ -0,0 +1,46 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const mcCrossCreateRenameSameTargetTest: TestDefinition = {
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: [
{ type: "create", client: 0, path: "X.md", content: "content-x" },
{ type: "create", client: 1, path: "Y.md", content: "content-y" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileExists("X.md").assertFileExists("Y.md");
}
},
{ type: "disable-sync", client: 1 },
{ type: "rename", client: 0, oldPath: "X.md", newPath: "Z.md" },
{ type: "sync", client: 0 },
{ type: "rename", client: 1, oldPath: "Y.md", newPath: "Z.md" },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(2)
.assertFileNotExists("X.md")
.assertFileNotExists("Y.md")
.assertFileExists("Z.md")
.assertAnyFileContains("content-x", "content-y");
}
}
]
};

View file

@ -0,0 +1,39 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const mcDeleteThenOfflineRenameTest: TestDefinition = {
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: [
{ 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: "barrier" },
{ type: "disable-sync", client: 1 },
{ type: "delete", client: 0, path: "A.md" },
{ type: "sync", client: 0 },
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertContent("C.md", "unrelated").assertFileNotExists(
"A.md"
);
s.ifFileExists("B.md", (inner) =>
inner.assertContent("B.md", "original")
);
}
}
]
};

View file

@ -0,0 +1,49 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const mcMultiDeleteOfflineRenameTest: TestDefinition = {
description:
"Client 0 creates 5 files. Client 1 deletes 2 while Client 0 (offline) " +
"renames one of the deleted files. Both must converge.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "file-1.md", content: "content-1" },
{ type: "create", client: 0, path: "file-2.md", content: "content-2" },
{ type: "create", client: 0, path: "file-3.md", content: "content-3" },
{ type: "create", client: 0, path: "file-4.md", content: "content-4" },
{ type: "create", client: 0, path: "file-5.md", content: "content-5" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "disable-sync", client: 0 },
{ type: "delete", client: 1, path: "file-2.md" },
{ type: "delete", client: 1, path: "file-4.md" },
{ type: "sync", client: 1 },
{
type: "rename",
client: 0,
oldPath: "file-2.md",
newPath: "renamed.md"
},
{ type: "enable-sync", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
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", (inner) =>
inner.assertContent("renamed.md", "content-2")
);
}
}
]
};

View file

@ -0,0 +1,41 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const mcThreeClientRenameOfflineUpdateTest: TestDefinition = {
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: [
{ type: "create", client: 0, path: "A.md", content: "original" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "enable-sync", client: 2 },
{ type: "barrier" },
{ type: "disable-sync", client: 2 },
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
{ type: "sync", client: 1 },
{ type: "sync", client: 0 },
{
type: "update",
client: 2,
path: "A.md",
content: "updated-by-client-2"
},
{ type: "enable-sync", client: 2 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1)
.assertFileNotExists("A.md")
.assertContains("B.md", "updated-by-client-2");
}
}
]
};

View file

@ -0,0 +1,77 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const mergingUpdateResponseSurvivesUserRenameTest: TestDefinition = {
description:
"Client 1 sends a content update with a stale `parent_version_id` " +
"(its WebSocket is paused, so it hasn't seen Client 0's intervening " +
"edit). The server merges and replies with `MergingUpdate` carrying " +
"the merged text. Before the response lands, the user renames the " +
"doc on Client 1, vacating the disk path the in-flight " +
"`processLocalUpdate` captured. Pre-fix: " +
"`handleMaybeMergingResponse`'s `operations.write(diskPath, …)` " +
"hits the `we wont recreate it` early-return inside `write`, " +
"silently dropping the server-merged content — Client 0's edit is " +
"lost on Client 1's disk, and Client 1's next local-update PUT " +
"(rebased on the now-untracked merged version) deletes Client 0's " +
"edit on the server too. Post-fix: the response is written to the " +
"doc's current tracked disk path, preserving both edits.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "create", client: 0, path: "doc.md", content: "0\n" },
{ type: "barrier" },
// Stop Client 1 from seeing Client 0's next edit, so its next
// outbound PUT carries a stale `parent_version_id` and the server
// is forced to merge.
{ type: "pause-websocket", client: 1 },
// Server now holds v_b = "0\nA\n". Client 1's tracked parent
// version stays at v_a = "0\n".
{ type: "update", client: 0, path: "doc.md", content: "0\nA\n" },
{ type: "sync", client: 0 },
// Pause the server. Subsequent HTTP PUTs from Client 1 buffer at
// the OS layer until resume. This guarantees the merge response
// for Client 1's update is still in flight when the rename below
// mutates `queue.documents`.
{ type: "pause-server" },
// Client 1 edits doc.md with "B". The drain pops the LocalUpdate,
// captures `diskPath = "doc.md"`, reads the file, and sends the
// HTTP PUT — which buffers because the server is SIGSTOPped.
{ type: "update", client: 1, path: "doc.md", content: "0\nB\n" },
// User renames the file while the previous PUT is still in flight.
// `queue.enqueue`'s rename branch updates `documents` to point at
// `renamed.md` synchronously, but `processLocalUpdate`'s captured
// `diskPath` ("doc.md") is a local — it can't be retargeted.
{ type: "rename", client: 1, oldPath: "doc.md", newPath: "renamed.md" },
// Resume the server. It reconciles parent=v_a, latest=v_b,
// new="0\nB\n" → v_c with both edits, replies `MergingUpdate`.
// Pre-fix: write("doc.md", …) sees no file at that path
// (renamed.md now holds the data) and bails out without ever
// writing the merged bytes. Post-fix: the merged bytes land at
// the tracked path (renamed.md).
{ type: "resume-server" },
{ type: "resume-websocket", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state: AssertableState): void => {
state.assertFileCount(1);
state.assertFileExists("renamed.md");
state.assertFileNotExists("doc.md");
// Both edits survive: Client 0's "A" and Client 1's "B".
// The reconcile may interleave them either way; assert
// both tokens are present in the converged content.
state.assertContains("renamed.md", "A", "B");
}
}
]
};

View file

@ -0,0 +1,37 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const migrateKeyPreservesExistingTest: TestDefinition = {
description:
"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 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "pause-server" },
{ type: "create", client: 0, path: "A.md", content: "initial" },
{
type: "update",
client: 0,
path: "A.md",
content: "updated by client 0"
},
{ type: "resume-server" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContains(
"A.md",
"updated by client 0"
);
}
}
]
};

View file

@ -0,0 +1,43 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const moveAndConcurrentRemoteUpdateTest: TestDefinition = {
description:
"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: [
{
type: "create",
client: 0,
path: "A.md",
content: "original content"
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "disable-sync", client: 0 },
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
{
type: "update",
client: 1,
path: "A.md",
content: "updated by client 1"
},
{ type: "sync", client: 1 },
{ type: "enable-sync", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1)
.assertFileNotExists("A.md")
.assertContains("B.md", "updated by client 1");
}
}
]
};

View file

@ -0,0 +1,48 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const movePreservesRemoteUpdateTest: TestDefinition = {
description:
"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: [
{
type: "create",
client: 0,
path: "doc.md",
content: "line 1\nline 2"
},
{ 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: "doc.md", newPath: "renamed.md" },
{
type: "update",
client: 1,
path: "doc.md",
content: "line 1\nclient 1 edit\nline 2"
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1);
const [content] = Array.from(s.files.values());
if (!content.includes("client 1 edit")) {
throw new Error(
`Expected merged content to include "client 1 edit", got: "${content}"`
);
}
}
}
]
};

View file

@ -0,0 +1,38 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const moveRemoteUpdateRevertsRenameTest: TestDefinition = {
description:
"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: [
{ 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: "update",
client: 1,
path: "doc.md",
content: "updated by client 1"
},
{ type: "sync", client: 1 },
{ type: "enable-sync", client: 0 },
{ type: "rename", client: 0, oldPath: "doc.md", newPath: "renamed.md" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContent(
"renamed.md",
"updated by client 1"
);
}
}
]
};

View file

@ -0,0 +1,34 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const moveThenDeleteStalePathTest: TestDefinition = {
description:
"Client 0 renames A.md to B.md and immediately deletes B.md. " +
"Both clients should end up with zero files.",
clients: 2,
steps: [
{
type: "create",
client: 0,
path: "A.md",
content: "content to delete"
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
{ type: "delete", client: 0, path: "B.md" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(0)
.assertFileNotExists("A.md")
.assertFileNotExists("B.md");
}
}
]
};

View file

@ -0,0 +1,45 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const multiFileOperationsTest: TestDefinition = {
description:
"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: [
{ type: "create", client: 0, path: "A.md", content: "content-a" },
{ type: "create", client: 0, path: "B.md", content: "content-b" },
{ type: "create", client: 0, path: "C.md", content: "content-c" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "disable-sync", client: 1 },
{ type: "delete", client: 0, path: "A.md" },
{ type: "sync", client: 0 },
{
type: "update",
client: 1,
path: "B.md",
content: "updated by client 1"
},
{ type: "rename", client: 1, oldPath: "A.md", newPath: "D.md" },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertContains("B.md", "updated")
.assertFileExists("C.md")
.assertFileNotExists("A.md");
s.ifFileExists("D.md", (inner) =>
inner.assertContent("D.md", "content-a")
);
}
}
]
};

View file

@ -0,0 +1,59 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const offlineConcurrentRenamesTest: TestDefinition = {
description:
"Client 0 creates A.md and syncs to both clients. Both clients go offline. " +
"Client 0 renames A.md to B.md. Client 1 renames A.md to C.md. " +
"Both reconnect. The system must converge -- both clients should " +
"agree on the final state and the content must not be lost.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "A.md", content: "shared-content" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertContent("A.md", "shared-content");
}
},
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
{
type: "rename",
client: 0,
oldPath: "A.md",
newPath: "B.md"
},
{
type: "rename",
client: 1,
oldPath: "A.md",
newPath: "C.md"
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileNotExists("A.md")
.assertFileCount(1)
.assertAnyFileContains("shared-content");
s.ifFileExists("B.md", (inner) =>
inner.assertContent("B.md", "shared-content")
);
s.ifFileExists("C.md", (inner) =>
inner.assertContent("C.md", "shared-content")
);
}
}
]
};

View file

@ -0,0 +1,41 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const offlineCreateSamePathMergeableTest: TestDefinition = {
description:
"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: [
{
type: "create",
client: 0,
path: "notes.md",
content: "alpha wrote this line"
},
{
type: "create",
client: 1,
path: "notes.md",
content: "beta wrote this different line"
},
{ type: "enable-sync", client: 0 },
{ type: "sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1)
.assertFileExists("notes.md")
.assertContains(
"notes.md",
"alpha wrote this line",
"beta wrote this different line"
);
}
}
]
};

View file

@ -0,0 +1,38 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const offlineDeleteRemoteRenameTest: TestDefinition = {
description:
"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: [
{ type: "create", client: 0, path: "A.md", content: "content-a" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "disable-sync", client: 0 },
{ type: "delete", client: 0, path: "A.md" },
{
type: "rename",
client: 1,
oldPath: "A.md",
newPath: "A_renamed.md"
},
{ type: "sync", client: 1 },
{ type: "enable-sync", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileNotExists("A.md").assertFileNotExists(
"A_renamed.md"
);
}
}
]
};

View file

@ -0,0 +1,46 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const offlineDeleteVsRemoteUpdateTest: TestDefinition = {
description:
"Client 0 deletes A.md offline while client 1 updates it. Both clients must converge.",
clients: 2,
steps: [
{
type: "create",
client: 0,
path: "A.md",
content: "original content"
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertContent("A.md", "original content");
}
},
{ type: "disable-sync", client: 0 },
{ type: "delete", client: 0, path: "A.md" },
{
type: "update",
client: 1,
path: "A.md",
content: "important update by client 1"
},
{ type: "sync", client: 1 },
{ type: "enable-sync", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(0);
}
}
]
};

View file

@ -0,0 +1,49 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const offlineEditRemoteRenameTest: TestDefinition = {
description:
"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: [
{ type: "create", client: 0, path: "A.md", content: "original" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertContent("A.md", "original");
}
},
{ type: "disable-sync", client: 0 },
{
type: "update",
client: 0,
path: "A.md",
content: "edited by client 0"
},
{
type: "rename",
client: 1,
oldPath: "A.md",
newPath: "B.md"
},
{ type: "sync", client: 1 },
{ type: "enable-sync", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileNotExists("A.md")
.assertFileCount(1)
.assertContains("B.md", "edited by client 0");
}
}
]
};

View file

@ -0,0 +1,51 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const offlineEditThenMoveSameContentTest: TestDefinition = {
description:
"A file is renamed and edited to match a deleted file's content. Both clients must converge despite the ambiguity.",
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: 0 },
{ type: "delete", client: 0, path: "A.md" },
{ type: "rename", client: 0, oldPath: "B.md", newPath: "C.md" },
{
type: "update",
client: 0,
path: "C.md",
content: "content A"
},
{ type: "enable-sync", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileNotExists("A.md")
.assertFileNotExists("B.md")
.assertContent("C.md", "content A")
.assertFileCount(1);
}
}
]
};

View file

@ -0,0 +1,57 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const offlineMixedOperationsTest: TestDefinition = {
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: [
{ type: "create", client: 0, path: "file1.md", content: "content-1" },
{ type: "create", client: 0, path: "file2.md", content: "content-2" },
{ type: "create", client: 0, path: "file3.md", content: "content-3" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertContent("file1.md", "content-1")
.assertContent("file2.md", "content-2")
.assertContent("file3.md", "content-3");
}
},
{ type: "disable-sync", client: 0 },
{ type: "delete", client: 0, path: "file1.md" },
{
type: "rename",
client: 0,
oldPath: "file2.md",
newPath: "moved.md"
},
{
type: "update",
client: 0,
path: "file3.md",
content: "updated-content-3"
},
{ type: "enable-sync", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileNotExists("file1.md")
.assertFileNotExists("file2.md")
.assertContent("moved.md", "content-2")
.assertContent("file3.md", "updated-content-3")
.assertFileCount(2);
}
}
]
};

View file

@ -0,0 +1,36 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const offlineMoveThenRemoteDeleteTest: TestDefinition = {
description:
"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: [
{
type: "create",
client: 0,
path: "A.md",
content: "content to delete"
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "disable-sync", client: 0 },
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
{ type: "delete", client: 1, path: "A.md" },
{ type: "sync", client: 1 },
{ type: "enable-sync", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(0);
}
}
]
};

View file

@ -0,0 +1,40 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const offlineMultipleEditsTest: TestDefinition = {
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: [
{ type: "create", client: 0, path: "doc.md", content: "original" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertContent("doc.md", "original");
}
},
{ type: "disable-sync", client: 0 },
{ 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" },
{ type: "enable-sync", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContent("doc.md", "edit-5-final");
}
}
]
};

View file

@ -0,0 +1,43 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const offlineRenameAndEditTest: TestDefinition = {
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: [
{ type: "create", client: 0, path: "A.md", content: "original" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertContent("A.md", "original");
}
},
{ 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"
},
{ type: "enable-sync", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileNotExists("A.md")
.assertFileCount(1)
.assertContent("B.md", "edited after rename");
}
}
]
};

View file

@ -0,0 +1,51 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const offlineRenameRemoteCreateOldPathTest: TestDefinition = {
description:
"Client 0 renames X.md to Y.md while offline. Client 1 updates X.md " +
"(same document). When Client 0 reconnects, the rename and update " +
"should merge. Y.md should exist with Client 1's content.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "X.md", content: "original" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertContent("X.md", "original");
}
},
{ type: "disable-sync", client: 0 },
{
type: "rename",
client: 0,
oldPath: "X.md",
newPath: "Y.md"
},
{
type: "update",
client: 1,
path: "X.md",
content: "updated-by-client-1"
},
{ type: "sync", client: 1 },
{ type: "enable-sync", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContains(
"Y.md",
"updated-by-client-1"
);
}
}
]
};

View file

@ -0,0 +1,75 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const offlineUpdateBothThenDeleteOneTest: TestDefinition = {
description:
"Client 0 goes offline, updates A.md and B.md, then deletes B.md. " +
"Client 1 updates B.md while Client 0 is offline. When Client 0 " +
"reconnects, A.md should have the update and B.md should be " +
"consistently resolved (delete wins).",
clients: 2,
steps: [
{
type: "create",
client: 0,
path: "A.md",
content: "A original"
},
{
type: "create",
client: 0,
path: "B.md",
content: "B original"
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertContent("A.md", "A original").assertContent(
"B.md",
"B original"
);
}
},
{ type: "disable-sync", client: 0 },
{
type: "update",
client: 0,
path: "A.md",
content: "A updated by client 0"
},
{
type: "update",
client: 0,
path: "B.md",
content: "B updated by client 0"
},
{ type: "delete", client: 0, path: "B.md" },
{
type: "update",
client: 1,
path: "B.md",
content: "B updated by client 1"
},
{ type: "sync", client: 1 },
{ type: "enable-sync", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertContent(
"A.md",
"A updated by client 0"
).assertFileNotExists("B.md");
}
}
]
};

View file

@ -0,0 +1,34 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const onlineBothCreateSamePathDeconflictTest: TestDefinition = {
description:
"Both clients create a file at the same path while online. " +
"One client's create gets deconflicted by the server. " +
"Both files must exist on both clients after convergence.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "pause-websocket", client: 1 },
{ type: "create", client: 0, path: "A.md", content: " from-client-0 " },
{ type: "update", client: 0, path: "A.md", content: " updated-by-0 " },
{ type: "sync" },
{ type: "create", client: 1, path: "A.md", content: " from-client-1 " },
{ type: "resume-websocket", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state: AssertableState): void => {
state
.assertFileCount(1)
.assertContains("A.md", "updated-by-0", "from-client-1 ");
}
}
]
};

View file

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

View file

@ -0,0 +1,48 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const onlineCreateUpdateWhileOtherCreatesSamePathTest: TestDefinition = {
description:
"Client 0 creates a binary file and updates it while client 1 also " +
"creates a binary file at the same path. Both clients are online. " +
"Both clients must end up with the same file set.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "pause-websocket", client: 1 },
{
type: "create",
client: 0,
path: "data.bin",
content: "BINARY:content-v1"
},
{
type: "update",
client: 0,
path: "data.bin",
content: "BINARY:content-v2"
},
{
type: "create",
client: 1,
path: "data.bin",
content: "BINARY:other-content"
},
{ type: "resume-websocket", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state: AssertableState): void => {
state
.assertFileCount(2)
.assertNoFileContains("content-v1")
.assertAnyFileContains("content-v2")
.assertAnyFileContains("other-content");
}
}
]
};

View file

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

View file

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

View file

@ -0,0 +1,54 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const overlappingEditsSameSectionTest: TestDefinition = {
description:
"Both clients go offline and edit different parts of the same document. " +
"After both reconnect, both edits must be preserved without data loss.",
clients: 2,
steps: [
{
type: "create",
client: 0,
path: "doc.md",
content: "# Title\n\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: "# Title\nalpha addition\n\nfooter"
},
{
type: "update",
client: 1,
path: "doc.md",
content: "# Title\n\nbeta addition\nfooter"
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContains(
"doc.md",
"# Title",
"alpha addition",
"beta addition",
"footer"
);
}
}
]
};

View file

@ -0,0 +1,36 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const queueResetLosesCoalescedLocalEditTest: TestDefinition = {
description:
"Client 0 goes offline, both clients edit doc.md concurrently, " +
"then client 0 reconnects. Both edits must be preserved.",
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: "update", client: 1, path: "doc.md", content: "alpha bravo" },
{ type: "sync", client: 1 },
{ type: "update", client: 0, path: "doc.md", content: "charlie delta" },
{ type: "enable-sync", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContains(
"doc.md",
"alpha",
"charlie"
);
}
}
]
};

View file

@ -0,0 +1,56 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const queuedCreateDeleteDoesNotHijackReusedPathTest: TestDefinition = {
description:
"A create/delete pair that is still queued behind another request " +
"must collapse locally. It must not later read a different file " +
"that reused the same path before the queued create drained.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "pause-server" },
{
type: "create",
client: 1,
path: "blocker.bin",
content: "BINARY:blocker"
},
{ type: "sleep", ms: 100 },
{
type: "create",
client: 1,
path: "target.bin",
content: "BINARY:old"
},
{ type: "delete", client: 1, path: "target.bin" },
{
type: "create",
client: 1,
path: "source.bin",
content: "BINARY:new"
},
{
type: "rename",
client: 1,
oldPath: "source.bin",
newPath: "target.bin"
},
{ type: "resume-server" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state: AssertableState): void => {
state
.assertFileCount(2)
.assertContent("blocker.bin", "BINARY:blocker")
.assertContent("target.bin", "BINARY:new")
.assertFileNotExists("source.bin");
}
}
]
};

View file

@ -0,0 +1,52 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const rapidCreateUpdateDeleteCycleTest: TestDefinition = {
description:
"Client 0 rapidly creates, updates, deletes, then re-creates a file while the server is paused. " +
"After the server resumes, client 1 must see only the final file.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "pause-server" },
{
type: "create",
client: 0,
path: "cycle.md",
content: "version 1"
},
{
type: "update",
client: 0,
path: "cycle.md",
content: "version 2"
},
{ type: "delete", client: 0, path: "cycle.md" },
{ type: "resume-server" },
{ type: "sync" },
{
type: "create",
client: 0,
path: "cycle.md",
content: "final creation"
},
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContent(
"cycle.md",
"final creation"
);
}
}
]
};

View file

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

View file

@ -0,0 +1,49 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const rapidUpdatesAfterMergeTest: TestDefinition = {
description:
"Both clients create the same file offline, triggering a merge on sync. " +
"Client 0 then rapidly sends three updates. Both clients must converge to the final update.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "doc.md", content: "from client 0" },
{ type: "create", client: 1, path: "doc.md", content: "from client 1" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "update",
client: 0,
path: "doc.md",
content: "update 1"
},
{ type: "sync", client: 0 },
{
type: "update",
client: 0,
path: "doc.md",
content: "update 2"
},
{ type: "sync", client: 0 },
{
type: "update",
client: 0,
path: "doc.md",
content: "update 3"
},
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContains("doc.md", "update 3");
}
}
]
};

View file

@ -0,0 +1,45 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const recentlyDeletedClearedOnReconnectTest: TestDefinition = {
description:
"After a client deletes a document and reconnects, it should " +
"accept new documents from other clients even if they happen to " +
"arrive at the same path as the deleted document.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "create", client: 0, path: "doc.md", content: "original" },
{ type: "sync" },
{ type: "delete", client: 0, path: "doc.md" },
{ type: "barrier" },
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
{
type: "create",
client: 1,
path: "doc.md",
content: "new content from client 1"
},
{ type: "enable-sync", client: 1 },
{ type: "sync", client: 1 },
{ type: "enable-sync", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContent(
"doc.md",
"new content from client 1"
);
}
}
]
};

View file

@ -0,0 +1,36 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const remoteQuickWriteRenameBeforeRecordTest: TestDefinition = {
description:
"Client 0 receives a remote create and the user renames the new " +
"file immediately after the syncer writes it. The watcher event " +
"must bind to the new document instead of being dropped before " +
"the remote-create handler persists the record.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{
type: "rename-next-write",
client: 0,
oldPath: "doc.md",
newPath: "renamed.md"
},
{ type: "create", client: 1, path: "doc.md", content: "v1\n" },
{ type: "sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1);
s.assertFileExists("renamed.md");
s.assertFileNotExists("doc.md");
s.assertContent("renamed.md", "v1\n");
}
}
]
};

View file

@ -0,0 +1,76 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const remoteRenameCollidesWithPendingLocalCreateTest: TestDefinition = {
// TODO(refactor): the failure mode described below is the
// pre-refactor "deflect-to-conflict-uuid" path that no longer
// exists. Under the new model the wire loop never moves files for
// path placement, so the remote rename can't deflect anywhere; the
// reconciler waits for the slot to free. Convergence assertion is
// still valid (no conflict-uuid stashes, both files present, the
// local create lands at a server-deconflicted sibling).
description:
"Client 0 has doc D tracked at `original.md`. Client 1 owns doc E " +
"and renames it to `target.md` server-side. Before client 0's " +
"drain processes the WS broadcast for E, the user creates a new " +
"local file `target.md` (a different doc, untracked). When the " +
"buffered RemoteChange for E drains, the engine has to reconcile " +
"doc E onto `target.md` even though the slot is held by client " +
"0's pending LocalCreate. Convergence requires both clients end " +
"up with [target.md = E] and the local create lands at a " +
"server-deconflicted sibling (e.g. `target (1).md`).",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "create", client: 1, path: "original.md", content: "v1\n" },
{ type: "barrier" },
// Pause client 0's WS so the upcoming remote rename buffers and
// we can stage a colliding local create before the rename
// drains on client 0.
{ type: "pause-websocket", client: 0 },
// Client 1 renames the doc. Server commits, broadcasts to
// client 0 (buffered).
{
type: "rename",
client: 1,
oldPath: "original.md",
newPath: "target.md"
},
{ type: "sync", client: 1 },
// Client 0 still believes the doc is at `original.md`. The user
// creates a NEW file at `target.md` (an unrelated untracked
// doc). Disk on client 0 now has both `original.md` (the
// tracked doc) and `target.md` (the new untracked file).
{ type: "create", client: 0, path: "target.md", content: "extra\n" },
// Resume client 0's WS. The buffered RemoteChange drains.
// The reconciler must converge without ever leaving a
// conflict-uuid stash on disk.
{ type: "resume-websocket", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state: AssertableState): void => {
state.assertFileCount(2);
for (const path of state.files.keys()) {
if (path.startsWith("conflict-")) {
throw new Error(
`Unexpected conflict-uuid stash on a converged client: ${path}`
);
}
}
state.assertFileExists("target.md");
state.assertContent("target.md", "v1\n");
// The local create gets server-deconflicted to a
// sibling path (e.g. `target (1).md`).
}
}
]
};

View file

@ -0,0 +1,59 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const remoteUpdateResurrectsDeletedDocTest: TestDefinition = {
description:
"Client 1 updates, deletes, and recreates P (with a new docId D2). " +
"While the buffered remote events are being processed by client 0, " +
"client 0 also makes a local edit to P. The local edit lands in the " +
"queue while v17 is mid-process, sending v17 down processRemoteUpdate's " +
"re-enqueue branch. The deferred v17 must NOT later resurrect D1 as a " +
"conflict-… file at P after the delete and the D2 create have drained.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "create", client: 1, path: "P.md", content: "v8 content\n" },
{ type: "barrier" },
{ type: "pause-websocket", client: 0 },
{
type: "update",
client: 1,
path: "P.md",
content: "v17 content from client 1\n"
},
{ type: "sync", client: 1 },
{ type: "delete", client: 1, path: "P.md" },
{ type: "sync", client: 1 },
{
type: "create",
client: 1,
path: "P.md",
content: "v21 content (D2)\n"
},
{ type: "sync", client: 1 },
{ type: "resume-websocket", client: 0 },
{
type: "update",
client: 0,
path: "P.md",
content: "local edit by client 0\n"
},
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state: AssertableState): void => {
state
.assertFileCount(1)
.assertContent("P.md", "v21 content (D2)\n");
}
}
]
};

View file

@ -0,0 +1,84 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const remoteUpdateSurvivesUserRenameTest: TestDefinition = {
description:
"Client 0 updates a tracked doc; while Client 1 is processing the " +
"broadcast and parked on the GET for the new version's content, the " +
"user renames the doc on Client 1. Pre-fix: `processRemoteUpdate` " +
"captures `actualPath` before the await and, after the GET returns, " +
"calls `write(actualPath, …)` (no-op — file was renamed away), " +
"`updateCache(actualPath, …)`, and `setDocument(actualPath, …)`. " +
"`setDocument` mutates the same record in place so its `path` is " +
"yanked from the user's renamed slot back to the pre-rename path, " +
"wiping the rename out of the queue's documents map. The queued " +
"`LocalUpdate` then reads from the now-stale `record.path`, hits " +
"`FileNotFoundError`, and is silently dropped — the user's rename " +
"never reaches the server. Post-fix: the handler defers when a " +
"local event landed mid-await, so the rename drains first and " +
"the deferred remote update is folded into the broadcast that " +
"follows the rename round-trip.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "doc.md", content: "v1\n" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
// Buffer Client 1's incoming broadcasts so it doesn't see
// Client 0's update until we've paused the server.
{ type: "pause-websocket", client: 1 },
// Server now holds v=2 of doc.md.
{ type: "update", client: 0, path: "doc.md", content: "v2\n" },
{ type: "sync", client: 0 },
// Pause the server. Client 1's upcoming GET for the new version
// content blocks at the OS layer until resume.
{ type: "pause-server" },
// Release the buffered broadcast. Client 1's drain enters
// `processRemoteUpdate`, captures `actualPath`, fires the GET,
// and parks awaiting the response.
{ type: "resume-websocket", client: 1 },
// Yield long enough for the drain to traverse all microtask
// hops between the WS handler and the GET, so the HTTP request
// is queued at the (paused) server before the rename runs.
// Without this yield the rename would be enqueued before
// `processRemoteUpdate`'s entry-time `hasPendingLocalEvents`
// check and the early-defer branch would mask the bug.
{ type: "sleep", ms: 50 },
// While the GET is in flight the user renames the doc. The queue
// mutates `record.path` to "renamed.md" in place and pushes a
// LocalUpdate carrying the rename target.
{
type: "rename",
client: 1,
oldPath: "doc.md",
newPath: "renamed.md"
},
// Resume the server. The GET response unblocks
// `processRemoteUpdate`. With the fix in place it sees the
// queued LocalUpdate and defers; without the fix it walks past
// the rename and clobbers the documents map, dropping the
// pending LocalUpdate's read on the way back through.
{ type: "resume-server" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1);
s.assertFileExists("renamed.md");
s.assertFileNotExists("doc.md");
// Both edits survive: the user's rename and Client 0's
// content update at v=2.
s.assertContent("renamed.md", "v2\n");
}
}
]
};

View file

@ -0,0 +1,64 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const renameChainDuringPendingCreateTest: TestDefinition = {
description:
"User creates a doc, then renames it twice while the LocalCreate's " +
"HTTP roundtrip is still in flight (server paused). Each rename " +
"pushes a LocalUpdate whose `documentId` is the create's Promise " +
"(see `pendingDocumentId` in `SyncEventQueue.enqueue`). After the " +
"create resolves, the first rename drains successfully and " +
"`setDocument` walks `events[]` to retarget queued LocalUpdates' " +
"`event.path` to the new disk location — but the comparison " +
"`e.documentId === record.documentId` mismatches the still-Promise " +
"references, so the second rename's `event.path` stays at the " +
"vacated previous slot. On the next drain step `skipIfOversized`'s " +
"`getFileSize(event.path)` throws FileNotFoundError, which " +
"`processEvent` swallows as 'Skipping sync event ... because the " +
"file no longer exists' — losing the user's final rename. " +
"Post-fix: `resolveCreate` (and the displacement-merge branch in " +
"`processCreate`) swap the Promise references for the resolved id " +
"before `setDocument` runs, so retarget works.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
// Pause the server so client 0's create stalls on the HTTP PUT
// while we queue rename events behind it.
{ type: "pause-server" },
{ type: "create", client: 0, path: "first.md", content: "v1\n" },
{
type: "rename",
client: 0,
oldPath: "first.md",
newPath: "second.md"
},
{
type: "rename",
client: 0,
oldPath: "second.md",
newPath: "third.md"
},
// Resume — drain pops LocalCreate (now resolves), then the two
// queued LocalUpdates. Pre-fix: only the first rename's
// file-system effect lands; the second is silently dropped.
// The server ends up with the doc at second.md, leaving
// client 0's local third.md untracked / out-of-sync.
{ type: "resume-server" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state: AssertableState): void => {
state.assertFileCount(1);
state.assertFileExists("third.md");
state.assertContent("third.md", "v1\n");
}
}
]
};

View file

@ -0,0 +1,50 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const renameChainThenDeleteTest: TestDefinition = {
description:
"Client 0 renames X.md to Y.md to Z.md, then deletes Z.md while client 1 is offline. " +
"After client 1 reconnects, both clients must have no files.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "X.md", content: "chain-content" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertContent("X.md", "chain-content");
}
},
{ type: "disable-sync", client: 1 },
{
type: "rename",
client: 0,
oldPath: "X.md",
newPath: "Y.md"
},
{ type: "sync", client: 0 },
{
type: "rename",
client: 0,
oldPath: "Y.md",
newPath: "Z.md"
},
{ type: "sync", client: 0 },
{ type: "delete", client: 0, path: "Z.md" },
{ type: "sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(0);
}
}
]
};

View file

@ -0,0 +1,34 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const renameChainTest: TestDefinition = {
description:
"Client 0 (offline) creates A.md, renames to B.md, then renames to C.md. " +
"When sync is enabled, only C.md should exist. Client 1 should receive C.md " +
"with the original content. Intermediate paths should never appear.",
clients: 2,
steps: [
{ type: "enable-sync", client: 1 },
{
type: "create",
client: 0,
path: "A.md",
content: "important content"
},
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
{ type: "rename", client: 0, oldPath: "B.md", newPath: "C.md" },
{ type: "enable-sync", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileNotExists("A.md")
.assertFileNotExists("B.md")
.assertContent("C.md", "important content");
}
}
]
};

View file

@ -0,0 +1,44 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const renameCircularTest: TestDefinition = {
description:
"Client 0 creates three files, syncs, then goes offline and performs a circular rename via a temp file (A->temp, C->A, B->C, temp->B). After reconnecting, all three contents should exist across three files but paths may be deconflicted.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "A.md", content: "content-a" },
{ type: "create", client: 0, path: "B.md", content: "content-b" },
{ type: "create", client: 0, path: "C.md", content: "content-c" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertContent("A.md", "content-a")
.assertContent("B.md", "content-b")
.assertContent("C.md", "content-c");
}
},
{ type: "disable-sync", client: 0 },
{ type: "rename", client: 0, oldPath: "A.md", newPath: "temp-a.md" },
{ type: "rename", client: 0, oldPath: "C.md", newPath: "A.md" },
{ type: "rename", client: 0, oldPath: "B.md", newPath: "C.md" },
{ type: "rename", client: 0, oldPath: "temp-a.md", newPath: "B.md" },
{ type: "enable-sync", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileNotExists("temp-a.md")
.assertFileCount(3)
.assertAnyFileContains("content-c")
.assertAnyFileContains("content-a")
.assertAnyFileContains("content-b");
}
}
]
};

View file

@ -0,0 +1,34 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const renameCreateConflictTest: TestDefinition = {
description:
"Client 0 creates A.md and syncs. Client 1 renames A.md to B.md and syncs. Client 0 (offline) creates B.md with the same content. After reconnecting, both clients should converge with only B.md.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "create", client: 0, path: "A.md", content: "hi" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertContent("A.md", "hi");
}
},
{ type: "disable-sync", client: 0 },
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
{ type: "sync", client: 1 },
{ type: "create", client: 0, path: "B.md", content: "hi" },
{ type: "enable-sync", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(2)
.assertContent("B.md", "hi")
.assertContent("B (1).md", "hi");
}
}
]
};

View file

@ -0,0 +1,51 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const renameOverwritesPendingCreateThenDeleteTest: TestDefinition = {
description:
"A pending local create at a path must not mask a synced document renamed onto that path; later rename/delete events still belong to the synced document.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{
type: "create",
client: 0,
path: "tracked.bin",
content: "BINARY:tracked"
},
{ type: "barrier" },
{ type: "pause-server" },
{
type: "create",
client: 0,
path: "pending.bin",
content: "BINARY:pending"
},
{
type: "rename",
client: 0,
oldPath: "tracked.bin",
newPath: "pending.bin"
},
{
type: "rename",
client: 0,
oldPath: "pending.bin",
newPath: "final.bin"
},
{ type: "delete", client: 0, path: "final.bin" },
{ type: "resume-server" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state: AssertableState): void => {
state.assertFileCount(0);
}
}
]
};

View file

@ -0,0 +1,42 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const renamePendingCreateBeforeResponseTest: TestDefinition = {
description:
"Client 0 creates a file while the server is paused, then renames it before the create completes. After the server resumes, both clients should converge with the file at the renamed path.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "pause-server" },
{
type: "create",
client: 0,
path: "doc.md",
content: "original-content"
},
{
type: "rename",
client: 0,
oldPath: "doc.md",
newPath: "renamed.md"
},
{ type: "resume-server" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContent(
"renamed.md",
"original-content"
);
}
}
]
};

View file

@ -0,0 +1,59 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const renamePendingCreateOntoPendingDeletePathTest: TestDefinition = {
description:
"A pending create is renamed onto a path whose old server document " +
"has a queued delete. The delete must reach the server before the " +
"new create so the new generation is not merged into the soon-to-be " +
"deleted document.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "create",
client: 1,
path: "file-17.md",
content: "old\n"
},
{ type: "barrier" },
{ type: "pause-server" },
{
type: "create",
client: 1,
path: "blocker.md",
content: "blocker\n"
},
{ type: "sleep", ms: 100 },
{
type: "create",
client: 1,
path: "file-23.md",
content: "new\n"
},
{ type: "delete", client: 1, path: "file-17.md" },
{
type: "rename",
client: 1,
oldPath: "file-23.md",
newPath: "file-17.md"
},
{ type: "resume-server" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state: AssertableState): void => {
state
.assertFileCount(2)
.assertContent("blocker.md", "blocker\n")
.assertContent("file-17.md", "new\n")
.assertFileNotExists("file-23.md");
}
}
]
};

View file

@ -0,0 +1,40 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const renameRoundtripTest: TestDefinition = {
description:
"Client 0 creates A.md, renames it to B.md, then renames it back to A.md. After each step both clients sync. Both should end with only A.md at the original path.",
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: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertContent("A.md", "original");
}
},
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileNotExists("A.md").assertContent("B.md", "original");
}
},
{ type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileNotExists("B.md").assertContent("A.md", "original");
}
}
]
};

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