Compare commits
46 commits
main
...
asch/smart
| Author | SHA1 | Date | |
|---|---|---|---|
| a20264bcaf | |||
| 8f2f5e4fa9 | |||
| df37e6c236 | |||
| bbec7f14dd | |||
| a75b3469a3 | |||
| 2e827b6da5 | |||
| ae590e6fc8 | |||
| a63903734d | |||
| 75ef370703 | |||
| 2fbed09548 | |||
| 7fcd0f0bfa | |||
| 727b6b7ed5 | |||
| 4fb4b498a1 | |||
| cb2d82ab44 | |||
| f784d05a86 | |||
| 750cf8d4ee | |||
| 722e7af3e2 | |||
| f53ac121e8 | |||
| 16afe31e89 | |||
| ea5a123cb8 | |||
| bd8650e80b | |||
| 0e1849061b | |||
| 2dfb8b71e5 | |||
| e3a90833ff | |||
| 7c991c3b4d | |||
| 0d7d36e971 | |||
| 951200724c | |||
| c4f992c9d6 | |||
| e103bba12c | |||
| 439c066b57 | |||
| 63867be48a | |||
| a21b1e8c03 | |||
| d13abc115d | |||
| 7438108885 | |||
| a212aba755 | |||
| 16bb5042d5 | |||
| e25306c4c1 | |||
| c7507a3e7a | |||
| f431bea1af | |||
| d91993f249 | |||
| 45505a4bf7 | |||
| 9c5882e5fb | |||
| 19022c5b5f | |||
| 2a53fd3b59 | |||
| c638ded53a | |||
| 63a2079773 |
293 changed files with 15172 additions and 13735 deletions
|
|
@ -1,38 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
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
|
||||
27
.github/dependabot.yml
vendored
Normal file
27
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directories: ["/frontend", "/docs"]
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directories: ["**"]
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
- package-ecosystem: "cargo"
|
||||
directories: ["**"]
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
# Disable this for security reasons
|
||||
# - package-ecosystem: "github-actions"
|
||||
# directories: ["**"]
|
||||
# schedule:
|
||||
# interval: "daily"
|
||||
|
|
@ -13,7 +13,7 @@ env:
|
|||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
|
@ -21,9 +21,10 @@ jobs:
|
|||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version: "25.x"
|
||||
check-latest: true
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
58
.github/workflows/deploy-docs.yml
vendored
Normal file
58
.github/workflows/deploy-docs.yml
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
name: Deploy Documentation
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "docs/**"
|
||||
- ".github/workflows/deploy-docs.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version: "25.x"
|
||||
check-latest: true
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
|
||||
- name: Build docs
|
||||
run: scripts/build-docs.sh
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: docs/.vitepress/dist
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
needs: build
|
||||
runs-on: self-hosted
|
||||
name: Deploy
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
|
|
@ -18,7 +18,7 @@ env:
|
|||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
|
@ -26,9 +26,10 @@ jobs:
|
|||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version: "25.x"
|
||||
check-latest: true
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
67
.github/workflows/publish-cli-docker.yml
vendored
Normal file
67
.github/workflows/publish-cli-docker.yml
vendored
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
name: Publish CLI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
tags: ["*"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}-cli
|
||||
|
||||
jobs:
|
||||
publish-docker:
|
||||
runs-on: self-hosted
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install cosign
|
||||
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0
|
||||
with:
|
||||
cosign-release: "v2.2.4"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
|
||||
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=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Sign the published Docker image
|
||||
env:
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||
|
|
@ -9,7 +9,7 @@ env:
|
|||
|
||||
jobs:
|
||||
publish-plugin:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
|
@ -17,9 +17,10 @@ jobs:
|
|||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version: "25.x"
|
||||
check-latest: true
|
||||
|
||||
- name: Build plugin
|
||||
run: |
|
||||
|
|
@ -36,36 +37,23 @@ jobs:
|
|||
- name: Install cross-compilation tools
|
||||
run: |
|
||||
apt update
|
||||
apt install -y gcc-aarch64-linux-gnu musl-tools gcc-mingw-w64-x86-64 jq
|
||||
apt install -y gcc-aarch64-linux-gnu musl-tools gcc-mingw-w64-x86-64
|
||||
|
||||
- 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 }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
|
||||
mkdir -p release
|
||||
cp frontend/obsidian-plugin/dist/* release/
|
||||
cp sync-server/artifacts/sync-server-* release/
|
||||
cd 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
|
||||
gh release create "$tag" \
|
||||
--title="$tag" \
|
||||
--draft \
|
||||
*
|
||||
92
.github/workflows/publish-server-docker.yml
vendored
Normal file
92
.github/workflows/publish-server-docker.yml
vendored
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
name: Publish server Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
tags: ["*"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
env:
|
||||
# Use docker.io for Docker Hub if empty
|
||||
REGISTRY: ghcr.io
|
||||
# github.repository as <account>/<repo>
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
publish-docker:
|
||||
runs-on: self-hosted
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
# This is used to complete the identity challenge
|
||||
# with sigstore/fulcio.
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Install the cosign tool
|
||||
# https://github.com/sigstore/cosign-installer
|
||||
- name: Install cosign
|
||||
if: github.ref_type == 'tag'
|
||||
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0
|
||||
with:
|
||||
cosign-release: "v2.2.4"
|
||||
|
||||
# Set up BuildKit Docker container builder to be able to build
|
||||
# multi-platform images and export cache
|
||||
# https://github.com/docker/setup-buildx-action
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
|
||||
|
||||
# Login against a Docker registry
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.ref_type == 'tag'
|
||||
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Extract metadata (tags, labels) for Docker
|
||||
# https://github.com/docker/metadata-action
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
# Build and push Docker image with Buildx
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
|
||||
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=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Sign the resulting Docker image digest.
|
||||
# This will only write to the public Rekor transparency log when the Docker
|
||||
# repository is public to avoid leaking data. If you would like to publish
|
||||
# transparency data even for private images, pass --force to cosign below.
|
||||
# https://github.com/sigstore/cosign
|
||||
- name: Sign the published Docker image
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
env:
|
||||
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
||||
# This step uses the identity token to provision an ephemeral certificate
|
||||
# against the sigstore community Fulcio instance.
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -14,7 +14,8 @@ sync-server/bindings/*.ts
|
|||
|
||||
# build folders
|
||||
sync-server/db.sqlite3*
|
||||
**/databases
|
||||
sync-server/databases
|
||||
frontend/deterministic-tests/databases
|
||||
|
||||
*.log
|
||||
*.sqlx
|
||||
|
|
|
|||
512
CLAUDE.md
512
CLAUDE.md
|
|
@ -2,154 +2,490 @@
|
|||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project shape
|
||||
## Project Overview
|
||||
|
||||
VaultLink is a self-hosted Obsidian file-sync system. Two halves of one repo:
|
||||
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 four main components: an Obsidian plugin, a sync client library, a test client, and a standalone CLI client.
|
||||
|
||||
- `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.
|
||||
## Architecture
|
||||
|
||||
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.
|
||||
### Core Components
|
||||
|
||||
### Frontend workspaces
|
||||
- **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 simulating multiple concurrent users
|
||||
- **frontend/local-client-cli/**: Standalone CLI for VaultLink sync client
|
||||
- **frontend/history-ui/**: Svelte 5 web UI for browsing vault history, viewing diffs, and restoring versions
|
||||
|
||||
- `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.
|
||||
### Key Technologies
|
||||
|
||||
## Common commands
|
||||
- **Backend**: Rust with Axum framework, SQLite with SQLx, WebSockets for real-time sync
|
||||
- **Frontend**: TypeScript, Webpack for bundling, Node.js native test runner
|
||||
- **History UI**: Svelte 5 with runes, Vite for bundling, embedded in server binary via `rust-embed`
|
||||
- **Sync Algorithm**: Uses reconcile-text library for operational transformation
|
||||
|
||||
Pre-push hygiene (formats, lints, runs tests, requires clean git state):
|
||||
### Architectural Patterns
|
||||
|
||||
```sh
|
||||
scripts/check.sh --fix
|
||||
**Server Architecture:**
|
||||
|
||||
- `AppState`: Central state container holding `Database`, `Cursors`, and `Broadcasts`
|
||||
- `Database`: SQLite-backed document versioning with SQLx for compile-time query verification
|
||||
- `Broadcasts`: WebSocket broadcast system for real-time updates to connected clients
|
||||
- `Cursors`: Tracks user cursor positions across documents with background cleanup task
|
||||
|
||||
**Client Architecture:**
|
||||
|
||||
- `SyncClient`: Main entry point, orchestrates all sync operations
|
||||
- `SyncService`: HTTP API client for CRUD operations on documents
|
||||
- `WebSocketManager`: Manages WebSocket connection and real-time updates
|
||||
- `Syncer`: Coordinates file synchronization between local filesystem and server
|
||||
- `CursorTracker`: Manages local and remote cursor positions
|
||||
- `Database`: Client-side document metadata cache
|
||||
- `FileOperations`: Abstraction layer for filesystem operations
|
||||
|
||||
**Dual-Bundle Strategy:**
|
||||
The sync-client builds two separate bundles:
|
||||
|
||||
- `sync-client.web.js`: Browser-compatible UMD bundle (excludes `ws` package)
|
||||
- `sync-client.node.js`: Node.js CommonJS bundle with WebSocket support
|
||||
|
||||
**History UI Architecture:**
|
||||
|
||||
The history UI (`frontend/history-ui/`) is a standalone Svelte 5 SPA that provides read-only vault history browsing. It communicates with the server via the same REST API used by sync clients, plus three additional endpoints:
|
||||
|
||||
- `GET /vaults/:vault_id/documents/:document_id/versions` — all versions of a document (without content)
|
||||
- `GET /vaults/:vault_id/history?limit=&before_update_id=` — paginated vault-wide version history (cursor-based)
|
||||
- `POST /vaults/:vault_id/documents/:document_id/restore` — restore a document to a historical version (creates a new version with old content)
|
||||
|
||||
Server-side implementation:
|
||||
- Database methods: `get_document_versions()` and `get_vault_history()` in `database.rs`, plus a `VaultHistoryRow` helper struct for `sqlx::query_as!`
|
||||
- Handlers: `fetch_document_versions.rs`, `fetch_vault_history.rs`, `restore_document_version.rs`
|
||||
- Response type: `VaultHistoryResponse { versions, hasMore }` in `responses.rs`
|
||||
- SPA serving: `rust-embed` embeds `frontend/history-ui/dist/` into the binary; `index.rs` serves the SPA at `/` and assets at `/assets/*`
|
||||
|
||||
Client-side component hierarchy:
|
||||
- `App.svelte` — session restore, routing
|
||||
- `Login.svelte` — vault name + token auth via `/ping`
|
||||
- `Dashboard.svelte` — main layout: file tree sidebar, activity feed, time-travel slider
|
||||
- `DocumentDetail.svelte` — version timeline, content preview, diff view, restore
|
||||
- `DiffView.svelte` — unified diff with LCS algorithm
|
||||
- `FileTree.svelte` — recursive tree built from flat `relativePath` values
|
||||
- `ActivityFeed.svelte` — git-log-style feed with action pills (created/updated/renamed/deleted/restored)
|
||||
- `TimeSlider.svelte` — scrubs through `vaultUpdateId` range, reconstructs vault state at any point
|
||||
|
||||
State is managed with Svelte 5 runes (`$state`, `$derived`, `$effect`) in `lib/stores.svelte.ts`. Auth is stored in `sessionStorage`. The API client (`lib/api.ts`) sets `Authorization: Bearer` and `device-id: history-ui` headers on all requests.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Initial Setup
|
||||
|
||||
**Node.js (requires version 25):**
|
||||
|
||||
```bash
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
|
||||
nvm install 25
|
||||
nvm use 25
|
||||
nvm alias default 25 # Optional: set as system default
|
||||
```
|
||||
|
||||
Run the fuzz E2E (N parallel processes):
|
||||
**Rust:**
|
||||
|
||||
```sh
|
||||
scripts/e2e.sh 12
|
||||
# Logs land in logs/log_<i>.log. Clean with scripts/clean-up.sh
|
||||
```bash
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
|
||||
cargo install sqlx-cli cargo-machete cargo-edit cargo-insta
|
||||
```
|
||||
|
||||
Run deterministic tests (require a release-built server in `sync-server/target/release/sync_server` — they spawn it themselves):
|
||||
**Frontend:**
|
||||
|
||||
```sh
|
||||
cd sync-server && cargo build --release && cd ..
|
||||
```bash
|
||||
cd frontend
|
||||
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
|
||||
npm install
|
||||
```
|
||||
|
||||
Run a single sync-client unit test by file:
|
||||
### Server Development
|
||||
|
||||
```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
|
||||
```bash
|
||||
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
|
||||
cargo run config-e2e.yml # Start development server
|
||||
cargo test --verbose # Run all Rust tests
|
||||
cargo test <test_name> # Run specific test
|
||||
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
|
||||
```
|
||||
|
||||
Frontend dev (sync-client + obsidian-plugin watch in parallel):
|
||||
### Frontend Development
|
||||
|
||||
```sh
|
||||
cd frontend && npm install && npm run dev
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev # Start development mode (watches sync-client and obsidian-plugin)
|
||||
npm run build # Build all workspaces
|
||||
npm run build -w sync-client # Build specific workspace
|
||||
npm run test # Run all tests across all workspaces
|
||||
npm run test -w sync-client # Run tests for specific workspace
|
||||
npm run lint # Lint and format TypeScript code with ESLint + Prettier
|
||||
```
|
||||
|
||||
Regenerate TS bindings from Rust types (touches `frontend/{sync-client,history-ui}/src/.../types/`):
|
||||
### History UI Development
|
||||
|
||||
```sh
|
||||
scripts/update-api-types.sh
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev -w history-ui # Start Vite dev server (localhost:5173, proxies API to localhost:3000)
|
||||
npm run build -w history-ui # Build for production (output: frontend/history-ui/dist/)
|
||||
```
|
||||
|
||||
## SQLite / sqlx
|
||||
The history UI is a Svelte 5 SPA embedded in the server binary via `rust-embed`. The build flow is:
|
||||
|
||||
The server uses `sqlx::query!` macros that need a prepared `.sqlx` cache to compile offline. Touching any SQL means regenerating it:
|
||||
1. `npm run build -w history-ui` produces `frontend/history-ui/dist/`
|
||||
2. The Rust server embeds these files at compile time (`sync-server/src/server/index.rs`)
|
||||
3. The server serves `index.html` at `GET /` and static assets at `GET /assets/*`
|
||||
4. If the dist directory doesn't exist at Rust compile time, `build.rs` creates a placeholder
|
||||
|
||||
```sh
|
||||
During development, run the Vite dev server separately and use its proxy to forward API calls to the running sync server.
|
||||
|
||||
### Database Operations
|
||||
|
||||
```bash
|
||||
cd sync-server
|
||||
# Create/reset database for development
|
||||
rm -rf db.sqlite*
|
||||
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
|
||||
|
||||
# Add new migration
|
||||
sqlx migrate add --source src/app_state/database/migrations <migration_name>
|
||||
sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3
|
||||
```
|
||||
|
||||
New migrations: `sqlx migrate add --source src/app_state/database/migrations <name>`.
|
||||
### Project Scripts
|
||||
|
||||
## Sync engine architecture
|
||||
- `scripts/check.sh`: Full CI check (builds, lints, tests both server and frontend). **Run before pushing.**
|
||||
- `scripts/check.sh --fix`: Same as above but auto-fixes linting and formatting issues
|
||||
- `scripts/e2e.sh`: End-to-end testing (e.g., `scripts/e2e.sh 8` for 8 concurrent clients)
|
||||
- `scripts/clean-up.sh`: Clean logs and database files
|
||||
- `scripts/bump-version.sh patch`: Publish new version (options: patch, minor, major)
|
||||
- `scripts/update-api-types.sh`: Update TypeScript bindings from Rust types (uses ts-rs)
|
||||
|
||||
Read `frontend/sync-client/src/sync-operations/` to follow the sync engine; the rest of `sync-client` is plumbing (filesystem ops, persistence, services, telemetry).
|
||||
## Code Structure
|
||||
|
||||
The engine is **two independent loops with separate invariants**:
|
||||
### Workspace Configuration
|
||||
|
||||
- **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.
|
||||
The frontend uses npm workspaces with five packages:
|
||||
|
||||
**`SyncEventQueue`** (`sync-event-queue.ts`) holds:
|
||||
- `sync-client`: Core synchronization logic (builds dual bundles for web and Node.js)
|
||||
- `obsidian-plugin`: Obsidian-specific integration
|
||||
- `test-client`: Testing utilities for E2E tests
|
||||
- `local-client-cli`: Standalone CLI for VaultLink sync client
|
||||
- `history-ui`: Svelte 5 SPA for vault history browsing (built with Vite, embedded in server binary)
|
||||
|
||||
- `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.
|
||||
### Type Generation and API Updates
|
||||
|
||||
```ts
|
||||
DocumentRecord = {
|
||||
documentId,
|
||||
parentVersionId,
|
||||
remoteHash?,
|
||||
remoteRelativePath,
|
||||
localPath: RelativePath | undefined
|
||||
}
|
||||
Rust structs generate TypeScript types via ts-rs crate:
|
||||
|
||||
1. Rust structs annotated with `#[derive(TS)]` export to `sync-server/bindings/`
|
||||
2. Run `scripts/update-api-types.sh` to copy bindings to `frontend/sync-client/src/services/types/`
|
||||
3. Frontend imports these types for type-safe API communication
|
||||
|
||||
### Important Implementation Details
|
||||
|
||||
**SQLx Compile-Time Verification:**
|
||||
|
||||
- SQLx verifies SQL queries at compile time against the database schema
|
||||
- Run `cargo sqlx prepare --workspace` after schema changes to update `.sqlx/` directory
|
||||
- CI builds require prepared query metadata to avoid needing a live database
|
||||
|
||||
## Testing
|
||||
|
||||
### Running Tests
|
||||
|
||||
**Server:**
|
||||
|
||||
```bash
|
||||
cargo test --verbose # All tests
|
||||
cargo test <test_name> # Specific test
|
||||
```
|
||||
|
||||
`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`).
|
||||
**Frontend:**
|
||||
|
||||
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`.
|
||||
```bash
|
||||
npm run test # All workspaces
|
||||
npm run test -w sync-client # Specific workspace
|
||||
```
|
||||
|
||||
**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.
|
||||
**E2E:**
|
||||
|
||||
**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.
|
||||
```bash
|
||||
scripts/e2e.sh 8 # 8 concurrent clients
|
||||
scripts/clean-up.sh # Clean up after tests
|
||||
```
|
||||
|
||||
**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.
|
||||
### Test Structure
|
||||
|
||||
## Edge-case patterns the sync engine has to survive
|
||||
- **Rust**: Unit tests alongside source files, uses `cargo-insta` for snapshot testing
|
||||
- **TypeScript**: `.test.ts` files using Node.js native test runner (not Jest)
|
||||
- **E2E**: Uses `test-client` to simulate multiple concurrent users with random operations
|
||||
|
||||
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:
|
||||
## Code Style and Formatting
|
||||
|
||||
**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.
|
||||
### Rust
|
||||
|
||||
**`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.
|
||||
- Extensive Clippy lints (see `Cargo.toml`)
|
||||
- Pedantic linting rules enabled
|
||||
- Forbids unsafe code
|
||||
- Uses `rustfmt.toml` for formatting configuration (4 spaces, Unix line endings)
|
||||
- Run `cargo fmt --all` to format
|
||||
|
||||
**`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
|
||||
|
||||
**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.
|
||||
- **Prettier**: 4-space indentation, no trailing commas, LF line endings
|
||||
- **YAML/Markdown override**: 2-space indentation (via prettier config)
|
||||
- **ESLint**: Strict rules with unused imports detection
|
||||
- Configuration in `frontend/package.json`
|
||||
- Run `npm run lint` to format and fix issues
|
||||
|
||||
**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).
|
||||
### Svelte (History UI)
|
||||
|
||||
**`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.
|
||||
- Uses Svelte 5 runes syntax (`$state`, `$derived`, `$effect`, `$props`)
|
||||
- Vite as bundler with `@sveltejs/vite-plugin-svelte`
|
||||
- Excluded from the main ESLint config (Svelte files need different linting); `history-ui/**` is in the eslint ignores list
|
||||
- CSS is component-scoped via Svelte's `<style>` blocks with CSS custom properties defined in `app.css`
|
||||
|
||||
**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.
|
||||
### EditorConfig
|
||||
|
||||
**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.
|
||||
- `.editorconfig` at project root defines baseline formatting rules
|
||||
- `rustfmt.toml` and Prettier config explicitly mirror these settings
|
||||
- Both formatters enforce: 4-space indent (2 for YAML/MD), LF endings, final newline, trim trailing whitespace
|
||||
|
||||
## Two complementary E2E harnesses
|
||||
## Sync Logic Deep Dive
|
||||
|
||||
- **`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.).
|
||||
### Document Lifecycle
|
||||
|
||||
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.
|
||||
Documents go through these states on the client:
|
||||
|
||||
## Style
|
||||
1. **Pending create**: `metadata === undefined`, `idempotencyKey` set. File exists locally but hasn't been confirmed by the server yet.
|
||||
2. **Synced**: `metadata` has `documentId`, `parentVersionId`, `hash`. The server knows about this document.
|
||||
3. **Deleted**: `isDeleted === true`. Locally deleted, may or may not be synced to server yet.
|
||||
|
||||
- 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`.
|
||||
Pending creates are persisted to the local DB (via `StoredPendingDocument`) so they survive app crashes.
|
||||
|
||||
### Create Flow and Idempotency
|
||||
|
||||
The create flow is designed to handle interrupted creates (lost responses, app crashes):
|
||||
|
||||
1. Client generates `idempotencyKey` (UUID) and persists it locally before sending the request
|
||||
2. Client sends HTTP POST with the key and file content to the server
|
||||
3. Server checks if the `idempotency_key` already exists — if so, returns existing document (idempotent)
|
||||
4. Server stores the key in the `documents` table alongside the document version
|
||||
5. When a create results in a merge (document already exists at that path), both the original key and the new key are preserved — they're on different version rows of the same document
|
||||
|
||||
On reconnect, the client calls `POST /documents/resolve-keys` with all pending idempotency keys. The server maps each key to a `documentId`. The client assigns these documentIds to pending documents so they're recognized during subsequent remote fetch, preventing duplicates.
|
||||
|
||||
If key resolution fails (e.g., during a SyncReset), the pending creates retry normally with the same key — the server deduplicates.
|
||||
|
||||
### Server-Side Smart Create
|
||||
|
||||
When a client sends a create request for a path where a document already exists:
|
||||
|
||||
1. Server calls `merge_with_stored_version` instead of creating a new document
|
||||
2. Content is 3-way merged using `reconcile-text` (for text files) or last-write-wins (for binary)
|
||||
3. The response uses the EXISTING document's `documentId` — the client adopts it
|
||||
4. The `idempotency_key` from the create request is stored on the new merged version
|
||||
|
||||
### Concurrency Model (Client)
|
||||
|
||||
The client uses two layers of concurrency control:
|
||||
|
||||
1. **PQueue (`syncQueue`)**: Limits concurrent sync operations (configurable via `syncConcurrency`)
|
||||
2. **Locks (`updatedDocumentsByPathAndKeysLocks`)**: Per-document locks keyed by `relativePath` and `documentId`
|
||||
|
||||
**Critical ordering**: Locks are acquired INSIDE the queue, not outside. Acquiring locks while waiting for queue slots causes deadlocks (two operations hold locks on different keys while both waiting for queue capacity).
|
||||
|
||||
```
|
||||
syncQueue.add(async () =>
|
||||
locks.withLock(keys, operation) // lock acquired only when queue slot is available
|
||||
)
|
||||
```
|
||||
|
||||
### Sync Reset and Recovery
|
||||
|
||||
A `SyncResetError` is thrown when the WebSocket disconnects or sync is toggled off. This:
|
||||
- Clears the sync queue
|
||||
- Rejects all pending lock waiters
|
||||
- On reconnect, `scheduleSyncForOfflineChanges()` runs to reconcile local state with server
|
||||
|
||||
**Important**: `SyncResetError` during `syncRemotelyUpdatedFile` must be caught and logged as INFO, not ERROR. The test client exits on ERROR-level logs (except retries), so logging SyncResetError as ERROR during expected resets causes false test failures.
|
||||
|
||||
### The Offline Sync Algorithm (`scheduleSyncForOfflineChanges`)
|
||||
|
||||
Runs on reconnect to detect what changed while offline:
|
||||
|
||||
1. **Resolve idempotency keys first**: Call `resolveIdempotencyKeys()` to map pending creates to server-side documentIds before scanning files
|
||||
2. List all local files
|
||||
3. For each file with metadata: schedule as update (hash comparison will skip unchanged)
|
||||
4. For each file without metadata: try to match against "deleted" DB records by content hash (detects moves). If no match, schedule as create.
|
||||
5. For DB records whose files don't exist locally: schedule as delete
|
||||
6. Ordering is: interrupted-deletes → updates → creates → possibly-deleted-deletes. Creates run BEFORE possibly-deleted deletes so that the server can merge creates with existing documents at the same path (preserving documentIds). If deletes ran first, a renamed+edited file would get a new documentId instead of adopting the existing one.
|
||||
|
||||
### Remote Update Processing
|
||||
|
||||
When the server broadcasts updates via WebSocket:
|
||||
|
||||
1. `scheduleSyncForOfflineChanges()` runs first (ensures local changes are queued)
|
||||
2. For each remote document update:
|
||||
- If client knows the `documentId`: treat as update to existing doc
|
||||
- If client doesn't know the `documentId`: it's a new remote document — create locally
|
||||
3. Before creating a new local file for an unknown remote doc, check if a pending local create exists at the same `originalCreationPath`. If so, skip (the pending retry with idempotency key will handle it).
|
||||
|
||||
### Known Concurrency Pitfalls
|
||||
|
||||
1. **Interrupted create + rename + modify**: A create request succeeds on the server but the response is lost. The file is renamed and modified locally. On reconnect, the idempotency key resolution maps the pending doc to the server's documentId, preventing a duplicate.
|
||||
|
||||
2. **Two clients create at same path**: Both send creates with different idempotency keys. Server merges them under one `documentId`. Each key is stored on its respective version row. Both clients can resolve their keys to the same document.
|
||||
|
||||
3. **Lock ordering**: Multi-key locks are sorted alphabetically to prevent deadlocks. Lock acquisition is sequential (not concurrent) even for multiple keys.
|
||||
|
||||
4. **`resolvedDocuments` vs `pendingDocuments`**: `resolvedDocuments` only includes docs with metadata (filters by `metadata !== undefined`). `pendingDocuments` returns docs with `metadata === undefined && !isDeleted`. Never confuse the two — scanning `resolvedDocuments` for pending docs returns nothing.
|
||||
|
||||
5. **`saveInTheBackground` triggers `ensureConsistency`**: The consistency check calls `resolvedDocuments` which can throw if there are duplicate paths with the same `parallelVersion`. Avoid calling `saveInTheBackground` during operations that temporarily create inconsistent state — use `save()` directly instead. This is why `createNewPendingDocument` calls `save()` directly.
|
||||
|
||||
6. **Pending doc `parallelVersion` on load**: When loading pending documents from storage, compute `parallelVersion` based on existing docs at the same path (use `getLatestDocumentByRelativePath` to find the current max). Setting all to 0 causes collisions if a resolved doc at the same path also has `parallelVersion: 0`.
|
||||
|
||||
7. **Key resolution with stale documentIds**: When `resolveIdempotencyKeys` returns a documentId, check `getDocumentByDocumentId` first. If another document already has that ID (assigned through normal sync), remove the stale pending doc instead of creating a duplicate.
|
||||
|
||||
8. **`resolveIdempotencyKeys` uses `retryForever`**: The HTTP call to `/documents/resolve-keys` retries forever like all other sync service calls. `SyncResetError` is re-thrown by `retryForever`, so the pipeline properly aborts on WebSocket disconnect without deadlocking.
|
||||
|
||||
### E2E Test Configuration
|
||||
|
||||
The test client (`frontend/test-client/src/cli.ts`) runs 5 iterations of 9 test configurations per process:
|
||||
- 2 agents, concurrency 16 and 1, with/without deletes, with/without resets, with/without slow file events
|
||||
- Tests assert: file system consistency between agents AND no duplicate content across files
|
||||
- Uses `jitterScaleInSeconds: 0.75` to simulate network latency
|
||||
|
||||
**Running E2E**: Requires a server running with `config-e2e.yml`. Always clean the server databases before running. Use `scripts/e2e.sh 8` for 8 concurrent processes (each running the full test suite independently).
|
||||
|
||||
**E2E test harness known issue**: The named pipe mechanism for log collection can cause processes to hang when debug output exceeds the pipe buffer size. This is an infrastructure issue, not a sync bug. If processes appear stuck with logs that stopped growing, it's likely a pipe buffer issue.
|
||||
|
||||
### File Operations Abstraction
|
||||
|
||||
`FileOperations` has an `ensureClearPath` method that renames existing files to `(1).md`, `(2).md` etc. if a file already exists at the target path. This prevents data loss but can create apparent duplicates if the sync logic doesn't handle it.
|
||||
|
||||
The `write` method does a 3-way merge: `write(path, oldContent, newContent)`. It reads the current file, computes a diff from `oldContent` to `newContent`, and applies that diff to the current file content. This preserves local changes that happened between the read and write. If the old content doesn't match what's expected, the merge can fail with "Part X not found in new content".
|
||||
|
||||
### Approaches That Were Tried and Failed
|
||||
|
||||
When fixing the duplicate-document-after-interrupted-create problem, several heuristic approaches were attempted before landing on idempotency keys:
|
||||
|
||||
1. **Content-hash matching during remote fetch**: Scan all pending docs, read each file, hash it, and compare against incoming remote document. Failed because: (a) local content can be modified between the create and the fetch, so hashes don't match; (b) O(pending × remote) file I/O; (c) the `resolvedDocuments` getter was used instead of `pendingDocuments`, which filtered out all pending docs — a silent no-op bug.
|
||||
|
||||
2. **`originalCreationPath` matching**: Track where each pending doc was originally created. When a remote doc arrives at that path, assign metadata. Failed because: (a) two different clients can create at the same path — false matches assign wrong metadata, causing 3-way merge errors on the other client; (b) adding a `deviceId` check to limit false matches broke the case where another client updated the document (changing the deviceId in the broadcast).
|
||||
|
||||
3. **In-memory tracking** (e.g., `pendingLocalId`): Any in-memory state is lost on app crash. The whole point of the fix is to handle interrupted creates, which include crashes.
|
||||
|
||||
The idempotency key approach works because it's: (a) crash-safe (persisted locally); (b) deterministic (UUID lookup, no heuristics); (c) server-authoritative (the server resolves keys to documentIds).
|
||||
|
||||
### Critical Implementation Invariants (Learned from Bugs)
|
||||
|
||||
These invariants were discovered through deep auditing and E2E testing. Violating any of them causes data loss, sync stalls, or test failures.
|
||||
|
||||
**1. `waitUntilFinished` must loop until both sync queue AND WebSocket handlers are simultaneously idle.**
|
||||
WebSocket message handlers (`onRemoteVaultUpdateReceived`) enqueue new sync operations. If you wait for the sync queue first, then WebSocket handlers, the handlers may have enqueued new operations that aren't awaited. The correct implementation loops: wait for WS handlers → wait for sync queue → check if WS has new work → repeat if needed. See `SyncClient.waitUntilFinished()`.
|
||||
|
||||
**2. `enqueueSyncOperation` must catch ALL errors, not just `SyncResetError`.**
|
||||
`executeSync` re-throws non-SyncReset/non-FileNotFound errors (they're logged in sync history as ERROR). If `enqueueSyncOperation` doesn't catch these, they become unhandled promise rejections that crash the process. The catch logs the error and returns undefined — failed operations will be retried on the next WebSocket reconnect (which clears `runningScheduleSyncForOfflineChanges` and triggers a fresh filesystem scan).
|
||||
|
||||
**3. `Locks.reset()` must NOT clear `this.locked`.**
|
||||
In-flight operations (currently executing their callback) still hold conceptual locks. If `reset()` clears `this.locked`, new operations can acquire the same key and run concurrently with the still-running old operation. Only clear `this.waiters` (to reject pending waiters with SyncResetError). Let running operations release their locks naturally via the `finally` block in `withLock`.
|
||||
|
||||
**4. `handleMaybeMergingResponse` must write the file BEFORE updating metadata.**
|
||||
If metadata is updated first and the write fails (crash, OS error), the metadata points to a server version whose content was never written locally. On recovery, the stale local content is uploaded, potentially overwriting other clients' changes that were part of the merge. Order: write file → re-read + re-hash → update metadata → update cache.
|
||||
|
||||
**5. After a MergingUpdate, cache the SERVER's content (`responseBytes`), not the local content.**
|
||||
The content cache is used to compute diffs for subsequent updates: `diff(cached, newFileContent)`. The server applies this diff against its content at `parentVersionId`. If the cache stores the local content (which may differ from the server's due to the 3-way merge in `FileOperations.write`), the diff won't match the server's state and the update will fail with "Invalid diff".
|
||||
|
||||
**6. After a MergingUpdate, re-read the file and re-hash.**
|
||||
The 3-way merge in `operations.write()` may produce content different from `responseBytes` (because the user edited the file between the read and the write). The stored hash must match the actual on-disk content, not the server's merged content. Otherwise, the next sync cycle incorrectly detects "no changes" (phantom hash match) or always detects changes (phantom hash mismatch).
|
||||
|
||||
**7. Snapshot `parentVersionId` before computing diffs.**
|
||||
`document.metadata` is a mutable shared reference. A concurrent operation (via a WebSocket handler running during an `await` in the same sync operation) can update `parentVersionId` between the cache lookup and the `putText` call. Always capture `const parentVersionIdForUpdate = document.metadata.parentVersionId` and use that value for both the cache lookup and the HTTP request.
|
||||
|
||||
**8. Guard `updateDocumentMetadata` against concurrently removed documents.**
|
||||
After any `await` (file write, re-read, HTTP call), the document may have been removed from the database by a concurrent delete operation. Always check `database.containsDocument(document)` before calling `updateDocumentMetadata` if there was an `await` since the document reference was obtained. Return gracefully if removed — the file is on disk and `scheduleSyncForOfflineChanges` will re-detect it.
|
||||
|
||||
**9. When assigning a `documentId` to a pending doc, check for duplicates first.**
|
||||
Both `resolveIdempotencyKeys` and `handleMaybeMergingResponse` (for deleted pending docs) assign documentIds. Before setting metadata, call `getDocumentByDocumentId(id)`. If another document already has that ID, remove the stale pending doc instead of creating a duplicate. `ensureConsistency` checks for duplicate documentIds across ALL documents (not just `resolvedDocuments`).
|
||||
|
||||
**10. `resolveIdempotencyKeys` sets `parentVersionId: 0` — treat this as a create, not an update.**
|
||||
When `resolveIdempotencyKeys` assigns a documentId to a pending doc, it uses `parentVersionId: 0` as a placeholder. The sync path must check for `parentVersionId === 0` and take the CREATE path (sending a create with the idempotency key), not the UPDATE path (which would fail because version 0 doesn't exist on the server).
|
||||
|
||||
**11. Idempotent create returns can have stale content — always fetch server content.**
|
||||
When the server returns a `FastForwardUpdate` for a create with an idempotency key, it may return the ORIGINAL version (from the first create), not a new version with the current content. Always fetch the actual server content for idempotent create returns (the `isCreate` path in `handleMaybeMergingResponse`) and use it for the cache and hash, so subsequent diffs are correct. Do not use a content-length comparison as a shortcut — two different byte sequences can have the same length.
|
||||
|
||||
**12. `SyncClient.pause()` must swallow `SyncResetError`.**
|
||||
`pause()` calls `fetchController.startReset()` which rejects in-flight fetches. Those rejections propagate through `waitUntilFinished()`. Since `pause()` CAUSED the reset, the resulting `SyncResetError` is expected and must be caught (not re-thrown). Only re-throw non-SyncResetError exceptions. Also call `fetchController.finishReset()` in the catch block to prevent the FetchController from getting stuck in resetting state.
|
||||
|
||||
**13. `runningScheduleSyncForOfflineChanges` must be cleared on WebSocket disconnect.**
|
||||
After the initial `scheduleSyncForOfflineChanges()` completes, the field retains the resolved promise. On WebSocket disconnect/reconnect (without a full client reset), the field must be cleared so the next call triggers a fresh filesystem scan. Add a handler on `onWebSocketStatusChanged` that sets the field to `undefined` when `isConnected` is false.
|
||||
|
||||
**14. The server must not `expect()` / panic on UTF-8 conversion — return a client error.**
|
||||
In `update_text`, the parent version's content may be binary (if another client uploaded binary via `putBinary`). Using `.expect()` on `str::from_utf8()` panics the server. Use `.context(...).map_err(client_error)?` to return a 4xx error, allowing the client to fall back to `putBinary`.
|
||||
|
||||
**15. The create-merge parent content must be empty (`&Vec::new()`), not `latest_version.content`.**
|
||||
In `create_document.rs`, when a create merges with an existing document, the 3-way merge parent must be an empty vector (`&Vec::new()`), not the latest version's content. Using `latest_version.content` as the parent makes `reconcile(A, A, B) = B`, which silently discards the existing content (last-write-wins). An empty parent causes `reconcile("", existing, new)` to correctly treat both sides as independent additions and merge them together.
|
||||
|
||||
**16. `retryForever` must not retry 4xx HTTP errors.**
|
||||
4xx errors indicate the request itself is wrong (e.g., invalid diff, missing parent version). Retrying won't help. The `HttpClientError` class (in `errors/http-client-error.ts`) carries the status code. `retryForever` checks for it and re-throws immediately. Only 5xx errors (transient server failures) are retried.
|
||||
|
||||
**17. The broadcast channel's `RecvError::Lagged` must be handled explicitly.**
|
||||
The `while let Ok(update) = broadcast_receiver.recv().await` pattern silently exits the loop on `Lagged`, disconnecting the client without logging. Handle `Lagged` explicitly with a `warn!` log and `break`. The channel capacity is `max_clients_per_vault`.
|
||||
|
||||
**18. `merge_with_stored_version` must not short-circuit when an idempotency key is provided.**
|
||||
When the new content is identical to the latest version and an `idempotency_key` is present, the function must still insert a new version row so the key is persisted in the database. Without this, the key is lost: `resolveIdempotencyKeys` returns no match after a crash, and the client retries the create without idempotency protection — potentially doubling content via the empty-parent merge. The short-circuit (`content == latest_version.content && ... && idempotency_key.is_none()`) only applies to keyless updates.
|
||||
|
||||
**19. The idempotency key check in `create_document` must skip deleted documents.**
|
||||
When `get_document_by_idempotency_key` returns a document with `is_deleted: true`, the server must NOT return it as an idempotent match. Returning a deleted version causes the client to call `applyRemoteDeleteLocally`, silently deleting the user's local file. Instead, fall through to the normal create path so the file is preserved as a new document.
|
||||
|
||||
**20. `syncLocallyCreatedFile` must treat `parentVersionId === 0` as needing a create retry.**
|
||||
When `resolveIdempotencyKeys` assigns metadata with `parentVersionId: 0`, the document looks "resolved" to `syncLocallyCreatedFile` (it has `metadata !== undefined`). Without a special check for `parentVersionId === 0`, the method returns early ("already exists with metadata"), leaving the document permanently stuck — it never syncs. The fix: when `parentVersionId === 0`, treat it like a pending create retry and enqueue `unrestrictedSyncLocallyCreatedOrUpdatedFile`.
|
||||
|
||||
**21. The client must normalize content to UTF-8 at the read boundary.**
|
||||
`FileOperations.read()` calls `normalizeToUtf8()` to transcode UTF-16 (detected by BOM) to UTF-8 before any downstream code sees the bytes. This means `isBinary` / `is_binary` on both client and server only need to check UTF-8 validity — no UTF-16 handling required. A disagreement between client and server on text vs binary causes permanent sync failures (client sends `putText` for content the server considers binary, 4xx error, `retryForever` won't retry). The UTF-8-only contract keeps classification trivial and impossible to get out of sync.
|
||||
|
||||
### E2E Test Debugging Guide
|
||||
|
||||
**How to run E2E tests:**
|
||||
```bash
|
||||
cd sync-server && rm -rf databases && ./target/release/sync_server config-e2e.yml &
|
||||
sleep 3
|
||||
cd /volumes/syncthing/Projects/vault-link && scripts/e2e.sh 8
|
||||
```
|
||||
Always clean the `databases` directory before running. The server must be running separately.
|
||||
|
||||
**Common E2E failure patterns:**
|
||||
|
||||
1. **`SyncResetError` unhandled rejection**: Check that `enqueueSyncOperation` catches all errors and that `pause()` swallows SyncResetError. The test client's `unhandledRejection` handler checks `error.name === "SyncResetError"` — if the error message changes, update the filter in `test-client/src/cli.ts`.
|
||||
|
||||
2. **"Files from agent-X missing in agent-Y"**: This is a consistency assertion. Check the agent's LOCAL file list (now correctly logged per-agent after a logging bug fix). Common causes:
|
||||
- **Broadcasts lost during shutdown**: Operations completed on one agent but the WebSocket broadcast didn't reach the other before destroy. The 5-second sleep between finish and destroy helps.
|
||||
- **Path deconfliction**: Both agents have the same DOCUMENT but at different LOCAL paths (e.g., `binary-10.bin` vs `binary-10 (1).bin`). This is a known limitation with concurrent creates at the same path.
|
||||
- **Failed sync operations not retried**: If `executeSync` throws, the failed file won't be retried until the next WebSocket reconnect (which clears `runningScheduleSyncForOfflineChanges` and triggers a fresh filesystem scan).
|
||||
|
||||
3. **"Document not found in database"**: A concurrent operation removed the document between the last `await` and the `updateDocumentMetadata` call. Add a `containsDocument` guard.
|
||||
|
||||
4. **"Duplicate documentId found in database"**: Two documents have the same `documentId`. Usually caused by `resolveIdempotencyKeys` or `handleMaybeMergingResponse` assigning a documentId without checking if another doc already has it.
|
||||
|
||||
5. **"Invalid diff: attempting to access N characters..."**: The content cache has wrong content for a `parentVersionId`. Common causes: (a) cached local content instead of server content after MergingUpdate; (b) idempotent create returned a stale version but the client cached its current content under that version ID; (c) `parentVersionId` changed between cache lookup and `putText` call due to mutable shared reference.
|
||||
|
||||
6. **"Parent version with id 0 not found"**: A document's `parentVersionId` is 0 (set by `resolveIdempotencyKeys`). The sync path should treat `parentVersionId === 0` as a create, not an update.
|
||||
|
||||
**Test client internals (`test-client/src/agent/mock-agent.ts`):**
|
||||
- `files`: InMemoryFileSystem map — the ACTUAL filesystem state
|
||||
- `data`: Map of expected file contents — what the agent CREATED/UPDATED
|
||||
- `assertFileSystemsAreConsistent`: Compares `files` maps between two agents
|
||||
- `assertAllContentIsPresentOnce`: Checks no duplicate content across files
|
||||
- The `finish()` and `destroy()` methods use `withTimeout(TIMEOUT_MS)` — operations that exceed 30s are killed
|
||||
|
||||
**Logging bug (fixed):** In `assertFileSystemsAreConsistent`, the error handler's "Local files" log previously printed `otherAgent.files.keys()` for BOTH agents. Now correctly prints `this.files.keys()` for the current agent.
|
||||
|
|
|
|||
16
README.md
16
README.md
|
|
@ -46,34 +46,38 @@ npm install
|
|||
npm run dev
|
||||
```
|
||||
|
||||
### Scripts
|
||||
### Common Tasks
|
||||
|
||||
This project uses [Taskfile](https://taskfile.dev/) for task automation. Run `task --list` to see all available tasks.
|
||||
|
||||
#### Before pushing
|
||||
|
||||
```sh
|
||||
scripts/check.sh --fix
|
||||
task check:fix
|
||||
```
|
||||
|
||||
#### Update HTTP API TS bindings
|
||||
|
||||
```sh
|
||||
scripts/update-api-types.sh
|
||||
task update-api-types
|
||||
```
|
||||
|
||||
#### Publish new version
|
||||
|
||||
```sh
|
||||
scripts/bump-version.sh patch
|
||||
task release:bump -- patch
|
||||
```
|
||||
|
||||
#### Run E2E tests
|
||||
|
||||
```sh
|
||||
scripts/e2e.sh 8
|
||||
task e2e -- 8
|
||||
```
|
||||
|
||||
And to clean up the logs & database files, run `scripts/clean-up.sh`
|
||||
And to clean up the logs & database files, run `task clean`
|
||||
|
||||
## Projects
|
||||
|
||||
- [Sync server](./sync-server/README.md)
|
||||
|
||||
remove force merge everywhere
|
||||
|
|
|
|||
|
|
@ -1,118 +1,283 @@
|
|||
# Deterministic Tests
|
||||
# Deterministic Testing Framework
|
||||
|
||||
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.
|
||||
A framework for defining and running deterministic tests for VaultLink sync operations. Unlike the fuzz testing approach, these tests execute exact sequences of operations to verify specific conflict resolution scenarios.
|
||||
|
||||
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.
|
||||
## Overview
|
||||
|
||||
## How it works
|
||||
The deterministic testing framework allows you to:
|
||||
|
||||
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.
|
||||
- Define exact sequences of client operations in TypeScript
|
||||
- Control both client and server processes (pause/resume)
|
||||
- Test specific conflict scenarios (write/write, rename/create, etc.)
|
||||
- Verify that the system resolves conflicts consistently
|
||||
|
||||
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.
|
||||
## Architecture
|
||||
|
||||
The runner executes two sequential phases: regular tests on the shared server, then pause-server tests on dedicated servers. Within each phase tests run in parallel up to a concurrency limit.
|
||||
|
||||
## Step types
|
||||
|
||||
Clients always start with syncing disabled.
|
||||
|
||||
**File operations** (per-client, fire-and-forget — sync is enqueued but not awaited):
|
||||
|
||||
- `create`, `update`, `rename`, `delete`
|
||||
- `rename-next-write` — arm a deferred rename that fires the next time the given path is written. Lets a test race a user-rename against an in-flight remote create that's about to land at the same path.
|
||||
|
||||
**Sync control:**
|
||||
|
||||
- `sync` — wait for a specific client or all clients to finish pending operations
|
||||
- `barrier` — retry until all clients converge to identical file state (60s timeout)
|
||||
- `enable-sync` / `disable-sync` — simulate going online/offline
|
||||
- `reset` — reset a client's tracked sync state (keeps disk files); equivalent to a forced re-handshake on next enable
|
||||
- `sleep` — wall-clock pause; use sparingly, prefer `barrier` / `sync`
|
||||
|
||||
**WebSocket control** (per-client):
|
||||
|
||||
- `pause-websocket` / `resume-websocket` — buffer/release WebSocket messages for a specific client
|
||||
|
||||
**Server control:**
|
||||
|
||||
- `pause-server` / `resume-server` — SIGSTOP/SIGCONT the server process
|
||||
- `resume-server-until-history-then-pause` — resume the server, wait until a specific client observes a matching history entry (`CREATE`/`UPDATE`/`DELETE` for a path), then re-pause. Used to land exactly one operation across the wire.
|
||||
|
||||
**Fault injection** (per-client):
|
||||
|
||||
- `drop-next-create-response` — arm a one-shot interceptor that lets the next `POST /documents` reach the server (commit happens) but throws `SyncResetError` before the client sees the response, simulating connection loss after server commit.
|
||||
- `wait-for-dropped-create-response` — wait until the armed drop has fired.
|
||||
|
||||
**Assertions:**
|
||||
|
||||
- `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
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Test Definition (TypeScript) │
|
||||
│ - Declare steps sequentially │
|
||||
│ - Specify client operations │
|
||||
│ - Add assertions │
|
||||
└──────────────┬──────────────────────────────┘
|
||||
│
|
||||
v
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Test Runner │
|
||||
│ - Initializes clients │
|
||||
│ - Executes steps in order │
|
||||
│ - Manages server lifecycle │
|
||||
└──────────────┬──────────────────────────────┘
|
||||
│
|
||||
├─→ DeterministicAgent (per client)
|
||||
│ └─→ SyncClient
|
||||
│
|
||||
└─→ ServerControl
|
||||
└─→ sync_server process
|
||||
```
|
||||
|
||||
## Adding a test
|
||||
## Test Definition Format
|
||||
|
||||
1. Create `src/tests/my-scenario.test.ts`:
|
||||
Tests are defined using the `TestDefinition` interface:
|
||||
|
||||
```typescript
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
interface TestDefinition {
|
||||
name: string;
|
||||
description?: string;
|
||||
clients: number;
|
||||
steps: TestStep[];
|
||||
}
|
||||
```
|
||||
|
||||
export const myScenarioTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 creates A.md offline. After syncing, both clients should have the file.",
|
||||
### Available Steps
|
||||
|
||||
#### File Operations
|
||||
|
||||
```typescript
|
||||
{ type: "create", client: 0, path: "file.md", content: "hello" }
|
||||
{ type: "update", client: 0, path: "file.md", content: "world" }
|
||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }
|
||||
{ type: "delete", client: 0, path: "file.md" }
|
||||
```
|
||||
|
||||
#### Sync Control
|
||||
|
||||
```typescript
|
||||
{ type: "sync", client: 0 } // Wait for specific client
|
||||
{ type: "sync" } // Wait for all clients
|
||||
{ type: "barrier" } // Wait for all pending ops
|
||||
{ type: "disable-sync", client: 0 }
|
||||
{ type: "enable-sync", client: 0 }
|
||||
```
|
||||
|
||||
#### Server Control
|
||||
|
||||
```typescript
|
||||
{ type: "pause-server" } // Pause server process
|
||||
{ type: "resume-server" } // Resume server process
|
||||
{ type: "wait", duration: 500 } // Wait N milliseconds
|
||||
```
|
||||
|
||||
#### Assertions
|
||||
|
||||
```typescript
|
||||
{ type: "assert-content", client: 0, path: "file.md", content: "hello" }
|
||||
{ type: "assert-exists", client: 0, path: "file.md" }
|
||||
{ type: "assert-not-exists", client: 0, path: "file.md" }
|
||||
{ type: "assert-consistent" } // All clients have same state
|
||||
```
|
||||
|
||||
## Example Tests
|
||||
|
||||
### Write/Write Conflict
|
||||
|
||||
Two clients create the same file with different content:
|
||||
|
||||
```typescript
|
||||
export const writeWriteConflictTest: TestDefinition = {
|
||||
name: "Write/Write Conflict",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
{ type: "create", client: 0, path: "A.md", content: "hello" },
|
||||
{ type: "create", client: 1, path: "A.md", content: "world" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s) => {
|
||||
s.assertFileCount(1).assertContent("A.md", "hello");
|
||||
}
|
||||
}
|
||||
{ type: "wait", duration: 500 },
|
||||
{ type: "barrier" },
|
||||
{ type: "assert-consistent" }
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
The `verify` callback receives an `AssertableState` object with chainable assertion methods:
|
||||
### Rename/Create Conflict
|
||||
|
||||
Client 1 renames A→B while Client 0 creates B:
|
||||
|
||||
```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 in file
|
||||
s.assertContainsAny("path", "a", "b"); // at least one substring present
|
||||
s.assertAnyFileContains("text"); // substring present in some file
|
||||
s.assertNoFileContains("text"); // substring absent from every file
|
||||
s.assertSubstringCount("path", "x", 3); // substring appears exactly N times
|
||||
s.assertContentInAtMostOneFile("text"); // no duplicate content
|
||||
s.ifFileExists("path", (s) => { /* … */ }); // conditional block
|
||||
s.getContent("path"); // raw content (or "" if missing)
|
||||
```
|
||||
|
||||
2. Register it in `src/test-registry.ts`:
|
||||
|
||||
```typescript
|
||||
import { myScenarioTest } from "./tests/my-scenario.test";
|
||||
|
||||
const TESTS = {
|
||||
// ...
|
||||
"my-scenario": myScenarioTest
|
||||
export const renameCreateConflictTest: TestDefinition = {
|
||||
name: "Rename-Create Conflict",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "A.md", content: "hi" },
|
||||
{ type: "sync", client: 0 },
|
||||
{ type: "sync", client: 1 },
|
||||
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
|
||||
{ type: "sync", client: 1 },
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "create", client: 0, path: "B.md", content: "hi" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
{ type: "wait", duration: 500 },
|
||||
{ type: "barrier" },
|
||||
{ type: "assert-consistent" }
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Build and Run
|
||||
|
||||
```bash
|
||||
# From frontend/deterministic-tests
|
||||
npm run test
|
||||
```
|
||||
|
||||
### Run Specific Test
|
||||
|
||||
```bash
|
||||
npm run test -- --test write-write-conflict
|
||||
```
|
||||
|
||||
### List Available Tests
|
||||
|
||||
```bash
|
||||
npm run test -- --list
|
||||
```
|
||||
|
||||
### Advanced Options
|
||||
|
||||
```bash
|
||||
# Use custom server binary
|
||||
npm run test -- --server /path/to/sync_server
|
||||
|
||||
# Use custom config
|
||||
npm run test -- --config /path/to/config.yml
|
||||
|
||||
# Don't manage server (assume it's already running)
|
||||
npm run test -- --no-manage-server
|
||||
```
|
||||
|
||||
## Creating New Tests
|
||||
|
||||
1. Create a new test file in `src/tests/`:
|
||||
|
||||
```typescript
|
||||
// my-test.test.ts
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const myTest: TestDefinition = {
|
||||
name: "My Test",
|
||||
description: "What this test verifies",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Your test steps here
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
2. Register the test in `src/cli.ts`:
|
||||
|
||||
```typescript
|
||||
import { myTest } from "./tests/my-test.test";
|
||||
|
||||
const TESTS: Record<string, TestDefinition> = {
|
||||
// ... existing tests
|
||||
"my-test": myTest
|
||||
};
|
||||
```
|
||||
|
||||
3. Build and run:
|
||||
|
||||
```bash
|
||||
npm run test -- --test my-test
|
||||
```
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Synchronization Points
|
||||
|
||||
Use explicit sync barriers to ensure operations complete:
|
||||
|
||||
- `{ type: "sync", client: 0 }` - Wait for client 0 to finish pending ops
|
||||
- `{ type: "barrier" }` - Wait for all clients to finish
|
||||
- `{ type: "wait", duration: 500 }` - Wait for propagation
|
||||
|
||||
### Offline Testing
|
||||
|
||||
Disable sync to simulate offline edits:
|
||||
|
||||
```typescript
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "create", client: 0, path: "file.md", content: "offline edit" },
|
||||
{ type: "enable-sync", client: 0 }, // Sync when back online
|
||||
```
|
||||
|
||||
### Server Control
|
||||
|
||||
Pause the server to test reconnection:
|
||||
|
||||
```typescript
|
||||
{ type: "pause-server" },
|
||||
{ type: "create", client: 0, path: "file.md", content: "while paused" },
|
||||
{ type: "resume-server" },
|
||||
{ type: "barrier" }
|
||||
```
|
||||
|
||||
### Assertions
|
||||
|
||||
Always end tests with consistency checks:
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: "assert-consistent";
|
||||
} // Verify all clients converged
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Server Won't Start
|
||||
|
||||
- Ensure server is built: `cd sync-server && cargo build`
|
||||
- Check config file exists: `sync-server/config-e2e.yml`
|
||||
- Verify port 3000 is available
|
||||
|
||||
### Test Hangs
|
||||
|
||||
- Increase wait durations for slow systems
|
||||
- Add more `{ type: "barrier" }` steps
|
||||
- Check server logs for errors
|
||||
|
||||
### Assertion Failures
|
||||
|
||||
- Add `{ type: "wait", duration: 1000 }` before assertions
|
||||
- Check if conflict resolution is working as expected
|
||||
- Review test steps for logic errors
|
||||
|
||||
## Comparison to Fuzz Tests
|
||||
|
||||
| Aspect | Fuzz Tests | Deterministic Tests |
|
||||
| --------------- | --------------- | ------------------------- |
|
||||
| Operations | Random | Explicit sequence |
|
||||
| Reproducibility | Difficult | Perfect |
|
||||
| Coverage | Broad | Targeted |
|
||||
| Debugging | Hard | Easy |
|
||||
| Use Case | Find edge cases | Verify specific scenarios |
|
||||
|
||||
Use both approaches:
|
||||
|
||||
- Fuzz tests for discovering unexpected issues
|
||||
- Deterministic tests for verifying specific fixes
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@
|
|||
"test": "npm run build && node dist/cli.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"commander": "^14.0.2",
|
||||
"@types/node": "^25.0.2",
|
||||
"sync-client": "file:../sync-client",
|
||||
"ts-loader": "^9.5.4",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
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 { parseArgs } from "./parse-args";
|
||||
import { runWithConcurrency } from "./run-with-concurrency";
|
||||
import { TOKEN, SERVER_BINARY_PATH, CONFIG_PATH } from "./consts";
|
||||
import type { TestDefinition } from "./test-definition";
|
||||
import { writeWriteConflictTest } from "./tests/write-write-conflict.test";
|
||||
import { renameCreateConflictTest } from "./tests/rename-create-conflict.test";
|
||||
import { TOKEN, REMOTE_URI, 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";
|
||||
|
|
@ -24,107 +23,21 @@ process.on("uncaughtException", (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" ||
|
||||
step.type === "resume-server-until-history-then-pause"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk up from the CLI binary's location until we find a directory
|
||||
* containing `sync-server/` and `frontend/`.
|
||||
*/
|
||||
function findProjectRoot(): string {
|
||||
let dir = path.dirname(__filename);
|
||||
const root = path.parse(dir).root;
|
||||
while (dir !== root) {
|
||||
if (
|
||||
fs.existsSync(path.join(dir, "sync-server")) &&
|
||||
fs.existsSync(path.join(dir, "frontend"))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
throw new Error(
|
||||
`Could not locate project root (no ancestor of ${__filename} contains both 'sync-server' and 'frontend')`
|
||||
);
|
||||
}
|
||||
|
||||
interface NamedTestResult {
|
||||
name: string;
|
||||
result: TestResult;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
const TESTS: Partial<Record<string, TestDefinition>> = {
|
||||
// "write-write-conflict": writeWriteConflictTest,
|
||||
"rename-create-conflict": renameCreateConflictTest
|
||||
};
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const projectRoot = findProjectRoot();
|
||||
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}`);
|
||||
|
|
@ -137,103 +50,52 @@ async function main(): Promise<void> {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
const { filter, concurrency } = parseArgs(process.argv);
|
||||
|
||||
const testsToRun: [string, TestDefinition][] = [];
|
||||
for (const [key, test] of Object.entries(TESTS)) {
|
||||
const testsToRun: TestDefinition[] = [];
|
||||
for (const test of Object.values(TESTS)) {
|
||||
if (test) {
|
||||
if (
|
||||
filter !== undefined &&
|
||||
filter.length > 0 &&
|
||||
!key.includes(filter)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
testsToRun.push([key, test]);
|
||||
testsToRun.push(test);
|
||||
}
|
||||
}
|
||||
|
||||
if (testsToRun.length === 0) {
|
||||
logger.error(
|
||||
filter !== undefined && filter.length > 0
|
||||
? `No tests matched filter "${filter}"`
|
||||
: "No tests found"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
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}`);
|
||||
logger.info(`Tests to run: ${testsToRun.length}`);
|
||||
|
||||
const allResults: NamedTestResult[] = [];
|
||||
const serverControl = new ServerControl(serverPath, configPath, logger);
|
||||
|
||||
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);
|
||||
let allPassed = true;
|
||||
|
||||
try {
|
||||
await sharedServer.start();
|
||||
try {
|
||||
await serverControl.start();
|
||||
await serverControl.waitForReady();
|
||||
|
||||
const results = await runWithConcurrency(
|
||||
regularTests,
|
||||
concurrency,
|
||||
async ([name, test]) =>
|
||||
runSharedServerTest(name, test, sharedServer)
|
||||
for (const test of testsToRun) {
|
||||
const runner = new TestRunner(
|
||||
serverControl,
|
||||
logger,
|
||||
TOKEN,
|
||||
REMOTE_URI
|
||||
);
|
||||
const result = await runner.runTest(test);
|
||||
|
||||
allResults.push(...results);
|
||||
} finally {
|
||||
try {
|
||||
await sharedServer.stop();
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Error stopping shared server: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
if (!result.success) {
|
||||
allPassed = false;
|
||||
logger.error(`✗ FAILED: ${test.name}`);
|
||||
logger.error(`Error: ${result.error}`);
|
||||
} else {
|
||||
logger.info(`✓ PASSED: ${test.name} (${result.duration}ms)`);
|
||||
}
|
||||
serverManager.untrack(sharedServer);
|
||||
}
|
||||
} finally {
|
||||
await serverControl.stop();
|
||||
}
|
||||
|
||||
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!");
|
||||
if (allPassed) {
|
||||
logger.info("✓ All tests passed!");
|
||||
process.exit(0);
|
||||
} else {
|
||||
logger.info("✗ Some tests failed");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,5 @@
|
|||
export const TOKEN = "test-token-change-me";
|
||||
export const SERVER_BINARY_PATH = "sync-server/target/release/sync_server";
|
||||
export const TOKEN = "test-token-change-me ";
|
||||
export const REMOTE_URI = "http://localhost:3010";
|
||||
export const PING_URL = `${REMOTE_URI}/vaults/test/ping`;
|
||||
export const SERVER_BINARY_PATH = "sync-server/target/debug/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;
|
||||
|
||||
export const SERVER_READY_POLL_INTERVAL_MS = 100;
|
||||
export const SERVER_READY_MAX_ATTEMPTS = 50;
|
||||
export const SERVER_START_MAX_ATTEMPTS = 5;
|
||||
|
|
|
|||
|
|
@ -1,27 +1,6 @@
|
|||
import type {
|
||||
HistoryEntry,
|
||||
StoredDatabase,
|
||||
SyncSettings,
|
||||
RelativePath,
|
||||
TextWithCursors
|
||||
} from "sync-client";
|
||||
import {
|
||||
SyncClient,
|
||||
SyncResetError,
|
||||
debugging,
|
||||
LogLevel,
|
||||
utils
|
||||
} from "sync-client";
|
||||
import type { StoredDatabase, SyncSettings, RelativePath } from "sync-client";
|
||||
import { SyncClient, debugging } 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;
|
||||
|
|
@ -31,22 +10,7 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
|||
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;
|
||||
private isSyncEnabled = true;
|
||||
|
||||
public constructor(
|
||||
clientId: number,
|
||||
|
|
@ -56,11 +20,13 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
|||
super();
|
||||
this.clientId = clientId;
|
||||
this.logger = logger;
|
||||
this.data.settings = { ...initialSettings };
|
||||
this.data.settings = initialSettings;
|
||||
this.isSyncEnabled = initialSettings.isSyncEnabled !== false;
|
||||
}
|
||||
|
||||
public async init(
|
||||
fetchImplementation: typeof globalThis.fetch
|
||||
fetchImplementation: typeof globalThis.fetch,
|
||||
webSocketImplementation: typeof globalThis.WebSocket
|
||||
): Promise<void> {
|
||||
this.client = await SyncClient.create({
|
||||
fs: this,
|
||||
|
|
@ -68,27 +34,11 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
|||
load: async () => this.data,
|
||||
save: async (data) => void (this.data = data)
|
||||
},
|
||||
fetch: this.wrapFetch(fetchImplementation),
|
||||
webSocket: this.wsFactory.constructorFn
|
||||
fetch: fetchImplementation,
|
||||
webSocket: webSocketImplementation
|
||||
});
|
||||
|
||||
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} INFO: ${line.message}`);
|
||||
break;
|
||||
case LogLevel.DEBUG:
|
||||
this.logger(`${prefix} DEBUG: ${line.message}`);
|
||||
break;
|
||||
}
|
||||
});
|
||||
debugging.logToConsole(this.client.logger, { useColors: true });
|
||||
|
||||
await this.client.start();
|
||||
|
||||
|
|
@ -97,140 +47,117 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
|||
connectionCheck.isSuccessful,
|
||||
`Client ${this.clientId} connection check failed`
|
||||
);
|
||||
}
|
||||
|
||||
public async createFile(path: string, content: string): Promise<void> {
|
||||
this.log(`Creating file ${path} with content: ${content}`);
|
||||
if (this.files.has(path)) {
|
||||
throw new Error(`File ${path} already exists`);
|
||||
}
|
||||
const contentBytes = new TextEncoder().encode(content);
|
||||
this.files.set(path, contentBytes);
|
||||
|
||||
if (this.isSyncEnabled) {
|
||||
await this.waitForWebSocket();
|
||||
await this.client.syncLocallyCreatedFile(path);
|
||||
}
|
||||
}
|
||||
|
||||
public pauseWebSocket(): void {
|
||||
this.log("Pausing WebSocket message delivery");
|
||||
this.wsFactory.pause();
|
||||
}
|
||||
public async updateFile(path: string, content: string): Promise<void> {
|
||||
this.log(`Updating file ${path} with content: ${content}`);
|
||||
const contentBytes = new TextEncoder().encode(content);
|
||||
this.files.set(path, contentBytes);
|
||||
|
||||
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;
|
||||
if (this.isSyncEnabled) {
|
||||
await this.client.syncLocallyUpdatedFile({ relativePath: path });
|
||||
}
|
||||
}
|
||||
|
||||
await withTimeout(
|
||||
new Promise<void>((resolve) => {
|
||||
const unsubscribe = this.client.onSyncHistoryUpdated.add(() => {
|
||||
const entry = this.client
|
||||
.getHistoryEntries()
|
||||
.find(matches);
|
||||
if (entry === undefined) {
|
||||
return;
|
||||
}
|
||||
public async renameFile(oldPath: string, newPath: string): Promise<void> {
|
||||
this.log(`Renaming file ${oldPath} to ${newPath}`);
|
||||
const file = this.files.get(oldPath);
|
||||
if (!file) {
|
||||
throw new Error(`File ${oldPath} does not exist`);
|
||||
}
|
||||
this.files.set(newPath, file);
|
||||
if (oldPath !== newPath) {
|
||||
this.files.delete(oldPath);
|
||||
}
|
||||
if (this.isSyncEnabled) {
|
||||
await this.client.syncLocallyUpdatedFile({
|
||||
oldPath,
|
||||
relativePath: newPath
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
unsubscribe();
|
||||
onMatch?.(entry);
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
WAIT_TIMEOUT_MS,
|
||||
`Client ${this.clientId} timed out waiting for history entry`
|
||||
);
|
||||
public async deleteFile(path: string): Promise<void> {
|
||||
this.log(`Deleting file ${path}`);
|
||||
this.files.delete(path);
|
||||
if (this.isSyncEnabled) {
|
||||
await this.client.syncLocallyDeletedFile(path);
|
||||
}
|
||||
}
|
||||
|
||||
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")}`
|
||||
);
|
||||
}
|
||||
await this.client.waitUntilFinished();
|
||||
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;
|
||||
}
|
||||
}
|
||||
await this.client.setSetting("isSyncEnabled", false);
|
||||
}
|
||||
|
||||
public async enableSync(): Promise<void> {
|
||||
this.log("Enabling sync");
|
||||
await this.client.setSetting("isSyncEnabled", true);
|
||||
this.isSyncEnabled = true;
|
||||
await this.waitForWebSocket();
|
||||
await this.client.setSetting("isSyncEnabled", true);
|
||||
}
|
||||
|
||||
public async assertContent(
|
||||
path: string,
|
||||
expectedContent: string
|
||||
): Promise<void> {
|
||||
this.log(`Asserting content of ${path} equals "${expectedContent}"`);
|
||||
const exists = await this.exists(path);
|
||||
assert(
|
||||
exists,
|
||||
`File ${path} does not exist on client ${this.clientId}`
|
||||
);
|
||||
|
||||
const actualBytes = await this.read(path);
|
||||
const actualContent = new TextDecoder().decode(actualBytes);
|
||||
assert(
|
||||
actualContent === expectedContent,
|
||||
`Content mismatch on client ${this.clientId} for ${path}:\nExpected: "${expectedContent}"\nActual: "${actualContent}"`
|
||||
);
|
||||
this.log(`✓ Content assertion passed for ${path}`);
|
||||
}
|
||||
|
||||
public async assertExists(path: string): Promise<void> {
|
||||
this.log(`Asserting ${path} exists`);
|
||||
const exists = await this.exists(path);
|
||||
assert(
|
||||
exists,
|
||||
`File ${path} does not exist on client ${this.clientId}`
|
||||
);
|
||||
this.log(`✓ File ${path} exists`);
|
||||
}
|
||||
|
||||
public async assertNotExists(path: string): Promise<void> {
|
||||
this.log(`Asserting ${path} does not exist`);
|
||||
const exists = await this.exists(path);
|
||||
assert(
|
||||
!exists,
|
||||
`File ${path} exists on client ${this.clientId} but should not`
|
||||
);
|
||||
this.log(`✓ File ${path} does not exist`);
|
||||
}
|
||||
|
||||
public async getFiles(): Promise<RelativePath[]> {
|
||||
return this.listFilesRecursively();
|
||||
}
|
||||
|
||||
public async getFileContent(path: string): Promise<string> {
|
||||
|
|
@ -238,246 +165,14 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
|||
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}`);
|
||||
}
|
||||
}
|
||||
// Surface any background sync errors that arrived after the last
|
||||
// waitForSync (e.g. between the final assert-consistent and here).
|
||||
// Without this, regressions that fault the engine during the very
|
||||
// last step of a test would be silently swallowed.
|
||||
const pendingErrors = this.syncErrors.splice(0);
|
||||
await this.client.waitUntilFinished();
|
||||
await this.client.destroy();
|
||||
this.log("Cleanup complete");
|
||||
if (pendingErrors.length > 0) {
|
||||
throw new Error(
|
||||
`Client ${this.clientId} had ${pendingErrors.length} background sync error(s) during cleanup:\n${pendingErrors.map((e) => e.message).join("\n")}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
});
|
||||
}
|
||||
// The rename consumed `path`. Skip the post-update enqueue below
|
||||
// — it would send a syncLocallyUpdatedFile for a path that no
|
||||
// longer exists.
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isSyncEnabled) {
|
||||
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;
|
||||
try {
|
||||
await response.body?.cancel();
|
||||
} catch {
|
||||
// Best-effort — body may already be consumed/closed.
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,245 +0,0 @@
|
|||
/**
|
||||
* 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 {
|
||||
// Drain buffered messages BEFORE flipping `paused` to false.
|
||||
// If `externalOnMessage` is async (its return type is `unknown`),
|
||||
// dispatch yields control between buffered messages, and a fresh
|
||||
// live `ws.onmessage` event firing during that yield would jump
|
||||
// ahead of unprocessed buffered messages — silently reordering
|
||||
// events relative to the wire. Keeping `paused = true` during the
|
||||
// drain forces the live handler to keep buffering, so we splice
|
||||
// those late arrivals onto the tail and dispatch them in order.
|
||||
while (this.bufferedMessages.length > 0) {
|
||||
const messages = this.bufferedMessages.splice(0);
|
||||
for (const msg of messages) {
|
||||
this.externalOnMessage?.(msg);
|
||||
}
|
||||
}
|
||||
this.paused = false;
|
||||
}
|
||||
|
||||
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 {
|
||||
// Only the `.onmessage` setter routes through the pause buffer.
|
||||
// If sync-client ever attaches "message" listeners via
|
||||
// addEventListener instead, those messages would bypass pause/resume
|
||||
// and deterministic tests would silently lose their fault injection.
|
||||
if (args[0] === "message") {
|
||||
throw new Error(
|
||||
"ManagedWebSocket: addEventListener('message') bypasses the " +
|
||||
"pause buffer. Use the .onmessage setter instead, or " +
|
||||
"extend ManagedWebSocket to route message listeners."
|
||||
);
|
||||
}
|
||||
this.ws.addEventListener(...args);
|
||||
}
|
||||
|
||||
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 {
|
||||
// Append-only: closed sockets stay tracked. Bounded per test (one
|
||||
// factory per agent, each test discards its agents on cleanup), so
|
||||
// not a real leak — but iterating over closed instances on
|
||||
// pause/resume is a deliberate no-op since their `.onmessage` is
|
||||
// already detached.
|
||||
private readonly instances: ManagedWebSocket[] = [];
|
||||
// Sticky pause state: applied to current instances on `pause()` AND
|
||||
// to any new instance created later (e.g. WS reconnect after a
|
||||
// `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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import * as os from "node:os";
|
||||
import { Command, InvalidArgumentError } from "commander";
|
||||
|
||||
export interface CliArgs {
|
||||
filter: string | undefined;
|
||||
concurrency: number;
|
||||
}
|
||||
|
||||
function parsePositiveInt(value: string): number {
|
||||
const n = parseInt(value, 10);
|
||||
if (isNaN(n) || n <= 0) {
|
||||
throw new InvalidArgumentError("must be a positive integer");
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
export function parseArgs(argv: string[]): CliArgs {
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name("deterministic-tests")
|
||||
.description("Scripted multi-client sync tests against a real server")
|
||||
.option(
|
||||
"-f, --filter <substring>",
|
||||
"Run only tests whose name contains this substring"
|
||||
)
|
||||
.option(
|
||||
"-j, --concurrency <number>",
|
||||
"Number of tests to run in parallel",
|
||||
parsePositiveInt,
|
||||
os.cpus().length
|
||||
);
|
||||
|
||||
program.parse(argv);
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
|
||||
const opts = program.opts();
|
||||
const filter = opts.filter as string | undefined;
|
||||
const concurrency = opts.concurrency as number;
|
||||
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
|
||||
|
||||
return { filter, concurrency };
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
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}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,89 +1,32 @@
|
|||
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,
|
||||
SERVER_READY_POLL_INTERVAL_MS,
|
||||
SERVER_READY_MAX_ATTEMPTS,
|
||||
SERVER_START_MAX_ATTEMPTS
|
||||
} from "./consts";
|
||||
import { PING_URL } from "./consts";
|
||||
|
||||
export class ServerControl {
|
||||
private process: ChildProcess | null = null;
|
||||
private readonly serverPath: string;
|
||||
private readonly baseConfigPath: string;
|
||||
private readonly configPath: 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.configPath = 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");
|
||||
}
|
||||
|
||||
// Retry on bind failure: findFreePort closes its probe before we
|
||||
// spawn, so under heavy parallelism another process can grab the
|
||||
// same port. Each attempt picks a fresh port.
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= SERVER_START_MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
await this.startOnce();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
this.logger.warn(
|
||||
`Server start attempt ${attempt}/${SERVER_START_MAX_ATTEMPTS} failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
// startOnce already cleaned up its child + tempdir on failure.
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Server failed to start after ${SERVER_START_MAX_ATTEMPTS} attempts: ${lastError instanceof Error ? lastError.message : String(lastError)}`,
|
||||
{ cause: lastError instanceof Error ? lastError : undefined }
|
||||
);
|
||||
}
|
||||
|
||||
private async startOnce(): Promise<void> {
|
||||
const reservation = await findFreePort();
|
||||
this._port = reservation.port;
|
||||
const tmpBase = 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})`
|
||||
`Starting server: ${this.serverPath} ${this.configPath}`
|
||||
);
|
||||
|
||||
// Release the port reservation right before spawning to minimize
|
||||
// the TOCTOU window between port discovery and server binding.
|
||||
reservation.release();
|
||||
let startupError: string | null = null;
|
||||
|
||||
this.process = spawn(this.serverPath, [tempConfigPath], {
|
||||
this.process = spawn(this.serverPath, [this.configPath], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false
|
||||
});
|
||||
|
|
@ -93,53 +36,35 @@ export class ServerControl {
|
|||
});
|
||||
|
||||
this.process.stderr?.on("data", (data: Buffer) => {
|
||||
this.logger.info(`[SERVER] ${data.toString().trim()}`);
|
||||
const msg = data.toString().trim();
|
||||
this.logger.info(`[SERVER] ${msg}`);
|
||||
if (msg.includes("Failed to") || msg.includes("Error")) {
|
||||
startupError = msg;
|
||||
}
|
||||
});
|
||||
|
||||
this.process.on("error", (err) => {
|
||||
this.logger.error(`[SERVER] Process error: ${err.message}`);
|
||||
startupError = err.message;
|
||||
});
|
||||
|
||||
const currentProcess = this.process;
|
||||
currentProcess.on("exit", (code, signal) => {
|
||||
this.process.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;
|
||||
}
|
||||
this.process = null;
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
await sleep(100);
|
||||
this.checkProcessAlive(startupError, "startup");
|
||||
await this.waitForReady();
|
||||
this.checkProcessAlive(startupError, "after startup");
|
||||
}
|
||||
|
||||
public async waitForReady(
|
||||
maxAttempts: number = SERVER_READY_MAX_ATTEMPTS
|
||||
): Promise<void> {
|
||||
const pingUrl = `${this.remoteUri}/vaults/test/ping`;
|
||||
public async waitForReady(maxAttempts = 30): Promise<void> {
|
||||
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);
|
||||
const response = await fetch(PING_URL);
|
||||
if (response.ok) {
|
||||
this.logger.info("[SERVER] Ready");
|
||||
return;
|
||||
|
|
@ -147,7 +72,7 @@ export class ServerControl {
|
|||
} catch {
|
||||
// Server not ready yet, continue polling
|
||||
}
|
||||
await sleep(SERVER_READY_POLL_INTERVAL_MS);
|
||||
await sleep(100);
|
||||
}
|
||||
throw new Error("Server failed to start within timeout");
|
||||
}
|
||||
|
|
@ -156,141 +81,64 @@ export class ServerControl {
|
|||
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)}`
|
||||
);
|
||||
}
|
||||
process.kill(this.process.pid, "SIGSTOP");
|
||||
}
|
||||
|
||||
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)}`
|
||||
);
|
||||
}
|
||||
process.kill(this.process.pid, "SIGCONT");
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
const proc = this.process;
|
||||
if (proc?.pid === undefined) {
|
||||
this.cleanupTempDir();
|
||||
if (this.process?.pid === undefined) {
|
||||
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...");
|
||||
const { pid } = this.process;
|
||||
|
||||
// Set up a promise that resolves when the process actually exits.
|
||||
const exitPromise = new Promise<void>((resolve) => {
|
||||
if (proc.exitCode !== null) {
|
||||
return new Promise((resolve) => {
|
||||
if (this.process === null) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
proc.on("exit", () => {
|
||||
|
||||
this.process.on("exit", () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
process.kill(pid, "SIGTERM");
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.process?.pid !== undefined) {
|
||||
process.kill(this.process.pid, "SIGKILL");
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
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 {
|
||||
const proc = this.process;
|
||||
return (
|
||||
proc !== null &&
|
||||
proc.pid !== undefined &&
|
||||
proc.exitCode === null &&
|
||||
proc.signalCode === null
|
||||
);
|
||||
return this.process?.pid !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously SIGCONT-then-SIGKILL the child process. Safe to call
|
||||
* from a `process.on("exit", ...)` handler, where async work cannot
|
||||
* run. Used as a last-resort cleanup so a SIGSTOP'd server doesn't
|
||||
* outlive the test runner and wedge the next CI invocation.
|
||||
*/
|
||||
public forceKillSync(): void {
|
||||
private checkProcessAlive(
|
||||
startupError: string | null,
|
||||
phase: string
|
||||
): void {
|
||||
const proc = this.process;
|
||||
if (proc?.pid === undefined) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
process.kill(proc.pid, "SIGCONT");
|
||||
} catch {
|
||||
// Process may already be gone or never paused.
|
||||
}
|
||||
try {
|
||||
process.kill(proc.pid, "SIGKILL");
|
||||
} catch {
|
||||
// Process already gone.
|
||||
}
|
||||
}
|
||||
|
||||
private writeConfigFile(destPath: string, dbDir: string): void {
|
||||
// Assumes config-e2e.yml has exactly one 2-space-indented `port:` and
|
||||
// one `databases_directory_path:` (under `server:` and `database:`
|
||||
// respectively)
|
||||
const baseConfig = fs.readFileSync(this.baseConfigPath, "utf-8");
|
||||
const config = baseConfig
|
||||
.replace(/^\s*port:\s*\d+/m, ` port: ${this._port}`)
|
||||
.replace(
|
||||
/^\s*databases_directory_path:\s*.+/m,
|
||||
` databases_directory_path: ${dbDir}`
|
||||
if (proc === null) {
|
||||
throw new Error(
|
||||
`Server process died during ${phase}: ${startupError ?? "unknown error"}`
|
||||
);
|
||||
}
|
||||
if (proc.exitCode !== null) {
|
||||
throw new Error(
|
||||
`Server process exited during ${phase}: ${startupError ?? "unknown error"}`
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,71 +0,0 @@
|
|||
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));
|
||||
});
|
||||
|
||||
// Last-resort synchronous cleanup. Runs even when the process is
|
||||
// exiting via process.exit() from unhandledRejection /
|
||||
// uncaughtException — paths where async stopAll() cannot complete.
|
||||
// SIGSTOP'd servers MUST receive SIGCONT before SIGKILL or the
|
||||
// kernel keeps them as zombies holding the test's tmpdir, and the
|
||||
// next CI run can't reuse the port.
|
||||
process.on("exit", () => {
|
||||
for (const server of this.activeServers) {
|
||||
server.forceKillSync();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +1,25 @@
|
|||
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 };
|
||||
| { type: "assert-content"; client: number; path: string; content: string }
|
||||
| { type: "assert-exists"; client: number; path: string }
|
||||
| { type: "assert-not-exists"; client: number; path: string }
|
||||
| { type: "assert-consistent"; verify?: (state: ClientState) => void };
|
||||
|
||||
export interface TestDefinition {
|
||||
name: string;
|
||||
description?: string;
|
||||
clients: number;
|
||||
steps: TestStep[];
|
||||
|
|
@ -45,5 +28,6 @@ export interface TestDefinition {
|
|||
export interface TestResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
duration?: number;
|
||||
stepsFailed?: number;
|
||||
duration: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,245 +0,0 @@
|
|||
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-mergeable.test";
|
||||
import { deleteDuringPendingCreateTest } from "./tests/delete-during-pending-create.test";
|
||||
import { threeClientRenameCreateDeleteTest } from "./tests/three-client-rename-create-delete.test";
|
||||
import { renameToPathOfUnconfirmedDeleteTest } from "./tests/rename-to-path-of-unconfirmed-delete.test";
|
||||
import { offlineEditThenMoveSameContentTest } from "./tests/offline-edit-then-move-same-content.test";
|
||||
import { 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 { updateDoesNotSurviveRemoteDeleteTest } from "./tests/update-does-not-survive-remote-delete.test";
|
||||
import { movePreservesRemoteUpdateTest } from "./tests/move-preserves-remote-update.test";
|
||||
import { recentlyDeletedClearedOnReconnectTest } from "./tests/recently-deleted-cleared-on-reconnect.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/text-pending-create-not-displaced.test";
|
||||
import { binaryPendingCreateNotDisplacedTest } from "./tests/binary-pending-create-not-displaced.test";
|
||||
import { coalesceUpdateRemoteUpdateDataLossTest } from "./tests/coalesce-update-remote-update-data-loss.test";
|
||||
import { coalescedRemoteUpdateWatermarkLossTest } from "./tests/coalesced-remote-update-watermark-loss.test";
|
||||
import { concurrentDeleteDuringRemoteUpdateTest } from "./tests/concurrent-delete-during-remote-update.test";
|
||||
import { concurrentEditExactSamePositionTest } from "./tests/concurrent-edit-exact-same-position.test";
|
||||
import { concurrentRenameAndCreateAtTargetRenameFirstTest } from "./tests/concurrent-rename-and-create-at-target-rename-first.test";
|
||||
import { concurrentRenameAndCreateAtTargetCreateFirstTest } from "./tests/concurrent-rename-and-create-at-target-create-first.test";
|
||||
import { concurrentRenameSameTargetTest } from "./tests/concurrent-rename-same-target.test";
|
||||
import { concurrentUpdateDiffConsistencyTest } from "./tests/concurrent-update-diff-consistency.test";
|
||||
import { userParenthesizedFileNotDeletedTest } from "./tests/user-parenthesized-file-not-deleted.test";
|
||||
import { createDeleteNoopTest } from "./tests/create-delete-noop.test";
|
||||
import { createMergeDeleteTest } from "./tests/create-merge-delete.test";
|
||||
import { moveIdenticalContentAmbiguityTest } from "./tests/move-identical-content-ambiguity.test";
|
||||
import { createUpdateCoalesceServerPauseTest } from "./tests/create-update-coalesce-server-pause.test";
|
||||
import { createDuringReconciliationTest } from "./tests/create-during-reconciliation.test";
|
||||
import { createMergePreservesRenamedUpdateTest } from "./tests/create-merge-preserves-renamed-update.test";
|
||||
import { createRenameCreateSamePathTest } from "./tests/create-rename-create-same-path.test";
|
||||
import { moveChainThreeFilesTest } from "./tests/move-chain-three-files.test";
|
||||
import { deleteByOtherClientThenRecreateTest } from "./tests/delete-by-other-client-then-recreate.test";
|
||||
import { onlineDeleteRecreateRapidCycleTest } from "./tests/online-delete-recreate-rapid-cycle.test";
|
||||
import { onlineEditVsDeleteConvergenceTest } from "./tests/online-edit-vs-delete-convergence.test";
|
||||
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,
|
||||
"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-does-not-survive-remote-delete": updateDoesNotSurviveRemoteDeleteTest,
|
||||
"move-preserves-remote-update": movePreservesRemoteUpdateTest,
|
||||
"recently-deleted-cleared-on-reconnect":
|
||||
recentlyDeletedClearedOnReconnectTest,
|
||||
"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
|
||||
};
|
||||
|
|
@ -1,17 +1,13 @@
|
|||
import type { TestDefinition, TestResult, TestStep } from "./test-definition";
|
||||
import type {
|
||||
TestDefinition,
|
||||
TestResult,
|
||||
TestStep,
|
||||
ClientState
|
||||
} 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 {
|
||||
|
|
@ -33,12 +29,9 @@ export class TestRunner {
|
|||
this.remoteUri = remoteUri;
|
||||
}
|
||||
|
||||
public async runTest(
|
||||
name: string,
|
||||
test: TestDefinition
|
||||
): Promise<TestResult> {
|
||||
public async runTest(test: TestDefinition): Promise<TestResult> {
|
||||
const startTime = Date.now();
|
||||
this.logger.info(`Running test: ${name}`);
|
||||
this.logger.info(`Running test: ${test.name}`);
|
||||
if (test.description !== undefined && test.description !== "") {
|
||||
this.logger.info(`Description: ${test.description}`);
|
||||
}
|
||||
|
|
@ -46,13 +39,10 @@ export class TestRunner {
|
|||
this.logger.info(`Steps: ${test.steps.length}`);
|
||||
|
||||
try {
|
||||
assert(
|
||||
this.serverControl.isRunning(),
|
||||
"Server is not running before test start"
|
||||
);
|
||||
|
||||
// Initialize agents
|
||||
await this.initializeAgents(test.clients);
|
||||
|
||||
// Execute steps
|
||||
for (let i = 0; i < test.steps.length; i++) {
|
||||
const step = test.steps[i];
|
||||
this.logger.info(
|
||||
|
|
@ -64,7 +54,7 @@ export class TestRunner {
|
|||
await this.cleanup();
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
this.logger.info(`\n✓ Test passed: ${name} (${duration}ms)`);
|
||||
this.logger.info(`\n✓ Test passed: ${test.name} (${duration}ms)`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -74,7 +64,7 @@ export class TestRunner {
|
|||
const duration = Date.now() - startTime;
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
this.logger.info(`\n✗ Test failed: ${name}`);
|
||||
this.logger.info(`\n✗ Test failed: ${test.name}`);
|
||||
this.logger.info(`Error: ${errorMessage}`);
|
||||
|
||||
await this.cleanup();
|
||||
|
|
@ -88,76 +78,65 @@ export class TestRunner {
|
|||
}
|
||||
|
||||
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 settings: Partial<SyncSettings> = {
|
||||
isSyncEnabled: false,
|
||||
token: this.token,
|
||||
vaultName,
|
||||
remoteUri: this.remoteUri
|
||||
};
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
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`
|
||||
await agent.init(
|
||||
fetch,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
WebSocket as unknown as typeof globalThis.WebSocket
|
||||
);
|
||||
this.agents.push(agent);
|
||||
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(
|
||||
await this.agents[step.client].createFile(
|
||||
step.path,
|
||||
new TextEncoder().encode(step.content)
|
||||
step.content
|
||||
);
|
||||
break;
|
||||
|
||||
case "update":
|
||||
await this.agents[step.client].updateFile(
|
||||
step.path,
|
||||
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(
|
||||
await this.agents[step.client].renameFile(
|
||||
step.oldPath,
|
||||
step.newPath
|
||||
);
|
||||
break;
|
||||
|
||||
case "delete":
|
||||
await this.getAgent(step.client).delete(step.path);
|
||||
await this.agents[step.client].deleteFile(step.path);
|
||||
break;
|
||||
|
||||
case "sync":
|
||||
if (step.client !== undefined) {
|
||||
await this.getAgent(step.client).waitForSync();
|
||||
await this.agents[step.client].waitForSync();
|
||||
} else {
|
||||
for (const agent of this.agents) {
|
||||
await agent.waitForSync();
|
||||
|
|
@ -166,11 +145,11 @@ export class TestRunner {
|
|||
break;
|
||||
|
||||
case "disable-sync":
|
||||
await this.getAgent(step.client).disableSync();
|
||||
await this.agents[step.client].disableSync();
|
||||
break;
|
||||
|
||||
case "enable-sync":
|
||||
await this.getAgent(step.client).enableSync();
|
||||
await this.agents[step.client].enableSync();
|
||||
break;
|
||||
|
||||
case "pause-server":
|
||||
|
|
@ -179,56 +158,31 @@ export class TestRunner {
|
|||
|
||||
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-content":
|
||||
await this.agents[step.client].assertContent(
|
||||
step.path,
|
||||
step.content
|
||||
);
|
||||
break;
|
||||
|
||||
case "assert-exists":
|
||||
await this.agents[step.client].assertExists(step.path);
|
||||
break;
|
||||
|
||||
case "assert-not-exists":
|
||||
await this.agents[step.client].assertNotExists(step.path);
|
||||
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}`);
|
||||
|
|
@ -236,114 +190,94 @@ export class TestRunner {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
for (const agent of this.agents) {
|
||||
await agent.waitForSync();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
if (await this.checkConsistency()) {
|
||||
this.logger.info("Barrier complete: all clients converged");
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Convergence timed out after ${CONVERGENCE_TIMEOUT_MS}ms: ${lastError?.message ?? "no consistency check ran"}`,
|
||||
{ cause: lastError }
|
||||
`Clients did not converge`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 A→B→C→…→A),
|
||||
* 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 checkConsistency(): Promise<boolean> {
|
||||
const [referenceAgent] = this.agents;
|
||||
const referenceFiles = (await referenceAgent.getFiles()).sort();
|
||||
|
||||
for (let i = 1; i < this.agents.length; i++) {
|
||||
const agent = this.agents[i];
|
||||
const files = (await agent.getFiles()).sort();
|
||||
|
||||
if (files.length !== referenceFiles.length) {
|
||||
throw new Error(
|
||||
`File count mismatch: client 0 has ${referenceFiles.length} files, client ${i} has ${files.length} files.\n Files: ${files.join(", ")}\n Reference: ${referenceFiles.join(", ")}`
|
||||
);
|
||||
}
|
||||
|
||||
for (const file of referenceFiles) {
|
||||
const referenceContent =
|
||||
await referenceAgent.getFileContent(file);
|
||||
const agentContent = await agent.getFileContent(file);
|
||||
|
||||
if (referenceContent !== agentContent) {
|
||||
throw new Error(
|
||||
`Content mismatch for ${file}:\nReference: "${referenceContent}"\nClient ${i}: "${agentContent}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async assertConsistent(
|
||||
verify?: (state: AssertableState) => void
|
||||
verify?: (state: ClientState) => 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 [referenceAgent] = this.agents;
|
||||
const referenceFiles = (await referenceAgent.getFiles()).sort();
|
||||
const referenceState: ClientState = { files: new Map() };
|
||||
|
||||
for (const file of referenceFiles) {
|
||||
const content = await referenceAgent.getFileContent(file);
|
||||
referenceState.files.set(file, content);
|
||||
}
|
||||
|
||||
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());
|
||||
for (let i = 1; i < this.agents.length; i++) {
|
||||
const agent = this.agents[i];
|
||||
const files = (await agent.getFiles()).sort();
|
||||
|
||||
this.logger.info(
|
||||
`Client ${i} has ${agentFileKeys.length} files: ${agentFileKeys.join(", ")}`
|
||||
`Client ${i} has ${files.length} files: ${files.join(", ")}`
|
||||
);
|
||||
|
||||
assert(
|
||||
agentFileKeys.length === referenceFiles.length,
|
||||
`File count mismatch: client 0 has ${referenceFiles.length} files, client ${i} has ${agentFileKeys.length} files`
|
||||
files.length === referenceFiles.length,
|
||||
`File count mismatch: client 0 has ${referenceFiles.length} files, client ${i} has ${files.length} files`
|
||||
);
|
||||
|
||||
for (let j = 0; j < agentFileKeys.length; j++) {
|
||||
for (let j = 0; j < files.length; j++) {
|
||||
assert(
|
||||
agentFileKeys[j] === referenceFiles[j],
|
||||
`File list mismatch at index ${j}: client 0 has "${referenceFiles[j]}", client ${i} has "${agentFileKeys[j]}"`
|
||||
files[j] === referenceFiles[j],
|
||||
`File list mismatch at index ${j}: client 0 has "${referenceFiles[j]}", client ${i} has "${files[j]}"`
|
||||
);
|
||||
}
|
||||
|
||||
for (const file of referenceFiles) {
|
||||
const referenceContent = clientFiles[0].get(file);
|
||||
const agentContent = clientFiles[i].get(file);
|
||||
const referenceContent = referenceState.files.get(file);
|
||||
const agentContent = await agent.getFileContent(file);
|
||||
|
||||
assert(
|
||||
referenceContent === agentContent,
|
||||
|
|
@ -356,42 +290,15 @@ export class TestRunner {
|
|||
|
||||
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}`);
|
||||
}
|
||||
verify(referenceState);
|
||||
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)}`
|
||||
);
|
||||
}
|
||||
await agent.cleanup();
|
||||
}
|
||||
this.agents = [];
|
||||
this.logger.info("Cleanup complete");
|
||||
|
|
|
|||
|
|
@ -1,40 +0,0 @@
|
|||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const coalesceUpdateRemoteUpdateDataLossTest: TestDefinition = {
|
||||
description:
|
||||
"Divergent offline edits with text-merge expectation. Client 0's " +
|
||||
"remote update fully lands before Client 1 reconnects (`sync`-after " +
|
||||
"the c0 update enforces this), so Client 1's offline edit merges " +
|
||||
"against a server-known version, not a coalesced batch. Both " +
|
||||
"additions must survive in the final merged content. (Filename's " +
|
||||
"'coalesce' framing is aspirational — a true update-coalesce test " +
|
||||
"would skip the c0 sync and queue overlapping local + remote " +
|
||||
"updates against the same parent version.)",
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const concurrentRenameAndCreateAtTargetCreateFirstTest: TestDefinition = {
|
||||
description:
|
||||
"One client renames X to Y while another creates a new file at Y, " +
|
||||
"both offline. After syncing, Y should contain merged content from " +
|
||||
"both the renamed file and the newly created file.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "X.md",
|
||||
content: "original file X"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
{ type: "rename", client: 0, oldPath: "X.md", newPath: "Y.md" },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "Y.md",
|
||||
content: "brand new Y content"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(2)
|
||||
.assertContains("Y (1).md", "original file X")
|
||||
.assertContains("Y.md", "brand new Y content");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const concurrentRenameAndCreateAtTargetRenameFirstTest: TestDefinition = {
|
||||
description:
|
||||
"One client renames X to Y while another creates a new file at Y, " +
|
||||
"both offline. We can't merge the create because it would result in a cycle",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "X.md",
|
||||
content: "original file X"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
{ type: "rename", client: 0, oldPath: "X.md", newPath: "Y.md" },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "Y.md",
|
||||
content: "brand new Y content"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertFileNotExists("X.md")
|
||||
.assertFileExists("Y.md")
|
||||
.assertFileExists("Y (1).md")
|
||||
.assertAnyFileContains(
|
||||
"original file X",
|
||||
"brand new Y content"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
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}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
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 ");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const rapidCreateUpdateDeleteCycleTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 rapidly creates, updates, deletes, then re-creates a file while the server is paused. " +
|
||||
"After the server resumes, client 1 must see only the final file.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "pause-server" },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "cycle.md",
|
||||
content: "version 1"
|
||||
},
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "cycle.md",
|
||||
content: "version 2"
|
||||
},
|
||||
{ type: "delete", client: 0, path: "cycle.md" },
|
||||
|
||||
{ type: "resume-server" },
|
||||
{ type: "sync" },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "cycle.md",
|
||||
content: "final creation"
|
||||
},
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContent(
|
||||
"cycle.md",
|
||||
"final creation"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const rapidEditDeleteOnlineConvergenceTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 rapidly edits multiple files while client 1 deletes some of them, all while both are online. " +
|
||||
"Both clients must converge to a consistent state.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "A.md", content: "content A" },
|
||||
{ type: "create", client: 0, path: "B.md", content: "content B" },
|
||||
{ type: "create", client: 0, path: "C.md", content: "content C" },
|
||||
{ type: "create", client: 0, path: "D.md", content: "content D" },
|
||||
{ type: "create", client: 0, path: "E.md", content: "content E" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "update", client: 0, path: "A.md", content: "A edit 1" },
|
||||
{ type: "update", client: 0, path: "B.md", content: "B edit 1" },
|
||||
{ type: "update", client: 0, path: "C.md", content: "C edit 1" },
|
||||
{ type: "delete", client: 1, path: "A.md" },
|
||||
{ type: "delete", client: 1, path: "C.md" },
|
||||
{ type: "delete", client: 1, path: "E.md" },
|
||||
{ type: "update", client: 0, path: "A.md", content: "A edit 2" },
|
||||
{ type: "update", client: 0, path: "B.md", content: "B edit 2" },
|
||||
{ type: "update", client: 0, path: "C.md", content: "C edit 2" },
|
||||
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
for (const [path, content] of s.files) {
|
||||
for (const clientFiles of s.clientFiles) {
|
||||
if (
|
||||
clientFiles.has(path) &&
|
||||
clientFiles.get(path) !== content
|
||||
) {
|
||||
throw new Error(
|
||||
`Content mismatch for ${path}: "${clientFiles.get(path)}" vs "${content}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const rapidUpdatesAfterMergeTest: TestDefinition = {
|
||||
description:
|
||||
"Both clients create the same file offline, triggering a merge on sync. " +
|
||||
"Client 0 then rapidly sends three updates. Both clients must converge to the final update.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "doc.md", content: "from client 0" },
|
||||
{ type: "create", client: 1, path: "doc.md", content: "from client 1" },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "update 1"
|
||||
},
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "update 2"
|
||||
},
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "update 3"
|
||||
},
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContains("doc.md", "update 3");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const recentlyDeletedClearedOnReconnectTest: TestDefinition = {
|
||||
description:
|
||||
"After a client deletes a document and reconnects, it should " +
|
||||
"accept new documents from other clients even if they happen to " +
|
||||
"arrive at the same path as the deleted document.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
|
||||
{ type: "create", client: 0, path: "doc.md", content: "original" },
|
||||
{ type: "sync" },
|
||||
|
||||
{ type: "delete", client: 0, path: "doc.md" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "doc.md",
|
||||
content: "new content from client 1"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync", client: 1 },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContent(
|
||||
"doc.md",
|
||||
"new content from client 1"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const remoteQuickWriteRenameBeforeRecordTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 receives a remote create and the user renames the new " +
|
||||
"file immediately after the syncer writes it. The watcher event " +
|
||||
"must bind to the new document instead of being dropped before " +
|
||||
"the remote-create handler persists the record.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
|
||||
{
|
||||
type: "rename-next-write",
|
||||
client: 0,
|
||||
oldPath: "doc.md",
|
||||
newPath: "renamed.md"
|
||||
},
|
||||
|
||||
{ type: "create", client: 1, path: "doc.md", content: "v1\n" },
|
||||
{ type: "sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1);
|
||||
s.assertFileExists("renamed.md");
|
||||
s.assertFileNotExists("doc.md");
|
||||
s.assertContent("renamed.md", "v1\n");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const remoteRenameCollidesWithPendingLocalCreateTest: TestDefinition = {
|
||||
// TODO(refactor): the failure mode described below is the
|
||||
// pre-refactor "deflect-to-conflict-uuid" path that no longer
|
||||
// exists. Under the new model the wire loop never moves files for
|
||||
// path placement, so the remote rename can't deflect anywhere; the
|
||||
// reconciler waits for the slot to free. Convergence assertion is
|
||||
// still valid (no conflict-uuid stashes, both files present, the
|
||||
// local create lands at a server-deconflicted sibling).
|
||||
description:
|
||||
"Client 0 has doc D tracked at `original.md`. Client 1 owns doc E " +
|
||||
"and renames it to `target.md` server-side. Before client 0's " +
|
||||
"drain processes the WS broadcast for E, the user creates a new " +
|
||||
"local file `target.md` (a different doc, untracked). When the " +
|
||||
"buffered RemoteChange for E drains, the engine has to reconcile " +
|
||||
"doc E onto `target.md` even though the slot is held by client " +
|
||||
"0's pending LocalCreate. Convergence requires both clients end " +
|
||||
"up with [target.md = E] and the local create lands at a " +
|
||||
"server-deconflicted sibling (e.g. `target (1).md`).",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
|
||||
{ type: "create", client: 1, path: "original.md", content: "v1\n" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Pause client 0's WS so the upcoming remote rename buffers and
|
||||
// we can stage a colliding local create before the rename
|
||||
// drains on client 0.
|
||||
{ type: "pause-websocket", client: 0 },
|
||||
|
||||
// Client 1 renames the doc. Server commits, broadcasts to
|
||||
// client 0 (buffered).
|
||||
{
|
||||
type: "rename",
|
||||
client: 1,
|
||||
oldPath: "original.md",
|
||||
newPath: "target.md"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Client 0 still believes the doc is at `original.md`. The user
|
||||
// creates a NEW file at `target.md` (an unrelated untracked
|
||||
// doc). Disk on client 0 now has both `original.md` (the
|
||||
// tracked doc) and `target.md` (the new untracked file).
|
||||
{ type: "create", client: 0, path: "target.md", content: "extra\n" },
|
||||
|
||||
// Resume client 0's WS. The buffered RemoteChange drains.
|
||||
// The reconciler must converge without ever leaving a
|
||||
// conflict-uuid stash on disk.
|
||||
{ type: "resume-websocket", client: 0 },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state.assertFileCount(2);
|
||||
for (const path of state.files.keys()) {
|
||||
if (path.startsWith("conflict-")) {
|
||||
throw new Error(
|
||||
`Unexpected conflict-uuid stash on a converged client: ${path}`
|
||||
);
|
||||
}
|
||||
}
|
||||
state.assertFileExists("target.md");
|
||||
state.assertContent("target.md", "v1\n");
|
||||
// The local create gets server-deconflicted to a
|
||||
// sibling path (e.g. `target (1).md`).
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const remoteUpdateResurrectsDeletedDocTest: TestDefinition = {
|
||||
description:
|
||||
"Client 1 updates, deletes, and recreates P (with a new docId D2). " +
|
||||
"While the buffered remote events are being processed by client 0, " +
|
||||
"client 0 also makes a local edit to P. The local edit lands in the " +
|
||||
"queue while v17 is mid-process, sending v17 down processRemoteUpdate's " +
|
||||
"re-enqueue branch. The deferred v17 must NOT later resurrect D1 as a " +
|
||||
"conflict-… file at P after the delete and the D2 create have drained.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
|
||||
{ type: "create", client: 1, path: "P.md", content: "v8 content\n" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "pause-websocket", client: 0 },
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "P.md",
|
||||
content: "v17 content from client 1\n"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
{ type: "delete", client: 1, path: "P.md" },
|
||||
{ type: "sync", client: 1 },
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "P.md",
|
||||
content: "v21 content (D2)\n"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
{ type: "resume-websocket", client: 0 },
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "P.md",
|
||||
content: "local edit by client 0\n"
|
||||
},
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(1)
|
||||
.assertContent("P.md", "v21 content (D2)\n");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const remoteUpdateSurvivesUserRenameTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 updates a tracked doc; while Client 1 is processing the " +
|
||||
"broadcast and parked on the GET for the new version's content, the " +
|
||||
"user renames the doc on Client 1. Pre-fix: `processRemoteUpdate` " +
|
||||
"captures `actualPath` before the await and, after the GET returns, " +
|
||||
"calls `write(actualPath, …)` (no-op — file was renamed away), " +
|
||||
"`updateCache(actualPath, …)`, and `setDocument(actualPath, …)`. " +
|
||||
"`setDocument` mutates the same record in place so its `path` is " +
|
||||
"yanked from the user's renamed slot back to the pre-rename path, " +
|
||||
"wiping the rename out of the queue's documents map. The queued " +
|
||||
"`LocalUpdate` then reads from the now-stale `record.path`, hits " +
|
||||
"`FileNotFoundError`, and is silently dropped — the user's rename " +
|
||||
"never reaches the server. Post-fix: the handler defers when a " +
|
||||
"local event landed mid-await, so the rename drains first and " +
|
||||
"the deferred remote update is folded into the broadcast that " +
|
||||
"follows the rename round-trip.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "doc.md", content: "v1\n" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Buffer Client 1's incoming broadcasts so it doesn't see
|
||||
// Client 0's update until we've paused the server.
|
||||
{ type: "pause-websocket", client: 1 },
|
||||
|
||||
// Server now holds v=2 of doc.md.
|
||||
{ type: "update", client: 0, path: "doc.md", content: "v2\n" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
// Pause the server. Client 1's upcoming GET for the new version
|
||||
// content blocks at the OS layer until resume.
|
||||
{ type: "pause-server" },
|
||||
|
||||
// Release the buffered broadcast. Client 1's drain enters
|
||||
// `processRemoteUpdate`, captures `actualPath`, fires the GET,
|
||||
// and parks awaiting the response.
|
||||
{ type: "resume-websocket", client: 1 },
|
||||
|
||||
// Yield long enough for the drain to traverse all microtask
|
||||
// hops between the WS handler and the GET, so the HTTP request
|
||||
// is queued at the (paused) server before the rename runs.
|
||||
// Without this yield the rename would be enqueued before
|
||||
// `processRemoteUpdate`'s entry-time `hasPendingLocalEvents`
|
||||
// check and the early-defer branch would mask the bug.
|
||||
{ type: "sleep", ms: 50 },
|
||||
|
||||
// While the GET is in flight the user renames the doc. The queue
|
||||
// mutates `record.path` to "renamed.md" in place and pushes a
|
||||
// LocalUpdate carrying the rename target.
|
||||
{
|
||||
type: "rename",
|
||||
client: 1,
|
||||
oldPath: "doc.md",
|
||||
newPath: "renamed.md"
|
||||
},
|
||||
|
||||
// Resume the server. The GET response unblocks
|
||||
// `processRemoteUpdate`. With the fix in place it sees the
|
||||
// queued LocalUpdate and defers; without the fix it walks past
|
||||
// the rename and clobbers the documents map, dropping the
|
||||
// pending LocalUpdate's read on the way back through.
|
||||
{ type: "resume-server" },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1);
|
||||
s.assertFileExists("renamed.md");
|
||||
s.assertFileNotExists("doc.md");
|
||||
// Both edits survive: the user's rename and Client 0's
|
||||
// content update at v=2.
|
||||
s.assertContent("renamed.md", "v2\n");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue