Compare commits

...
Sign in to create a new pull request.

12 commits

Author SHA1 Message Date
0daeaf6382 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.
2026-05-08 21:37:51 +01:00
5a070340f1 split: history-ui (new Svelte workspace)
New web UI for browsing vault history. Svelte + Vite app with components
for activity feed, dashboard, document detail, diff view, file tree,
header, login, time slider, toast container, vault picker; lib/api.ts and
stores.svelte.ts for client state. (Generated TS types were added in
PR6.)
2026-05-08 21:37:37 +01:00
42c9d55489 split: sync-engine rewrite (sync-operations + sync-client.ts)
Replace the single unrestricted-syncer.ts with a two-loop architecture:
- syncer.ts drains the FIFO wire queue (HTTP + WS handlers).
- reconciler.ts moves files to make localPath match remoteRelativePath
  (topo-sorted move graph, in-memory cycle resolution with crash-safe
  swap markers).
- sync-event-queue.ts holds the byDocId / byLocalPath indexes and the
  pending-create promise chain.
- offline-change-detector.ts, expected-fs-events.ts, types.ts, and a
  rewritten cursor-tracker.ts / file-change-notifier.ts round it out.
Plus sync-client.ts wiring, tracing/sync-history.ts updates, index.ts
re-exports, and sync-client tsconfig/webpack/package.json.
2026-05-08 21:37:26 +01:00
0fda95ff8e split: sync-client file-operations + persistence
Rewrite file-operations and safe-filesystem-operations (and their tests),
update filesystem-operations. Drop persistence/database.ts (in-memory
record store moved into sync-event-queue). Update persistence/settings.ts.
2026-05-08 21:36:54 +01:00
45b86cffe4 split: sync-client services layer
Add build-vault-url helper. Rewrite fetch-controller and websocket-manager
(plus their tests). Update server-config and sync-service to consume the
new error types and the regenerated API types from previous chunks.
2026-05-08 21:36:41 +01:00
9d99a4ac23 split: sync-client utils and errors reorganization
Move error classes from services/ and file-operations/ into a new errors/
directory (authentication-error, server-version-mismatch-error,
sync-reset-error, file-not-found-error), plus add file-already-exists-error
and http-client-error. Update consts.ts and utils/* (await-all,
create-client-id, hash, rate-limit, find-matching-file). Replace
data-structures (locks, min-covered, event-listeners, fix-sized-cache) and
add debugging utilities (in-memory-file-system, log-to-console,
slow-web-socket-factory). Removes utils/create-promise.ts.
2026-05-08 21:36:29 +01:00
f7beb31d8f split: regenerated TS API type bindings
Auto-generated TS types regenerated from Rust ts-rs derives, mirrored into
frontend/sync-client/src/services/types/ and frontend/history-ui/src/lib/types/.
Adds ListVaultsResponse, VaultHistoryResponse, VaultInfo and updates several
existing types; removes DeleteDocumentVersion and UpdateDocumentVersion.
2026-05-08 21:36:13 +01:00
042233c4d7 split: server websocket + cursors
src/server/websocket.rs handshake/catch-up rewrite, app_state/cursors.rs,
app_state/websocket/{broadcasts,models,utils}.rs.
2026-05-08 21:35:52 +01:00
4ba439b874 split: server REST endpoints + rate limiting
server.rs router rewrite, auth.rs, device_id_header.rs, requests.rs,
responses.rs, plus per-endpoint changes: create/update/delete_document,
fetch_document_version{,_content,s}, fetch_latest_documents, index.rs.
Adds: fetch_vault_history, list_vaults, rate_limit (new files).
2026-05-08 21:35:41 +01:00
2d5edc6ec5 split: server database (app_state, migrations, models)
src/app_state.rs, src/app_state/database.rs (large schema/query rewrite),
two new migrations (add_idempotency_key, add_creation_vault_update_id),
and src/app_state/database/models.rs.
2026-05-08 21:35:30 +01:00
a9ce09b59d split: server foundation (Cargo, config, errors, utils, main)
Cargo.{toml,lock} bumps, build.rs, config-e2e.yml, rust-toolchain.toml,
src/config/* (database/logging/server/user configs), src/consts.rs,
src/errors.rs, src/main.rs, and src/utils/* (dedup_paths,
find_first_available_path, rotating_file_writer, sanitize_path).
2026-05-08 21:35:18 +01:00
70f97c4b16 split: CI workflows, scripts, root tooling, and docs
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
Forgejo workflows (new), GitHub workflow tweaks, .gitignore/.vscode, root
package-lock, rustfmt.toml, scripts/* updates, docs/ updates including
data-flow / authentication / server-setup, CLAUDE.md and README updates.
2026-05-08 21:35:07 +01:00
340 changed files with 26917 additions and 10338 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -23,13 +23,13 @@ jobs:
- name: Setup Node.js environment
uses: actions/setup-node@v4.2.0
with:
node-version: "22.x"
node-version: "25.x"
check-latest: true
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: "1.89.0"
toolchain: "1.92.0"
components: clippy, rustfmt
- name: Lint & test

View file

@ -5,8 +5,8 @@ on:
branches:
- main
paths:
- 'docs/**'
- '.github/workflows/deploy-docs.yml'
- "docs/**"
- ".github/workflows/deploy-docs.yml"
workflow_dispatch:
permissions:
@ -28,12 +28,11 @@ jobs:
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v4
- name: Setup Node.js environment
uses: actions/setup-node@v4.2.0
with:
node-version: 22
cache: npm
cache-dependency-path: docs/package-lock.json
node-version: "25.x"
check-latest: true
- name: Setup Pages
uses: actions/configure-pages@v4

View file

@ -6,7 +6,7 @@ on:
pull_request:
branches: ["main"]
schedule:
- cron: '0 * * * *'
- cron: "0 * * * *"
workflow_dispatch:
concurrency:
@ -28,13 +28,13 @@ jobs:
- name: Setup Node.js environment
uses: actions/setup-node@v4.2.0
with:
node-version: "22.x"
node-version: "25.x"
check-latest: true
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: "1.89.0"
toolchain: "1.92.0"
components: clippy, rustfmt
- name: Setup rust

View file

@ -19,7 +19,7 @@ jobs:
- name: Setup Node.js environment
uses: actions/setup-node@v4.2.0
with:
node-version: "22.x"
node-version: "25.x"
check-latest: true
- name: Build plugin
@ -31,7 +31,7 @@ jobs:
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: "1.89.0"
toolchain: "1.92.0"
components: clippy, rustfmt
- name: Install cross-compilation tools

9
.gitignore vendored
View file

@ -7,15 +7,18 @@ node_modules
# Frontend build folders
frontend/*/dist
sync-server/db.sqlite3*
sync-server/databases
# Rust build folders
sync-server/target
sync-server/artifacts
sync-server/bindings/*.ts
# build folders
sync-server/db.sqlite3*
**/databases
*.log
*.sqlx
target
.task

