Add deterministic-tests #190

Merged
andras merged 3 commits from asch/deterministic-tests into main 2026-05-09 10:15:22 +01:00
28 changed files with 115 additions and 56 deletions
Showing only changes of commit 20e1c3f22d - Show all commits

Improve tests
Some checks failed
Check / build (pull_request) Has been cancelled
E2E tests / build (pull_request) Has been cancelled
Publish CLI / publish-docker (pull_request) Has been cancelled
Publish server Docker image / publish-docker (pull_request) Has been cancelled

Andras Schmelczer 2026-05-09 09:06:12 +01:00

View file

@ -10,7 +10,7 @@ Each test is a `TestDefinition`: a client count and an ordered list of steps. Th
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.
The runner executes two sequential phases: regular tests on the shared server, then pause-server tests on dedicated servers. Within each phase tests run in parallel up to a concurrency limit.
## Step types
@ -19,12 +19,15 @@ Clients always start with syncing disabled.
**File operations** (per-client, fire-and-forget — sync is enqueued but not awaited):
- `create`, `update`, `rename`, `delete`
- `rename-next-write` — arm a deferred rename that fires the next time the given path is written. Lets a test race a user-rename against an in-flight remote create that's about to land at the same path.
**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
- `reset` — reset a client's tracked sync state (keeps disk files); equivalent to a forced re-handshake on next enable
- `sleep` — wall-clock pause; use sparingly, prefer `barrier` / `sync`
**WebSocket control** (per-client):
@ -33,6 +36,12 @@ Clients always start with syncing disabled.
**Server control:**
- `pause-server` / `resume-server` — SIGSTOP/SIGCONT the server process
- `resume-server-until-history-then-pause` — resume the server, wait until a specific client observes a matching history entry (`CREATE`/`UPDATE`/`DELETE` for a path), then re-pause. Used to land exactly one operation across the wire.
**Fault injection** (per-client):
- `drop-next-create-response` — arm a one-shot interceptor that lets the next `POST /documents` reach the server (commit happens) but throws `SyncResetError` before the client sees the response, simulating connection loss after server commit.
- `wait-for-dropped-create-response` — wait until the armed drop has fired.
**Assertions:**
@ -72,7 +81,9 @@ export const myScenarioTest: TestDefinition = {
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) => s.assertFileCount(1).assertContent("A.md", "hello")
verify: (s) => {
s.assertFileCount(1).assertContent("A.md", "hello");
}
}
]
};
@ -81,14 +92,18 @@ export const myScenarioTest: TestDefinition = {
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
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 in file
s.assertContainsAny("path", "a", "b"); // at least one substring present
s.assertAnyFileContains("text"); // substring present in some file
s.assertNoFileContains("text"); // substring absent from every file
s.assertSubstringCount("path", "x", 3); // substring appears exactly N times
s.assertContentInAtMostOneFile("text"); // no duplicate content
s.ifFileExists("path", (s) => { /* … */ }); // conditional block
s.getContent("path"); // raw content (or "" if missing)
```
2. Register it in `src/test-registry.ts`:

View file

@ -33,6 +33,27 @@ function testUsesPauseServer(test: TestDefinition): boolean {
);
}
/**
* Walk up from the CLI binary's location until we find a directory
* containing `sync-server/` and `frontend/`.
*/
function findProjectRoot(): string {
let dir = path.dirname(__filename);
const root = path.parse(dir).root;
while (dir !== root) {
if (
fs.existsSync(path.join(dir, "sync-server")) &&
fs.existsSync(path.join(dir, "frontend"))
) {
return dir;
}
dir = path.dirname(dir);
}
throw new Error(
`Could not locate project root (no ancestor of ${__filename} contains both 'sync-server' and 'frontend')`
);
}
interface NamedTestResult {
name: string;
result: TestResult;
@ -100,15 +121,7 @@ async function runDedicatedServerTest(
}
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 projectRoot = findProjectRoot();
const serverPath = path.join(projectRoot, SERVER_BINARY_PATH);
if (!fs.existsSync(serverPath)) {
logger.error(`Server binary not found at: ${serverPath}`);

View file

@ -11,3 +11,6 @@ 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;
export const SERVER_READY_POLL_INTERVAL_MS = 100;
export const SERVER_READY_MAX_ATTEMPTS = 50;

View file

@ -312,6 +312,10 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
});
});
}
// The rename consumed `path`. Skip the post-update enqueue below
// — it would send a syncLocallyUpdatedFile for a path that no
// longer exists.
return;
}
if (!this.isSyncEnabled) {
@ -435,6 +439,13 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
DeterministicAgent.isCreateDocumentRequest(input, init)
) {
this.nextCreateResponseDrop = undefined;
// Release the underlying socket: an unread body keeps the
// undici connection open until GC.
try {
await response.body?.cancel();
} catch {
// Best-effort — body may already be consumed/closed.
}
drop.resolveDropped();
throw new SyncResetError();
}

View file

@ -157,6 +157,17 @@ class ManagedWebSocket implements WebSocket {
public addEventListener(
...args: Parameters<WebSocket["addEventListener"]>
): void {
// Only the `.onmessage` setter routes through the pause buffer.
// If sync-client ever attaches "message" listeners via
// addEventListener instead, those messages would bypass pause/resume
// and deterministic tests would silently lose their fault injection.
if (args[0] === "message") {
throw new Error(
"ManagedWebSocket: addEventListener('message') bypasses the " +
"pause buffer. Use the .onmessage setter instead, or " +
"extend ManagedWebSocket to route message listeners."
);
}
this.ws.addEventListener(...args);
}
@ -176,6 +187,11 @@ class ManagedWebSocket implements WebSocket {
* for pause/resume control from the test harness
*/
export class ManagedWebSocketFactory {
// Append-only: closed sockets stay tracked. Bounded per test (one
// factory per agent, each test discards its agents on cleanup), so
// not a real leak — but iterating over closed instances on
// pause/resume is a deliberate no-op since their `.onmessage` is
// already detached.
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

View file

@ -5,7 +5,11 @@ 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";
import {
STOP_TIMEOUT_MS,
SERVER_READY_POLL_INTERVAL_MS,
SERVER_READY_MAX_ATTEMPTS
} from "./consts";
export class ServerControl {
private process: ChildProcess | null = null;
@ -101,7 +105,9 @@ export class ServerControl {
}
}
public async waitForReady(maxAttempts = 50): Promise<void> {
public async waitForReady(
maxAttempts: number = SERVER_READY_MAX_ATTEMPTS
): Promise<void> {
const pingUrl = `${this.remoteUri}/vaults/test/ping`;
for (let i = 0; i < maxAttempts; i++) {
if (this.process?.exitCode !== null) {
@ -118,7 +124,7 @@ export class ServerControl {
} catch {
// Server not ready yet, continue polling
}
await sleep(100);
await sleep(SERVER_READY_POLL_INTERVAL_MS);
}
throw new Error("Server failed to start within timeout");
}
@ -212,6 +218,9 @@ export class ServerControl {
}
private writeConfigFile(destPath: string, dbDir: string): void {
// Assumes config-e2e.yml has exactly one 2-space-indented `port:` and
// one `databases_directory_path:` (under `server:` and `database:`
// respectively)
const baseConfig = fs.readFileSync(this.baseConfigPath, "utf-8");
const config = baseConfig
.replace(/^\s*port:\s*\d+/m, ` port: ${this._port}`)

View file

@ -33,7 +33,7 @@ import { offlineDeleteVsRemoteUpdateTest } from "./tests/offline-delete-vs-remot
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 { offlineCreateSamePathMergeableTest } from "./tests/offline-create-same-path-mergeable.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";
@ -62,25 +62,25 @@ import { createRenameResponseSkipsFileTest } from "./tests/create-rename-respons
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 { textPendingCreateNotDisplacedTest } from "./tests/text-pending-create-not-displaced.test";
import { binaryPendingCreateNotDisplacedTest } from "./tests/binary-pending-create-not-displaced.test";
import { coalesceUpdateRemoteUpdateDataLossTest } from "./tests/coalesce-update-remote-update-data-loss.test";
import { coalescedRemoteUpdateWatermarkLossTest } from "./tests/coalesced-remote-update-watermark-loss.test";
import { concurrentDeleteDuringRemoteUpdateTest } from "./tests/concurrent-delete-during-remote-update.test";
import { concurrentEditExactSamePositionTest } from "./tests/concurrent-edit-exact-same-position.test";
import { concurrentRenameAndCreateAtTargetRenameFirstTest } from "./tests/concurrent-rename-and-create-at-target-rename-first.test";
import { concurrentRenameAndCreateAtTargetCreateFirstTest } from "./tests/concurrent-rename-and-create-at-target-create-first.test";
import { concurrentRenameSameTargetTest } from "./tests/concurrent-rename-same-target.test";
import { concurrentUpdateDiffConsistencyTest } from "./tests/concurrent-update-diff-consistency.test";
import { userParenthesizedFileNotDeletedTest } from "./tests/user-parenthesized-file-not-deleted.test";
import { createDeleteNoopTest } from "./tests/create-delete-noop.test";
import { createMergeDeleteTest } from "./tests/create-merge-delete.test";
import { moveIdenticalContentAmbiguityTest } from "./tests/move-identical-content-ambiguity.test";
import { createUpdateCoalesceServerPauseTest } from "./tests/create-update-coalesce-server-pause.test";
import { createDuringReconciliationTest } from "./tests/create-during-reconciliation.test";
import { createMergePreservesRenamedUpdateTest } from "./tests/create-merge-preserves-renamed-update.test";
import { createRenameCreateSamePathTest } from "./tests/create-rename-create-same-path.test";
import { moveChainThreeFilesTest } from "./tests/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";

View file

@ -266,18 +266,10 @@ export class TestRunner {
}
}
// 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 }
);
}
throw new Error(
`Convergence timed out after ${CONVERGENCE_TIMEOUT_MS}ms: ${lastError?.message ?? "no consistency check ran"}`,
{ cause: lastError }
);
}
/**

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,49 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const concurrentRenameAndCreateAtTargetCreateFirstTest: 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,52 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const concurrentRenameAndCreateAtTargetRenameFirstTest: 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,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,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,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,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,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");
}
}
]
};

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