View file

@ -5,6 +5,6 @@
"**/dist": true,
"**/node_modules": true,
"**/.sqlx": true,
"**/target": true,
},
"**/target": true
}
}

195
CLAUDE.md
View file

@ -2,109 +2,154 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
## Project shape
VaultLink is a self-hosted Obsidian plugin for real-time collaborative file syncing. The project consists of a Rust-based sync server and a TypeScript frontend with three main components: an Obsidian plugin, a sync client library, and a test client.
VaultLink is a self-hosted Obsidian file-sync system. Two halves of one repo:
## Architecture
- `sync-server/` — Rust (axum + sqlx/SQLite). Source of truth for vault state, broadcasts changes via WebSocket.
- `frontend/` — npm workspaces. The sync engine (`sync-client`) is consumed by an Obsidian plugin, a standalone CLI, a fuzz E2E harness, a scripted determinism harness, and a history UI.
### Core Components
The HTTP/WS API types are generated from Rust (`ts-rs`) and mirrored into the TS workspaces. **Never hand-edit files in `frontend/sync-client/src/services/types/` or `frontend/history-ui/src/lib/types/`** — run `scripts/update-api-types.sh` after changing anything Serde-derived in the server.
- **sync-server/**: Rust-based WebSocket server with SQLite database for document versioning and real-time synchronization
- **frontend/sync-client/**: TypeScript library providing core sync functionality, WebSocket management, and file operations
- **frontend/obsidian-plugin/**: Obsidian plugin that integrates the sync client with Obsidian's API
- **frontend/test-client/**: CLI testing tool for the sync functionality
### Frontend workspaces
### Key Technologies
- `sync-client` — the sync engine; published to consumers via `dist/`. All other TS workspaces depend on it via `file:../sync-client`.
- `obsidian-plugin` — Obsidian plugin built from `sync-client`.
- `local-client-cli` — same engine wrapped as a standalone CLI.
- `history-ui` — vault-history web UI.
- `test-client` — fuzz E2E harness (random ops across N processes).
- `deterministic-tests` — scripted multi-client tests with an in-memory FS, run against a real server.
- **Backend**: Rust with Axum framework, SQLite with SQLx, WebSockets for real-time sync
- **Frontend**: TypeScript, Webpack for bundling, Jest for testing
- **Sync Algorithm**: Uses reconcile-text library for operational transformation
## Common commands
## Development Commands
Pre-push hygiene (formats, lints, runs tests, requires clean git state):
### Server Development
```bash
cd sync-server
cargo run config-e2e.yml # Start development server
cargo test --verbose # Run Rust tests
cargo clippy --all-targets --all-features # Lint Rust code
cargo clippy --all-targets --all-features --fix --allow-dirty --allow-staged # Auto-fix clippy warnings
cargo fmt --all -- --check # Check Rust formatting
cargo fmt --all # Auto-format Rust code
cargo machete --with-metadata # Detect unused dependencies
```sh
scripts/check.sh --fix
```
### Frontend Development
```bash
Run the fuzz E2E (N parallel processes):
```sh
scripts/e2e.sh 12
# Logs land in logs/log_<i>.log. Clean with scripts/clean-up.sh
```
Run deterministic tests (require a release-built server in `sync-server/target/release/sync_server` — they spawn it themselves):
```sh
cd sync-server && cargo build --release && cd ..
cd frontend
npm run dev # Start development mode (watches sync-client and obsidian-plugin)
npm run build # Build all workspaces
npm run test # Run all tests
npm run lint # Lint and format TypeScript code
npm run build -w sync-client -w deterministic-tests
node deterministic-tests/dist/cli.js # all
node deterministic-tests/dist/cli.js --filter=rename # subset
node deterministic-tests/dist/cli.js --filter=… -j 4 # cap parallelism
```
### Database Setup (Development)
```bash
Run a single sync-client unit test by file:
```sh
cd frontend/sync-client && npx tsx --test 'src/**/sync-event-queue.test.ts'
```
Server: dev runs from `sync-server/` against `config-e2e.yml`:
```sh
cd sync-server
cargo run config-e2e.yml # dev
cargo build --release # used by both e2e harnesses
cargo test # unit + ts-rs binding export tests
```
Frontend dev (sync-client + obsidian-plugin watch in parallel):
```sh
cd frontend && npm install && npm run dev
```
Regenerate TS bindings from Rust types (touches `frontend/{sync-client,history-ui}/src/.../types/`):
```sh
scripts/update-api-types.sh
```
## SQLite / sqlx
The server uses `sqlx::query!` macros that need a prepared `.sqlx` cache to compile offline. Touching any SQL means regenerating it:
```sh
cd sync-server
sqlx database create --database-url sqlite://db.sqlite3
sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3
cargo sqlx prepare --workspace
```
### Initial Setup
```bash
# Install required cargo tools
cargo install sqlx-cli cargo-machete cargo-edit
New migrations: `sqlx migrate add --source src/app_state/database/migrations <name>`.
## Sync engine architecture
Read `frontend/sync-client/src/sync-operations/` to follow the sync engine; the rest of `sync-client` is plumbing (filesystem ops, persistence, services, telemetry).
The engine is **two independent loops with separate invariants**:
- **Wire loop** (`syncer.ts`) — drains the single-consumer FIFO queue. HTTP and WS handlers update record fields (`remoteRelativePath`, `parentVersionId`, `remoteHash`) and write content to the file at `record.localPath`. They never move files for path placement.
- **Path reconciler** (`reconciler.ts`) — runs after every drained event. Best-effort pass that moves files to make `localPath === remoteRelativePath`. The move graph is topologically sorted; cycles are resolved by reading every file in the cycle into memory and writing each back to its new slot (no tmp files). Records with pending local events are skipped on each pass — the reconciler operates only on settled records. Failures (slot occupied by an untracked file, etc.) are silent skips; the next pass retries.
**`SyncEventQueue`** (`sync-event-queue.ts`) holds:
- `byDocId: Map<DocumentId, DocumentRecord>` — primary record store.
- `byLocalPath: Map<RelativePath, DocumentRecord>` — derived index for path lookups, maintained at every mutation point.
- `events: SyncEvent[]` — pending wire ops in FIFO drain order.
```ts
DocumentRecord = {
documentId,
parentVersionId,
remoteHash?,
remoteRelativePath,
localPath: RelativePath | undefined
}
```
### Scripts
- `scripts/check.sh`: Full CI check (builds, lints, tests both server and frontend)
- `scripts/check.sh --fix`: Same as above but auto-fixes linting and formatting issues
- `scripts/e2e.sh`: End-to-end testing
- `scripts/clean-up.sh`: Clean logs and database files
- `scripts/bump-version.sh patch`: Publish new version
- `scripts/update-api-types.sh`: Update TypeScript bindings from Rust types
`localPath === undefined` means the doc has no local file yet — typically a remote create whose target slot was occupied at receive time; the reconciler will fetch and place when the slot frees (the bytes wait in `pendingPlacementContent`).
## Code Structure
Local FS events from the watcher update `localPath` synchronously at enqueue time via `setLocalPath` / `upsertRecord`. The wire loop never updates it for path placement; only the reconciler does. A user rename onto a tracked slot enqueues a `LocalDelete` for the displaced doc (the OS rename clobbered its content) and clears that doc's `localPath`.
### Workspace Configuration
The frontend uses npm workspaces with four packages:
- `sync-client`: Core synchronization logic
- `obsidian-plugin`: Obsidian-specific integration
- `test-client`: Testing utilities
- `local-client-cli`: Standalone CLI for VaultLink sync client
**Pending creates** use a `Promise<DocumentId>` chain to serialize dependent ops (`LocalUpdate`, `LocalDelete`) behind the still-in-flight `LocalCreate`. `resolveCreate` resolves the promise once the server returns a docId, and `replacePendingDocumentId` swaps the resolved id across already-queued events. `findLatestCreateForPath` is the lookup the watcher uses to attach dependents; `updatePendingCreatePath` rewrites a pending create's `event.path` in place when the user renames the file before its create has acked.
### Type Generation
Rust structs generate TypeScript types via ts-rs crate, stored in `sync-server/bindings/` and used by frontend packages.
**Watermark.** `lastSeenUpdateId` uses a `MinCovered` (a contiguous-prefix tracker over a stream of integers): we only advance the published min when the next consecutive id has been processed, so out-of-order RemoteChange ids don't fool the WebSocket handshake into requesting a too-recent catch-up.
### Key Files
- `sync-server/src/`: Rust server implementation with WebSocket handlers
- `frontend/sync-client/src/sync-client.ts`: Main sync client entry point
- `frontend/obsidian-plugin/src/vault-link-plugin.ts`: Main Obsidian plugin class
- `frontend/sync-client/src/services/sync-service.ts`: Core synchronization logic
**Server catch-up.** The server's WS handshake replays events newer than the client's `last_seen_vault_update_id` from the `latest_document_versions` view (one row per doc, the latest). On those replayed rows `is_new_file` means _new to this client_ (`creation_vault_update_id > last_seen_vault_update_id`), not "this row is the doc's first version" — necessary because the catch-up only carries the latest version; if a doc was created and updated past the watermark, the client never sees its create otherwise.
## Testing
## Edge-case patterns the sync engine has to survive
### Running Tests
- Server: `cargo test --verbose`
- Frontend: `npm run test` (runs Jest across all workspaces)
- E2E: `scripts/e2e.sh`
The two-loop split defuses most of the old race catalogue (slot-collision stashes, conflict-uuid divergence, `MoveOnConflict.NEW`/`EXISTING` policy choices) by separating wire transport from path placement. What's left:
### Test Structure
- Rust: Unit tests alongside source files
- TypeScript: `.test.ts` files using Jest
- E2E: Uses test-client to simulate multiple concurrent users
**Pending-create docId is a `Promise`, not a string, until the create acks.** Any `LocalUpdate` / `LocalDelete` queued behind a still-in-flight `LocalCreate` carries the create's `resolvers.promise` as its `documentId`. `replacePendingDocumentId` swaps the resolved id across queued events when the create resolves; `===` comparisons against the resolved string elsewhere will silently fail until that swap runs. Anything that walks `events[]` looking for a docId match must either run after the swap or be tolerant of `Promise`-typed ids.
## Code Style
**`processCreate` reads `event.path` live, not `event.originalPath`.** The watcher rewrites `event.path` in place via `updatePendingCreatePath` when the user renames a pending-create file. `originalPath` was removed from `LocalCreate` events specifically because reading it would send the stale pre-rename path to the server.
### Rust
- Uses extensive Clippy lints (see Cargo.toml)
- Follows pedantic linting rules
- Forbids unsafe code
- Uses cargo fmt with default settings
**`record.localPath` mutates in place across awaits.** When the watcher renames a doc while a drain handler is awaiting an HTTP roundtrip, the queue mutates the in-flight event's record so subsequent reads see the new path. Snapshotting `record.localPath` into a local at function entry and using it after an `await` reads/writes a now-vacated slot. Read `record.localPath` live; only snapshot for the deliberate "did it change while I was awaiting" comparison.
### TypeScript
- Prettier configuration: 4-space tabs, trailing commas removed, LF line endings
- ESLint with unused imports plugin
- Consistent across all three frontend packages
**Reconciler-defer is the wire-loop's contract with the reconciler.** The reconciler skips records where `hasPendingLocalEventsForDocumentId` returns true. Wire-loop handlers can therefore freely write `remoteRelativePath` to whatever the server returned — even if it disagrees with `localPath` — knowing the reconciler won't move the file out from under a queued user rename.
**Watermark advancement is load-bearing both ways.** Branches that _skip_ a remote event without advancing `lastSeenUpdateId` create permanent gaps that re-deliver forever. Branches that _advance_ without applying the content lose data: the server has no further event to re-deliver, the catch-up only carries the latest version, and any state in between is gone. Don't advance unless the event was actually applied (or deliberately discarded after weighing both halves).
**`isNewFile` semantics differ between catch-up and real-time.** On WS handshake replay it means _new to this client_ (`creation_vault_update_id > last_seen_vault_update_id`); on real-time broadcasts it means _this version is the create_ (`creation_vault_update_id == vault_update_id`). A handler that decides based on one interpretation will be wrong on the other channel; reasoning about fetch-and-treat-as-new vs. ignore needs to know which channel delivered the event.
**Pause / disable-sync mid-flight** is the one race the new model doesn't structurally fix. An HTTP that committed server-side but whose response was discarded leaves the server holding a doc the client has no record of. Resume → offline scan → server-side dedupe handles it (the server merges the duplicate create into the existing doc), but if the merge produces a deconflict, the client picks up an extra file. Out of scope for the two-loop split.
**Cycle reconciliation uses in-memory content swap.** When the move graph contains a cycle, the reconciler reads every file in the cycle into memory and writes each back to its new slot, with no tmp files. A write-ahead marker at `.vaultlink/swap-<uuid>.json` lists each leg; on startup the reconciler reads the marker, hashes each `from` to determine which legs ran, and replays the rest. The `.vaultlink/**` glob is hard-coded as an internal ignore pattern so swap markers don't get sync'd.
## Two complementary E2E harnesses
- **`test-client` (fuzz):** random ops across N parallel processes for many minutes. Used by `scripts/e2e.sh`. Catches bugs nobody thought to write a test for, but reproductions are noisy.
- **`deterministic-tests`:** scripted scenarios with an in-memory FS pinned to a real server. Used to _capture_ a fuzz-discovered bug as a minimal repro before fixing it. See `frontend/deterministic-tests/README.md` for the step grammar (`pause-server`, `pause-websocket`, `barrier`, `assert-consistent`, etc.).
When a fuzz failure surfaces, the workflow is: root-cause from logs → write a deterministic test that fails on the bug → fix → confirm both the deterministic test and `e2e.sh` pass.
## Style
- TS: 4-space indent, no tabs, LF, prettier (`trailingComma: "none"`). YAML/MD use 2-space indent.
- Rust: `rustfmt.toml` enforces 4-space spaces, LF.
- Lint: ESLint for TS, Clippy for Rust, `cargo machete` for unused deps. All wired into `scripts/check.sh`.

View file

@ -8,12 +8,12 @@
## Develop
### Install [nvm](https://github.com/nvm-sh/nvm)
### Set up Node.JS 25 with [nvm](https://github.com/nvm-sh/nvm)
- `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash`
- `nvm install 22`
- `nvm use 22`
- Optionally set the system-wide default: `nvm alias default 22`
- `nvm install 25`
- `nvm use 25`
- Optionally, set the system-wide default: `nvm alias default 25`
### Set up Rust

View file

@ -2,12 +2,7 @@
"version": "0.2",
"language": "en-GB",
"dictionaries": ["en-gb"],
"ignorePaths": [
"node_modules",
".vitepress/dist",
".vitepress/cache",
"package-lock.json"
],
"ignorePaths": ["node_modules", ".vitepress/dist", ".vitepress/cache", "package-lock.json"],
"words": [
"VaultLink",
"Obsidian",

View file

@ -53,7 +53,7 @@ Central authority for synchronisation. Rust + Axum framework.
**Technology**:
- **Language**: Rust 1.89+
- **Language**: Rust 1.92+
- **Framework**: Axum (async web framework)
- **Database**: SQLite with SQLx
- **Protocol**: WebSockets for real-time communication

View file

@ -75,7 +75,7 @@ chmod +x sync_server-linux-x86_64
### Build from Source
Requirements: Rust 1.89.0+, SQLite development headers, SQLx CLI
Requirements: Rust 1.92.0+, SQLite development headers, SQLx CLI
```bash
# Clone the repository

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");
}
}
]
};

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