Compare commits
51 commits
3ba0b7a88b
...
f2337dbbd0
| Author | SHA1 | Date | |
|---|---|---|---|
| f2337dbbd0 | |||
| fb71622e40 | |||
| 9151e0b2d6 | |||
| 8e87537e49 | |||
| 8aeb0d6027 | |||
| 35877b69da | |||
| 39c5591d36 | |||
| b5f448706e | |||
| 7198639db4 | |||
| 0d9aebf900 | |||
| 5776a37dc9 | |||
| 1163da826e | |||
| cc44b66fcd | |||
| 5707add47c | |||
| 3cfe095fa7 | |||
| debe7cfc37 | |||
| 0ab6984cdf | |||
| 439de6a264 | |||
| 81c7e0c984 | |||
| 039affff09 | |||
| 3d285b0b6e | |||
| 2a6d824cc9 | |||
| fc0ff0df1c | |||
| 8b7be48522 | |||
| 8eae770621 | |||
| fe2b4751bd | |||
| 56070912e8 | |||
| 14f25b4f2c | |||
| d23750f15b | |||
| a5b3cc5f3a | |||
| 7a8c497462 | |||
| 8ce33541a3 | |||
| bff3f5a5e9 | |||
| 7f62273e72 | |||
| fefac224b0 | |||
| 081e35be5c | |||
| 321b503379 | |||
| addaa1699f | |||
| b52c09fecc | |||
| 7293c58a71 | |||
| aecbcd1d2c | |||
| c9cf3239db | |||
| 17a1f4d060 | |||
| 19d5dc1999 | |||
| a7b588da97 | |||
| d715d94b6d | |||
| 6a8c7635f1 | |||
| 5ee9db0007 | |||
| dca59a18dc | |||
| 9183f30b5d | |||
| 5ec523234b |
254 changed files with 9514 additions and 3885 deletions
155
CLAUDE.md
Normal file
155
CLAUDE.md
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project shape
|
||||||
|
|
||||||
|
VaultLink is a self-hosted Obsidian file-sync system. Two halves of one repo:
|
||||||
|
|
||||||
|
- `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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Frontend workspaces
|
||||||
|
|
||||||
|
- `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.
|
||||||
|
|
||||||
|
## Common commands
|
||||||
|
|
||||||
|
Pre-push hygiene (formats, lints, runs tests, requires clean git state):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/check.sh --fix
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the fuzz E2E (N parallel processes):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/e2e.sh 12
|
||||||
|
# Logs land in logs/log_<i>.log. Clean with scripts/clean-up.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Run deterministic tests (require a release-built server in `sync-server/target/release/sync_server` — they spawn it themselves):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd sync-server && cargo build --release && cd ..
|
||||||
|
cd frontend
|
||||||
|
npm run 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
|
||||||
|
```
|
||||||
|
|
||||||
|
Run a single sync-client unit test by file:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd frontend/sync-client && npx tsx --test 'src/**/sync-event-queue.test.ts'
|
||||||
|
```
|
||||||
|
|
||||||
|
Server: dev runs from `sync-server/` against `config-e2e.yml`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd sync-server
|
||||||
|
cargo run config-e2e.yml # dev
|
||||||
|
cargo build --release # used by both e2e harnesses
|
||||||
|
cargo test # unit + ts-rs binding export tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend dev (sync-client + obsidian-plugin watch in parallel):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd frontend && npm install && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Regenerate TS bindings from Rust types (touches `frontend/{sync-client,history-ui}/src/.../types/`):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/update-api-types.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## SQLite / sqlx
|
||||||
|
|
||||||
|
The server uses `sqlx::query!` macros that need a prepared `.sqlx` cache to compile offline. Touching any SQL means regenerating it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd sync-server
|
||||||
|
sqlx database create --database-url sqlite://db.sqlite3
|
||||||
|
sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3
|
||||||
|
cargo sqlx prepare --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
New migrations: `sqlx migrate add --source src/app_state/database/migrations <name>`.
|
||||||
|
|
||||||
|
## Sync engine architecture
|
||||||
|
|
||||||
|
Read `frontend/sync-client/src/sync-operations/` to follow the sync engine; the rest of `sync-client` is plumbing (filesystem ops, persistence, services, telemetry).
|
||||||
|
|
||||||
|
The engine is **two independent loops with separate invariants**:
|
||||||
|
|
||||||
|
- **Wire loop** (`syncer.ts`) — drains the single-consumer FIFO queue. HTTP and WS handlers update record fields (`remoteRelativePath`, `parentVersionId`, `remoteHash`) and write content to the file at `record.localPath`. They never move files for path placement.
|
||||||
|
- **Path reconciler** (`reconciler.ts`) — runs after every drained event. Best-effort pass that moves files to make `localPath === remoteRelativePath`. The move graph is topologically sorted; cycles are resolved by reading every file in the cycle into memory and writing each back to its new slot (no tmp files). Records with pending local events are skipped on each pass — the reconciler operates only on settled records. Failures (slot occupied by an untracked file, etc.) are silent skips; the next pass retries.
|
||||||
|
|
||||||
|
**`SyncEventQueue`** (`sync-event-queue.ts`) holds:
|
||||||
|
|
||||||
|
- `byDocId: Map<DocumentId, DocumentRecord>` — primary record store.
|
||||||
|
- `byLocalPath: Map<RelativePath, DocumentRecord>` — derived index for path lookups, maintained at every mutation point.
|
||||||
|
- `events: SyncEvent[]` — pending wire ops in FIFO drain order.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
DocumentRecord = {
|
||||||
|
documentId,
|
||||||
|
parentVersionId,
|
||||||
|
remoteHash?,
|
||||||
|
remoteRelativePath,
|
||||||
|
localPath: RelativePath | undefined
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`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`).
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
## Edge-case patterns the sync engine has to survive
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**`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.
|
||||||
|
|
||||||
|
**`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.
|
||||||
|
|
||||||
|
**Reconciler-defer is the wire-loop's contract with the reconciler.** The reconciler skips records where `hasPendingLocalEventsForDocumentId` returns true. Wire-loop handlers can therefore freely write `remoteRelativePath` to whatever the server returned — even if it disagrees with `localPath` — knowing the reconciler won't move the file out from under a queued user rename.
|
||||||
|
|
||||||
|
**Watermark advancement is load-bearing both ways.** Branches that _skip_ a remote event without advancing `lastSeenUpdateId` create permanent gaps that re-deliver forever. Branches that _advance_ without applying the content lose data: the server has no further event to re-deliver, the catch-up only carries the latest version, and any state in between is gone. Don't advance unless the event was actually applied (or deliberately discarded after weighing both halves).
|
||||||
|
|
||||||
|
**`isNewFile` semantics differ between catch-up and real-time.** On WS handshake replay it means _new to this client_ (`creation_vault_update_id > last_seen_vault_update_id`); on real-time broadcasts it means _this version is the create_ (`creation_vault_update_id == vault_update_id`). A handler that decides based on one interpretation will be wrong on the other channel; reasoning about fetch-and-treat-as-new vs. ignore needs to know which channel delivered the event.
|
||||||
|
|
||||||
|
**Pause / disable-sync mid-flight** is the one race the new model doesn't structurally fix. An HTTP that committed server-side but whose response was discarded leaves the server holding a doc the client has no record of. Resume → offline scan → server-side dedupe handles it (the server merges the duplicate create into the existing doc), but if the merge produces a deconflict, the client picks up an extra file. Out of scope for the two-loop split.
|
||||||
|
|
||||||
|
**Cycle reconciliation uses in-memory content swap.** When the move graph contains a cycle, the reconciler reads every file in the cycle into memory and writes each back to its new slot, with no tmp files. A write-ahead marker at `.vaultlink/swap-<uuid>.json` lists each leg; on startup the reconciler reads the marker, hashes each `from` to determine which legs ran, and replays the rest. The `.vaultlink/**` glob is hard-coded as an internal ignore pattern so swap markers don't get sync'd.
|
||||||
|
|
||||||
|
## Two complementary E2E harnesses
|
||||||
|
|
||||||
|
- **`test-client` (fuzz):** random ops across N parallel processes for many minutes. Used by `scripts/e2e.sh`. Catches bugs nobody thought to write a test for, but reproductions are noisy.
|
||||||
|
- **`deterministic-tests`:** scripted scenarios with an in-memory FS pinned to a real server. Used to _capture_ a fuzz-discovered bug as a minimal repro before fixing it. See `frontend/deterministic-tests/README.md` for the step grammar (`pause-server`, `pause-websocket`, `barrier`, `assert-consistent`, etc.).
|
||||||
|
|
||||||
|
When a fuzz failure surfaces, the workflow is: root-cause from logs → write a deterministic test that fails on the bug → fix → confirm both the deterministic test and `e2e.sh` pass.
|
||||||
|
|
||||||
|
## Style
|
||||||
|
|
||||||
|
- TS: 4-space indent, no tabs, LF, prettier (`trailingComma: "none"`). YAML/MD use 2-space indent.
|
||||||
|
- Rust: `rustfmt.toml` enforces 4-space spaces, LF.
|
||||||
|
- Lint: ESLint for TS, Clippy for Rust, `cargo machete` for unused deps. All wired into `scripts/check.sh`.
|
||||||
|
|
@ -17,30 +17,35 @@ All tests run in parallel up to a concurrency limit.
|
||||||
Clients always start with syncing disabled.
|
Clients always start with syncing disabled.
|
||||||
|
|
||||||
**File operations** (per-client, fire-and-forget — sync is enqueued but not awaited):
|
**File operations** (per-client, fire-and-forget — sync is enqueued but not awaited):
|
||||||
|
|
||||||
- `create`, `update`, `rename`, `delete`
|
- `create`, `update`, `rename`, `delete`
|
||||||
|
|
||||||
**Sync control:**
|
**Sync control:**
|
||||||
|
|
||||||
- `sync` — wait for a specific client or all clients to finish pending operations
|
- `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)
|
- `barrier` — retry until all clients converge to identical file state (60s timeout)
|
||||||
- `enable-sync` / `disable-sync` — simulate going online/offline
|
- `enable-sync` / `disable-sync` — simulate going online/offline
|
||||||
|
|
||||||
**WebSocket control** (per-client):
|
**WebSocket control** (per-client):
|
||||||
|
|
||||||
- `pause-websocket` / `resume-websocket` — buffer/release WebSocket messages for a specific client
|
- `pause-websocket` / `resume-websocket` — buffer/release WebSocket messages for a specific client
|
||||||
|
|
||||||
**Server control:**
|
**Server control:**
|
||||||
|
|
||||||
- `pause-server` / `resume-server` — SIGSTOP/SIGCONT the server process
|
- `pause-server` / `resume-server` — SIGSTOP/SIGCONT the server process
|
||||||
|
|
||||||
**Assertions:**
|
**Assertions:**
|
||||||
|
|
||||||
- `assert-consistent` — all clients have identical files; optionally takes a custom `verify(state: AssertableState)` callback
|
- `assert-consistent` — all clients have identical files; optionally takes a custom `verify(state: AssertableState)` callback
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Build server first
|
# Build server first
|
||||||
cd sync-server && cargo build --release
|
cd sync-server && cargo build --release && cd -
|
||||||
|
|
||||||
# Run all tests
|
# Run all tests
|
||||||
cd frontend && npm run test -w deterministic-tests
|
cd frontend && npm run build -w sync-client && npm run test -w deterministic-tests
|
||||||
|
|
||||||
# Filter by name
|
# Filter by name
|
||||||
npm run test -w deterministic-tests -- --filter=rename
|
npm run test -w deterministic-tests -- --filter=rename
|
||||||
|
|
@ -57,14 +62,18 @@ npm run test -w deterministic-tests -- -j 4
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const myScenarioTest: TestDefinition = {
|
export const myScenarioTest: TestDefinition = {
|
||||||
description: "Client 0 creates A.md offline. After syncing, both clients should have the file.",
|
description:
|
||||||
|
"Client 0 creates A.md offline. After syncing, both clients should have the file.",
|
||||||
clients: 2,
|
clients: 2,
|
||||||
steps: [
|
steps: [
|
||||||
{ type: "create", client: 0, path: "A.md", content: "hello" },
|
{ type: "create", client: 0, path: "A.md", content: "hello" },
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("A.md", "hello") }
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s) => s.assertFileCount(1).assertContent("A.md", "hello")
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -38,137 +38,6 @@ interface NamedTestResult {
|
||||||
result: TestResult;
|
result: TestResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
|
||||||
const cwd = process.cwd();
|
|
||||||
let projectRoot = cwd;
|
|
||||||
|
|
||||||
if (cwd.endsWith("frontend/deterministic-tests")) {
|
|
||||||
projectRoot = path.resolve(cwd, "../..");
|
|
||||||
} else if (cwd.endsWith("frontend")) {
|
|
||||||
projectRoot = path.resolve(cwd, "..");
|
|
||||||
}
|
|
||||||
|
|
||||||
const serverPath = path.join(projectRoot, SERVER_BINARY_PATH);
|
|
||||||
if (!fs.existsSync(serverPath)) {
|
|
||||||
logger.error(`Server binary not found at: ${serverPath}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const configPath = path.join(projectRoot, CONFIG_PATH);
|
|
||||||
if (!fs.existsSync(configPath)) {
|
|
||||||
logger.error(`Config file not found at: ${configPath}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const filterArg = process.argv.find((a) => a.startsWith("--filter="));
|
|
||||||
const filter = filterArg?.slice("--filter=".length);
|
|
||||||
|
|
||||||
const testsToRun: [string, TestDefinition][] = [];
|
|
||||||
for (const [key, test] of Object.entries(TESTS)) {
|
|
||||||
if (test) {
|
|
||||||
if (filter && !key.includes(filter)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
testsToRun.push([key, test]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (testsToRun.length === 0) {
|
|
||||||
logger.error(
|
|
||||||
filter
|
|
||||||
? `No tests matched filter "${filter}"`
|
|
||||||
: "No tests found"
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const concurrency = parseConcurrency();
|
|
||||||
const regularTests = testsToRun.filter(
|
|
||||||
([, t]) => !testUsesPauseServer(t)
|
|
||||||
);
|
|
||||||
const pauseTests = testsToRun.filter(([, t]) => testUsesPauseServer(t));
|
|
||||||
|
|
||||||
logger.info(`Server: ${serverPath}`);
|
|
||||||
logger.info(`Config: ${configPath}`);
|
|
||||||
logger.info(
|
|
||||||
`Tests: ${testsToRun.length} total (${regularTests.length} regular, ${pauseTests.length} server-pause)`
|
|
||||||
);
|
|
||||||
logger.info(`Concurrency: ${concurrency}`);
|
|
||||||
|
|
||||||
const allResults: NamedTestResult[] = [];
|
|
||||||
|
|
||||||
if (regularTests.length > 0) {
|
|
||||||
logger.info(
|
|
||||||
`\n--- Running ${regularTests.length} regular tests (shared server, concurrency ${concurrency}) ---`
|
|
||||||
);
|
|
||||||
const sharedServer = new ServerControl(
|
|
||||||
serverPath,
|
|
||||||
configPath,
|
|
||||||
logger
|
|
||||||
);
|
|
||||||
serverManager.track(sharedServer);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sharedServer.start();
|
|
||||||
|
|
||||||
const results = await runWithConcurrency(
|
|
||||||
regularTests,
|
|
||||||
concurrency,
|
|
||||||
async ([name, test]) =>
|
|
||||||
runSharedServerTest(name, test, sharedServer)
|
|
||||||
);
|
|
||||||
|
|
||||||
allResults.push(...results);
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
await sharedServer.stop();
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(
|
|
||||||
`Error stopping shared server: ${error instanceof Error ? error.message : String(error)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
serverManager.untrack(sharedServer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pauseTests.length > 0) {
|
|
||||||
logger.info(
|
|
||||||
`\n--- Running ${pauseTests.length} server-pause tests (dedicated servers, concurrency ${concurrency}) ---`
|
|
||||||
);
|
|
||||||
|
|
||||||
const results = await runWithConcurrency(
|
|
||||||
pauseTests,
|
|
||||||
concurrency,
|
|
||||||
async ([name, test]) =>
|
|
||||||
runDedicatedServerTest(name, test, serverPath, configPath)
|
|
||||||
);
|
|
||||||
|
|
||||||
allResults.push(...results);
|
|
||||||
}
|
|
||||||
|
|
||||||
const passed = allResults.filter((r) => r.result.success);
|
|
||||||
const failed = allResults.filter((r) => !r.result.success);
|
|
||||||
|
|
||||||
logger.info(`\n--- Results: ${passed.length}/${allResults.length} passed ---`);
|
|
||||||
|
|
||||||
if (failed.length > 0) {
|
|
||||||
for (const { name, result } of failed) {
|
|
||||||
logger.error(` FAILED: ${name}: ${result.error}`);
|
|
||||||
}
|
|
||||||
process.exit(1);
|
|
||||||
} else {
|
|
||||||
logger.info("All tests passed!");
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err: unknown) => {
|
|
||||||
logger.error(`Unexpected error: ${err}`);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
async function runSharedServerTest(
|
async function runSharedServerTest(
|
||||||
name: string,
|
name: string,
|
||||||
test: TestDefinition,
|
test: TestDefinition,
|
||||||
|
|
@ -229,3 +98,132 @@ async function runDedicatedServerTest(
|
||||||
serverManager.untrack(server);
|
serverManager.untrack(server);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const cwd = process.cwd();
|
||||||
|
let projectRoot = cwd;
|
||||||
|
|
||||||
|
if (cwd.endsWith("frontend/deterministic-tests")) {
|
||||||
|
projectRoot = path.resolve(cwd, "../..");
|
||||||
|
} else if (cwd.endsWith("frontend")) {
|
||||||
|
projectRoot = path.resolve(cwd, "..");
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverPath = path.join(projectRoot, SERVER_BINARY_PATH);
|
||||||
|
if (!fs.existsSync(serverPath)) {
|
||||||
|
logger.error(`Server binary not found at: ${serverPath}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const configPath = path.join(projectRoot, CONFIG_PATH);
|
||||||
|
if (!fs.existsSync(configPath)) {
|
||||||
|
logger.error(`Config file not found at: ${configPath}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterArg = process.argv.find((a) => a.startsWith("--filter="));
|
||||||
|
const filter = filterArg?.slice("--filter=".length);
|
||||||
|
|
||||||
|
const testsToRun: [string, TestDefinition][] = [];
|
||||||
|
for (const [key, test] of Object.entries(TESTS)) {
|
||||||
|
if (test) {
|
||||||
|
if (
|
||||||
|
filter !== undefined &&
|
||||||
|
filter.length > 0 &&
|
||||||
|
!key.includes(filter)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
testsToRun.push([key, test]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (testsToRun.length === 0) {
|
||||||
|
logger.error(
|
||||||
|
filter !== undefined && filter.length > 0
|
||||||
|
? `No tests matched filter "${filter}"`
|
||||||
|
: "No tests found"
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const concurrency = parseConcurrency();
|
||||||
|
const regularTests = testsToRun.filter(([, t]) => !testUsesPauseServer(t));
|
||||||
|
const pauseTests = testsToRun.filter(([, t]) => testUsesPauseServer(t));
|
||||||
|
|
||||||
|
logger.info(`Server: ${serverPath}`);
|
||||||
|
logger.info(`Config: ${configPath}`);
|
||||||
|
logger.info(
|
||||||
|
`Tests: ${testsToRun.length} total (${regularTests.length} regular, ${pauseTests.length} server-pause)`
|
||||||
|
);
|
||||||
|
logger.info(`Concurrency: ${concurrency}`);
|
||||||
|
|
||||||
|
const allResults: NamedTestResult[] = [];
|
||||||
|
|
||||||
|
if (regularTests.length > 0) {
|
||||||
|
logger.info(
|
||||||
|
`\n--- Running ${regularTests.length} regular tests (shared server, concurrency ${concurrency}) ---`
|
||||||
|
);
|
||||||
|
const sharedServer = new ServerControl(serverPath, configPath, logger);
|
||||||
|
serverManager.track(sharedServer);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sharedServer.start();
|
||||||
|
|
||||||
|
const results = await runWithConcurrency(
|
||||||
|
regularTests,
|
||||||
|
concurrency,
|
||||||
|
async ([name, test]) =>
|
||||||
|
runSharedServerTest(name, test, sharedServer)
|
||||||
|
);
|
||||||
|
|
||||||
|
allResults.push(...results);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await sharedServer.stop();
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
`Error stopping shared server: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
serverManager.untrack(sharedServer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pauseTests.length > 0) {
|
||||||
|
logger.info(
|
||||||
|
`\n--- Running ${pauseTests.length} server-pause tests (dedicated servers, concurrency ${concurrency}) ---`
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await runWithConcurrency(
|
||||||
|
pauseTests,
|
||||||
|
concurrency,
|
||||||
|
async ([name, test]) =>
|
||||||
|
runDedicatedServerTest(name, test, serverPath, configPath)
|
||||||
|
);
|
||||||
|
|
||||||
|
allResults.push(...results);
|
||||||
|
}
|
||||||
|
|
||||||
|
const passed = allResults.filter((r) => r.result.success);
|
||||||
|
const failed = allResults.filter((r) => !r.result.success);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`\n--- Results: ${passed.length}/${allResults.length} passed ---`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (failed.length > 0) {
|
||||||
|
for (const { name, result } of failed) {
|
||||||
|
logger.error(` FAILED: ${name}: ${result.error}`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
logger.info("All tests passed!");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err: unknown) => {
|
||||||
|
logger.error(`Unexpected error: ${err}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,28 @@
|
||||||
import type { StoredDatabase, SyncSettings, RelativePath, TextWithCursors } from "sync-client";
|
import type {
|
||||||
import { SyncClient, debugging, LogLevel } from "sync-client";
|
HistoryEntry,
|
||||||
|
StoredDatabase,
|
||||||
|
SyncSettings,
|
||||||
|
RelativePath,
|
||||||
|
TextWithCursors
|
||||||
|
} from "sync-client";
|
||||||
|
import {
|
||||||
|
SyncClient,
|
||||||
|
SyncResetError,
|
||||||
|
debugging,
|
||||||
|
LogLevel,
|
||||||
|
utils
|
||||||
|
} from "sync-client";
|
||||||
import { assert } from "./utils/assert";
|
import { assert } from "./utils/assert";
|
||||||
import { sleep } from "./utils/sleep";
|
import { sleep } from "./utils/sleep";
|
||||||
import { withTimeout } from "./utils/with-timeout";
|
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 {
|
||||||
|
IS_SYNC_ENABLED_BY_DEFAULT,
|
||||||
|
WAIT_TIMEOUT_MS,
|
||||||
|
WEBSOCKET_CONNECT_TIMEOUT_MS,
|
||||||
|
WEBSOCKET_POLL_INTERVAL_MS
|
||||||
|
} from "./consts";
|
||||||
import { ManagedWebSocketFactory } from "./managed-websocket";
|
import { ManagedWebSocketFactory } from "./managed-websocket";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
public readonly clientId: number;
|
public readonly clientId: number;
|
||||||
private readonly logger: (msg: string) => void;
|
private readonly logger: (msg: string) => void;
|
||||||
|
|
@ -20,6 +35,18 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
private readonly syncErrors: Error[] = [];
|
private readonly syncErrors: Error[] = [];
|
||||||
private readonly pendingSyncOperations = new Set<Promise<void>>();
|
private readonly pendingSyncOperations = new Set<Promise<void>>();
|
||||||
private readonly wsFactory = new ManagedWebSocketFactory();
|
private readonly wsFactory = new ManagedWebSocketFactory();
|
||||||
|
private nextWriteRename:
|
||||||
|
| {
|
||||||
|
oldPath: RelativePath;
|
||||||
|
newPath: RelativePath;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
private nextCreateResponseDrop:
|
||||||
|
| {
|
||||||
|
dropped: Promise<void>;
|
||||||
|
resolveDropped: () => void;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
clientId: number,
|
clientId: number,
|
||||||
|
|
@ -33,7 +60,7 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async init(
|
public async init(
|
||||||
fetchImplementation: typeof globalThis.fetch,
|
fetchImplementation: typeof globalThis.fetch
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.client = await SyncClient.create({
|
this.client = await SyncClient.create({
|
||||||
fs: this,
|
fs: this,
|
||||||
|
|
@ -41,7 +68,7 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
load: async () => this.data,
|
load: async () => this.data,
|
||||||
save: async (data) => void (this.data = data)
|
save: async (data) => void (this.data = data)
|
||||||
},
|
},
|
||||||
fetch: fetchImplementation,
|
fetch: this.wrapFetch(fetchImplementation),
|
||||||
webSocket: this.wsFactory.constructorFn
|
webSocket: this.wsFactory.constructorFn
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -86,6 +113,65 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
this.wsFactory.resume();
|
this.wsFactory.resume();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public dropNextCreateResponse(): void {
|
||||||
|
assert(
|
||||||
|
this.nextCreateResponseDrop === undefined,
|
||||||
|
`Client ${this.clientId} already has a create response drop armed`
|
||||||
|
);
|
||||||
|
let resolveDropped!: () => void;
|
||||||
|
const dropped = new Promise<void>((resolve) => {
|
||||||
|
resolveDropped = resolve;
|
||||||
|
});
|
||||||
|
this.nextCreateResponseDrop = {
|
||||||
|
dropped,
|
||||||
|
resolveDropped
|
||||||
|
};
|
||||||
|
this.log("Armed next create response drop");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async waitForDroppedCreateResponse(): Promise<void> {
|
||||||
|
assert(
|
||||||
|
this.nextCreateResponseDrop !== undefined,
|
||||||
|
`Client ${this.clientId} has no create response drop armed`
|
||||||
|
);
|
||||||
|
await withTimeout(
|
||||||
|
this.nextCreateResponseDrop.dropped,
|
||||||
|
WAIT_TIMEOUT_MS,
|
||||||
|
`Client ${this.clientId} timed out waiting for create response drop`
|
||||||
|
);
|
||||||
|
this.log("Create response was dropped after server commit");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async waitForHistoryEntry(
|
||||||
|
matches: (entry: HistoryEntry) => boolean,
|
||||||
|
onMatch?: (entry: HistoryEntry) => void
|
||||||
|
): Promise<void> {
|
||||||
|
const existing = this.client.getHistoryEntries().find(matches);
|
||||||
|
if (existing !== undefined) {
|
||||||
|
onMatch?.(existing);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await withTimeout(
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
const unsubscribe = this.client.onSyncHistoryUpdated.add(() => {
|
||||||
|
const entry = this.client
|
||||||
|
.getHistoryEntries()
|
||||||
|
.find(matches);
|
||||||
|
if (entry === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
onMatch?.(entry);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
WAIT_TIMEOUT_MS,
|
||||||
|
`Client ${this.clientId} timed out waiting for history entry`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public async waitForSync(): Promise<void> {
|
public async waitForSync(): Promise<void> {
|
||||||
this.log("Waiting for sync to complete...");
|
this.log("Waiting for sync to complete...");
|
||||||
// Drain agent-level sync operations first. These are the fire-and-forget
|
// Drain agent-level sync operations first. These are the fire-and-forget
|
||||||
|
|
@ -107,6 +193,15 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
this.log("Sync complete");
|
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> {
|
public async disableSync(): Promise<void> {
|
||||||
this.log("Disabling sync");
|
this.log("Disabling sync");
|
||||||
// Drain pending enqueued operations before disabling so the SyncClient
|
// Drain pending enqueued operations before disabling so the SyncClient
|
||||||
|
|
@ -138,17 +233,27 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
await this.waitForWebSocket();
|
await this.waitForWebSocket();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public async getFileContent(path: string): Promise<string> {
|
public async getFileContent(path: string): Promise<string> {
|
||||||
const bytes = await this.read(path);
|
const bytes = await this.read(path);
|
||||||
return new TextDecoder().decode(bytes);
|
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> {
|
public async cleanup(): Promise<void> {
|
||||||
this.log("Cleaning up...");
|
this.log("Cleaning up...");
|
||||||
// Guard against uninitialized client (init() failed partway)
|
// Guard against uninitialized client (init() failed partway).
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// The class field uses `!:` so TS thinks this is always defined,
|
||||||
if (!this.client) {
|
// 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");
|
this.log("Client not initialized, nothing to clean up");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -183,12 +288,40 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
const isNew = !this.files.has(path);
|
const isNew = !this.files.has(path);
|
||||||
await super.write(path, content);
|
await super.write(path, content);
|
||||||
|
|
||||||
if (isNew) {
|
if (this.isSyncEnabled && isNew) {
|
||||||
this.enqueueSync(async () => { this.client.syncLocallyCreatedFile(path); }
|
this.enqueueSync(async () => {
|
||||||
);
|
this.client.syncLocallyCreatedFile(path);
|
||||||
} else {
|
});
|
||||||
this.enqueueSync(async () => { this.client.syncLocallyUpdatedFile({ relativePath: path }); }
|
}
|
||||||
|
|
||||||
|
const nextWriteRename = this.nextWriteRename;
|
||||||
|
if (
|
||||||
|
nextWriteRename !== undefined &&
|
||||||
|
nextWriteRename.oldPath === path
|
||||||
|
) {
|
||||||
|
this.nextWriteRename = undefined;
|
||||||
|
await super.rename(
|
||||||
|
nextWriteRename.oldPath,
|
||||||
|
nextWriteRename.newPath
|
||||||
);
|
);
|
||||||
|
if (this.isSyncEnabled) {
|
||||||
|
this.enqueueSync(async () => {
|
||||||
|
this.client.syncLocallyUpdatedFile({
|
||||||
|
oldPath: nextWriteRename.oldPath,
|
||||||
|
relativePath: nextWriteRename.newPath
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isSyncEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isNew) {
|
||||||
|
this.enqueueSync(async () => {
|
||||||
|
this.client.syncLocallyUpdatedFile({ relativePath: path });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,18 +330,20 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
updater: (current: TextWithCursors) => TextWithCursors
|
updater: (current: TextWithCursors) => TextWithCursors
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const result = await super.atomicUpdateText(path, updater);
|
const result = await super.atomicUpdateText(path, updater);
|
||||||
this.enqueueSync(async () => { this.client.syncLocallyUpdatedFile({ relativePath: path }); }
|
if (this.isSyncEnabled) {
|
||||||
);
|
this.enqueueSync(async () => {
|
||||||
return result;
|
this.client.syncLocallyUpdatedFile({ relativePath: path });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public override async delete(path: RelativePath): Promise<void> {
|
public override async delete(path: RelativePath): Promise<void> {
|
||||||
await super.delete(path);
|
await super.delete(path);
|
||||||
if (this.isSyncEnabled) {
|
if (this.isSyncEnabled) {
|
||||||
this.enqueueSync(async () => { this.client.syncLocallyDeletedFile(path); }
|
this.enqueueSync(async () => {
|
||||||
);
|
this.client.syncLocallyDeletedFile(path);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -217,13 +352,14 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
newPath: RelativePath
|
newPath: RelativePath
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await super.rename(oldPath, newPath);
|
await super.rename(oldPath, newPath);
|
||||||
|
if (this.isSyncEnabled) {
|
||||||
this.enqueueSync(async () => {
|
this.enqueueSync(async () => {
|
||||||
this.client.syncLocallyUpdatedFile({
|
this.client.syncLocallyUpdatedFile({
|
||||||
oldPath,
|
oldPath,
|
||||||
relativePath: newPath
|
relativePath: newPath
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async waitForWebSocket(): Promise<void> {
|
private async waitForWebSocket(): Promise<void> {
|
||||||
|
|
@ -243,7 +379,7 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
*/
|
*/
|
||||||
private async drainPendingSyncOperations(): Promise<void> {
|
private async drainPendingSyncOperations(): Promise<void> {
|
||||||
while (this.pendingSyncOperations.size > 0) {
|
while (this.pendingSyncOperations.size > 0) {
|
||||||
await Promise.all(this.pendingSyncOperations);
|
await utils.awaitAll([...this.pendingSyncOperations]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -287,4 +423,42 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
private log(message: string): void {
|
private log(message: string): void {
|
||||||
this.logger(`[Client ${this.clientId}] ${message}`);
|
this.logger(`[Client ${this.clientId}] ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private wrapFetch(
|
||||||
|
fetchImplementation: typeof globalThis.fetch
|
||||||
|
): typeof globalThis.fetch {
|
||||||
|
return async (input, init) => {
|
||||||
|
const response = await fetchImplementation(input, init);
|
||||||
|
const drop = this.nextCreateResponseDrop;
|
||||||
|
if (
|
||||||
|
drop !== undefined &&
|
||||||
|
DeterministicAgent.isCreateDocumentRequest(input, init)
|
||||||
|
) {
|
||||||
|
this.nextCreateResponseDrop = undefined;
|
||||||
|
drop.resolveDropped();
|
||||||
|
throw new SyncResetError();
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static isCreateDocumentRequest(
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
init: RequestInit | undefined
|
||||||
|
): boolean {
|
||||||
|
const method =
|
||||||
|
init?.method ??
|
||||||
|
(typeof Request !== "undefined" && input instanceof Request
|
||||||
|
? input.method
|
||||||
|
: "GET");
|
||||||
|
if (method.toUpperCase() !== "POST") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url =
|
||||||
|
input instanceof URL
|
||||||
|
? input
|
||||||
|
: new URL(typeof input === "string" ? input : input.url);
|
||||||
|
return /\/documents\/?$/.test(url.pathname);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,129 @@
|
||||||
* A WebSocket wrapper that can pause and resume message delivery.
|
* A WebSocket wrapper that can pause and resume message delivery.
|
||||||
* When paused, incoming messages are buffered. When resumed, buffered
|
* When paused, incoming messages are buffered. When resumed, buffered
|
||||||
* messages are delivered in order via the onmessage handler.
|
* 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.
|
||||||
*/
|
*/
|
||||||
export class ManagedWebSocket implements WebSocket {
|
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 ws: WebSocket;
|
||||||
private paused = false;
|
|
||||||
private readonly bufferedMessages: MessageEvent[] = [];
|
private readonly bufferedMessages: MessageEvent[] = [];
|
||||||
|
private paused = false;
|
||||||
private externalOnMessage: ((event: MessageEvent) => unknown) | null = null;
|
private externalOnMessage: ((event: MessageEvent) => unknown) | null = null;
|
||||||
|
|
||||||
public constructor(url: string | URL, protocols?: string | string[]) {
|
public constructor(url: string | URL, protocols?: string | string[]) {
|
||||||
this.ws = new WebSocket(url, protocols);
|
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 => {
|
this.ws.onmessage = (event: MessageEvent): void => {
|
||||||
if (this.paused) {
|
if (this.paused) {
|
||||||
this.bufferedMessages.push(event);
|
this.bufferedMessages.push(event);
|
||||||
|
|
@ -33,68 +146,6 @@ export class ManagedWebSocket implements WebSocket {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get readyState(): number {
|
|
||||||
return this.ws.readyState;
|
|
||||||
}
|
|
||||||
|
|
||||||
get url(): string {
|
|
||||||
return this.ws.url;
|
|
||||||
}
|
|
||||||
|
|
||||||
get protocol(): string {
|
|
||||||
return this.ws.protocol;
|
|
||||||
}
|
|
||||||
|
|
||||||
get extensions(): string {
|
|
||||||
return this.ws.extensions;
|
|
||||||
}
|
|
||||||
|
|
||||||
get bufferedAmount(): number {
|
|
||||||
return this.ws.bufferedAmount;
|
|
||||||
}
|
|
||||||
|
|
||||||
get binaryType(): BinaryType {
|
|
||||||
return this.ws.binaryType;
|
|
||||||
}
|
|
||||||
|
|
||||||
set binaryType(value: BinaryType) {
|
|
||||||
this.ws.binaryType = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
get onopen(): ((this: WebSocket, ev: Event) => unknown) | null {
|
|
||||||
return this.ws.onopen;
|
|
||||||
}
|
|
||||||
|
|
||||||
set onopen(handler: ((this: WebSocket, ev: Event) => unknown) | null) {
|
|
||||||
this.ws.onopen = handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
get onclose(): ((this: WebSocket, ev: CloseEvent) => unknown) | null {
|
|
||||||
return this.ws.onclose;
|
|
||||||
}
|
|
||||||
|
|
||||||
set onclose(handler: ((this: WebSocket, ev: CloseEvent) => unknown) | null) {
|
|
||||||
this.ws.onclose = handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
get onerror(): ((this: WebSocket, ev: Event) => unknown) | null {
|
|
||||||
return this.ws.onerror;
|
|
||||||
}
|
|
||||||
|
|
||||||
set onerror(handler: ((this: WebSocket, ev: Event) => unknown) | null) {
|
|
||||||
this.ws.onerror = handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
get onmessage(): ((this: WebSocket, ev: MessageEvent) => unknown) | null {
|
|
||||||
return this.externalOnMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
set onmessage(
|
|
||||||
handler: ((this: WebSocket, ev: MessageEvent) => unknown) | null
|
|
||||||
) {
|
|
||||||
this.externalOnMessage = handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
public send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
|
public send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
|
||||||
this.ws.send(data);
|
this.ws.send(data);
|
||||||
}
|
}
|
||||||
|
|
@ -118,16 +169,6 @@ export class ManagedWebSocket implements WebSocket {
|
||||||
public dispatchEvent(event: Event): boolean {
|
public dispatchEvent(event: Event): boolean {
|
||||||
return this.ws.dispatchEvent(event);
|
return this.ws.dispatchEvent(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
static readonly CONNECTING = WebSocket.CONNECTING;
|
|
||||||
static readonly OPEN = WebSocket.OPEN;
|
|
||||||
static readonly CLOSING = WebSocket.CLOSING;
|
|
||||||
static readonly CLOSED = WebSocket.CLOSED;
|
|
||||||
|
|
||||||
readonly CONNECTING = WebSocket.CONNECTING;
|
|
||||||
readonly OPEN = WebSocket.OPEN;
|
|
||||||
readonly CLOSING = WebSocket.CLOSING;
|
|
||||||
readonly CLOSED = WebSocket.CLOSED;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -136,33 +177,41 @@ export class ManagedWebSocket implements WebSocket {
|
||||||
*/
|
*/
|
||||||
export class ManagedWebSocketFactory {
|
export class ManagedWebSocketFactory {
|
||||||
private readonly instances: ManagedWebSocket[] = [];
|
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 {
|
public get constructorFn(): typeof globalThis.WebSocket {
|
||||||
const factory = this;
|
const trackInstance = (instance: ManagedWebSocket): void => {
|
||||||
const ctor = function ManagedWS(
|
this.instances.push(instance);
|
||||||
|
if (this.currentlyPaused) {
|
||||||
|
instance.pause();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
class TrackedManagedWebSocket extends ManagedWebSocket {
|
||||||
|
public constructor(
|
||||||
url: string | URL,
|
url: string | URL,
|
||||||
protocols?: string | string[]
|
protocols?: string | string[]
|
||||||
): ManagedWebSocket {
|
) {
|
||||||
const ws = new ManagedWebSocket(url, protocols);
|
super(url, protocols);
|
||||||
factory.instances.push(ws);
|
trackInstance(this);
|
||||||
return ws;
|
}
|
||||||
} as unknown as typeof globalThis.WebSocket;
|
}
|
||||||
|
return TrackedManagedWebSocket;
|
||||||
Object.defineProperty(ctor, "CONNECTING", { value: WebSocket.CONNECTING });
|
|
||||||
Object.defineProperty(ctor, "OPEN", { value: WebSocket.OPEN });
|
|
||||||
Object.defineProperty(ctor, "CLOSING", { value: WebSocket.CLOSING });
|
|
||||||
Object.defineProperty(ctor, "CLOSED", { value: WebSocket.CLOSED });
|
|
||||||
|
|
||||||
return ctor;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public pause(): void {
|
public pause(): void {
|
||||||
|
this.currentlyPaused = true;
|
||||||
for (const ws of this.instances) {
|
for (const ws of this.instances) {
|
||||||
ws.pause();
|
ws.pause();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public resume(): void {
|
public resume(): void {
|
||||||
|
this.currentlyPaused = false;
|
||||||
for (const ws of this.instances) {
|
for (const ws of this.instances) {
|
||||||
ws.resume();
|
ws.resume();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@ export function parseConcurrency(): number {
|
||||||
i + 1 < args.length
|
i + 1 < args.length
|
||||||
) {
|
) {
|
||||||
const n = parseInt(args[i + 1], 10);
|
const n = parseInt(args[i + 1], 10);
|
||||||
if (!isNaN(n) && n > 0) return n;
|
if (!isNaN(n) && n > 0) {
|
||||||
|
return n;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return os.cpus().length;
|
return os.cpus().length;
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,7 @@ export class ServerControl {
|
||||||
this._port = reservation.port;
|
this._port = reservation.port;
|
||||||
// Prefer tmpfs (/host/tmp) over disk-backed /tmp for faster SQLite I/O
|
// Prefer tmpfs (/host/tmp) over disk-backed /tmp for faster SQLite I/O
|
||||||
const tmpBase = fs.existsSync("/host/tmp") ? "/host/tmp" : os.tmpdir();
|
const tmpBase = fs.existsSync("/host/tmp") ? "/host/tmp" : os.tmpdir();
|
||||||
this.tempDir = fs.mkdtempSync(
|
this.tempDir = fs.mkdtempSync(path.join(tmpBase, "vault-link-test-"));
|
||||||
path.join(tmpBase, "vault-link-test-")
|
|
||||||
);
|
|
||||||
const tempConfigPath = path.join(this.tempDir, "config.yml");
|
const tempConfigPath = path.join(this.tempDir, "config.yml");
|
||||||
const dbDir = path.join(this.tempDir, "databases");
|
const dbDir = path.join(this.tempDir, "databases");
|
||||||
|
|
||||||
|
|
@ -225,7 +223,7 @@ export class ServerControl {
|
||||||
}
|
}
|
||||||
|
|
||||||
private cleanupTempDir(): void {
|
private cleanupTempDir(): void {
|
||||||
if (this.tempDir) {
|
if (this.tempDir !== undefined) {
|
||||||
try {
|
try {
|
||||||
fs.rmSync(this.tempDir, { recursive: true, force: true });
|
fs.rmSync(this.tempDir, { recursive: true, force: true });
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -234,5 +232,4 @@ export class ServerControl {
|
||||||
this.tempDir = undefined;
|
this.tempDir = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,9 @@ export class ServerManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async stopAll(): Promise<void> {
|
public async stopAll(): Promise<void> {
|
||||||
if (this.isShuttingDown) return;
|
if (this.isShuttingDown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.isShuttingDown = true;
|
this.isShuttingDown = true;
|
||||||
|
|
||||||
const servers = Array.from(this.activeServers);
|
const servers = Array.from(this.activeServers);
|
||||||
|
|
@ -39,14 +41,18 @@ export class ServerManager {
|
||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
this.logger.info("Received SIGINT, shutting down...");
|
this.logger.info("Received SIGINT, shutting down...");
|
||||||
void this.stopAll()
|
void this.stopAll()
|
||||||
.catch(() => {})
|
.catch(() => {
|
||||||
|
/* no-op */
|
||||||
|
})
|
||||||
.then(() => process.exit(130));
|
.then(() => process.exit(130));
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on("SIGTERM", () => {
|
process.on("SIGTERM", () => {
|
||||||
this.logger.info("Received SIGTERM, shutting down...");
|
this.logger.info("Received SIGTERM, shutting down...");
|
||||||
void this.stopAll()
|
void this.stopAll()
|
||||||
.catch(() => {})
|
.catch(() => {
|
||||||
|
/* no-op */
|
||||||
|
})
|
||||||
.then(() => process.exit(143));
|
.then(() => process.exit(143));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,32 @@ export type TestStep =
|
||||||
| { type: "create"; client: number; path: string; content: string }
|
| { type: "create"; client: number; path: string; content: string }
|
||||||
| { type: "update"; client: number; path: string; content: string }
|
| { type: "update"; client: number; path: string; content: string }
|
||||||
| { type: "rename"; client: number; oldPath: string; newPath: 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: "delete"; client: number; path: string }
|
||||||
| { type: "sync"; client?: number }
|
| { type: "sync"; client?: number }
|
||||||
| { type: "disable-sync"; client: number }
|
| { type: "disable-sync"; client: number }
|
||||||
| { type: "enable-sync"; client: number }
|
| { type: "enable-sync"; client: number }
|
||||||
| { type: "pause-server" }
|
| { type: "pause-server" }
|
||||||
| { type: "resume-server" }
|
| { type: "resume-server" }
|
||||||
|
| {
|
||||||
|
type: "resume-server-until-history-then-pause";
|
||||||
|
client: number;
|
||||||
|
syncType: "CREATE" | "UPDATE" | "DELETE";
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
| { type: "barrier" }
|
| { type: "barrier" }
|
||||||
| { type: "assert-consistent"; verify?: (state: AssertableState) => void }
|
| { type: "assert-consistent"; verify?: (state: AssertableState) => void }
|
||||||
| { type: "pause-websocket"; client: number }
|
| { type: "pause-websocket"; client: number }
|
||||||
| { type: "resume-websocket"; client: number };
|
| { type: "resume-websocket"; client: number }
|
||||||
|
| { type: "drop-next-create-response"; client: number }
|
||||||
|
| { type: "wait-for-dropped-create-response"; client: number }
|
||||||
|
| { type: "sleep"; ms: number }
|
||||||
|
| { type: "reset"; client: number };
|
||||||
|
|
||||||
export interface TestDefinition {
|
export interface TestDefinition {
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { deleteRenameConflictTest } from "./tests/delete-rename-conflict.test";
|
||||||
import { multiFileOperationsTest } from "./tests/multi-file-operations.test";
|
import { multiFileOperationsTest } from "./tests/multi-file-operations.test";
|
||||||
import { deleteRecreateSamePathTest } from "./tests/delete-recreate-same-path.test";
|
import { deleteRecreateSamePathTest } from "./tests/delete-recreate-same-path.test";
|
||||||
import { offlineRenameAndEditTest } from "./tests/offline-rename-and-edit.test";
|
import { offlineRenameAndEditTest } from "./tests/offline-rename-and-edit.test";
|
||||||
import { renameToExistingPathTest } from "./tests/rename-to-existing-path.test";
|
|
||||||
import { simultaneousCreateDeleteSamePathTest } from "./tests/simultaneous-create-delete-same-path.test";
|
import { simultaneousCreateDeleteSamePathTest } from "./tests/simultaneous-create-delete-same-path.test";
|
||||||
import { idempotencyAfterServerPauseTest } from "./tests/idempotency-after-server-pause.test";
|
import { idempotencyAfterServerPauseTest } from "./tests/idempotency-after-server-pause.test";
|
||||||
import { sequentialCreateDuplicateContentTest } from "./tests/sequential-create-duplicate-content.test";
|
import { sequentialCreateDuplicateContentTest } from "./tests/sequential-create-duplicate-content.test";
|
||||||
|
|
@ -26,7 +25,6 @@ import { offlineRenameRemoteCreateOldPathTest } from "./tests/offline-rename-rem
|
||||||
import { offlineEditRemoteRenameTest } from "./tests/offline-edit-remote-rename.test";
|
import { offlineEditRemoteRenameTest } from "./tests/offline-edit-remote-rename.test";
|
||||||
import { renameChainThenDeleteTest } from "./tests/rename-chain-then-delete.test";
|
import { renameChainThenDeleteTest } from "./tests/rename-chain-then-delete.test";
|
||||||
import { offlineDeleteRemoteRenameTest } from "./tests/offline-delete-remote-rename.test";
|
import { offlineDeleteRemoteRenameTest } from "./tests/offline-delete-remote-rename.test";
|
||||||
import { renameToRecentlyDeletedPathTest } from "./tests/rename-to-recently-deleted-path.test";
|
|
||||||
import { overlappingEditsSameSectionTest } from "./tests/overlapping-edits-same-section.test";
|
import { overlappingEditsSameSectionTest } from "./tests/overlapping-edits-same-section.test";
|
||||||
import { rapidUpdatesAfterMergeTest } from "./tests/rapid-updates-after-merge.test";
|
import { rapidUpdatesAfterMergeTest } from "./tests/rapid-updates-after-merge.test";
|
||||||
import { deleteRecreateConcurrentUpdateTest } from "./tests/delete-recreate-concurrent-update.test";
|
import { deleteRecreateConcurrentUpdateTest } from "./tests/delete-recreate-concurrent-update.test";
|
||||||
|
|
@ -53,7 +51,6 @@ import { updateDoesNotSurvivesRemoteDeleteTest } from "./tests/update-survives-r
|
||||||
import { movePreservesRemoteUpdateTest } from "./tests/move-preserves-remote-update.test";
|
import { movePreservesRemoteUpdateTest } from "./tests/move-preserves-remote-update.test";
|
||||||
import { recentlyDeletedClearedOnReconnectTest } from "./tests/recently-deleted-cleared-on-reconnect.test";
|
import { recentlyDeletedClearedOnReconnectTest } from "./tests/recently-deleted-cleared-on-reconnect.test";
|
||||||
import { migrateKeyPreservesExistingTest } from "./tests/migrate-key-preserves-existing.test";
|
import { migrateKeyPreservesExistingTest } from "./tests/migrate-key-preserves-existing.test";
|
||||||
import { failedVfsMoveFallsBackTest } from "./tests/failed-vfs-move-falls-back.test";
|
|
||||||
import { watermarkAdvancesOnSkipTest } from "./tests/watermark-advances-on-skip.test";
|
import { watermarkAdvancesOnSkipTest } from "./tests/watermark-advances-on-skip.test";
|
||||||
import { watermarkGapRemoteUpdateNotRecordedTest } from "./tests/watermark-gap-remote-update-not-recorded.test";
|
import { watermarkGapRemoteUpdateNotRecordedTest } from "./tests/watermark-gap-remote-update-not-recorded.test";
|
||||||
import { queueResetLosesCoalescedLocalEditTest } from "./tests/queue-reset-loses-coalesced-local-edit.test";
|
import { queueResetLosesCoalescedLocalEditTest } from "./tests/queue-reset-loses-coalesced-local-edit.test";
|
||||||
|
|
@ -92,6 +89,23 @@ import { serverPauseDeleteRecreateTest } from "./tests/server-pause-delete-recre
|
||||||
import { onlineBothCreateSamePathDeconflictTest } from "./tests/online-both-create-same-path-deconflict.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 { onlineCreateUpdateWhileOtherCreatesSamePathTest } from "./tests/online-create-update-while-other-creates-same-path.test";
|
||||||
import { displacedFileNotMarkedDeletedTest } from "./tests/displaced-file-not-marked-deleted.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>> = {
|
export const TESTS: Partial<Record<string, TestDefinition>> = {
|
||||||
"rename-create-conflict": renameCreateConflictTest,
|
"rename-create-conflict": renameCreateConflictTest,
|
||||||
|
|
@ -101,11 +115,12 @@ export const TESTS: Partial<Record<string, TestDefinition>> = {
|
||||||
"multi-file-operations": multiFileOperationsTest,
|
"multi-file-operations": multiFileOperationsTest,
|
||||||
"delete-recreate-same-path": deleteRecreateSamePathTest,
|
"delete-recreate-same-path": deleteRecreateSamePathTest,
|
||||||
"offline-rename-and-edit": offlineRenameAndEditTest,
|
"offline-rename-and-edit": offlineRenameAndEditTest,
|
||||||
"rename-to-existing-path": renameToExistingPathTest,
|
"simultaneous-create-delete-same-path":
|
||||||
"simultaneous-create-delete-same-path": simultaneousCreateDeleteSamePathTest,
|
simultaneousCreateDeleteSamePathTest,
|
||||||
"idempotency-after-server-pause": idempotencyAfterServerPauseTest,
|
"idempotency-after-server-pause": idempotencyAfterServerPauseTest,
|
||||||
"sequential-create-duplicate-content": sequentialCreateDuplicateContentTest,
|
"sequential-create-duplicate-content": sequentialCreateDuplicateContentTest,
|
||||||
"mc-three-client-rename-offline-update": mcThreeClientRenameOfflineUpdateTest,
|
"mc-three-client-rename-offline-update":
|
||||||
|
mcThreeClientRenameOfflineUpdateTest,
|
||||||
"mc-multi-delete-offline-rename": mcMultiDeleteOfflineRenameTest,
|
"mc-multi-delete-offline-rename": mcMultiDeleteOfflineRenameTest,
|
||||||
"mc-cross-create-rename-same-target": mcCrossCreateRenameSameTargetTest,
|
"mc-cross-create-rename-same-target": mcCrossCreateRenameSameTargetTest,
|
||||||
"mc-delete-then-offline-rename": mcDeleteThenOfflineRenameTest,
|
"mc-delete-then-offline-rename": mcDeleteThenOfflineRenameTest,
|
||||||
|
|
@ -117,11 +132,11 @@ export const TESTS: Partial<Record<string, TestDefinition>> = {
|
||||||
"rename-swap": renameSwapTest,
|
"rename-swap": renameSwapTest,
|
||||||
"rename-circular": renameCircularTest,
|
"rename-circular": renameCircularTest,
|
||||||
"rename-roundtrip": renameRoundtripTest,
|
"rename-roundtrip": renameRoundtripTest,
|
||||||
"offline-rename-remote-create-old-path": offlineRenameRemoteCreateOldPathTest,
|
"offline-rename-remote-create-old-path":
|
||||||
|
offlineRenameRemoteCreateOldPathTest,
|
||||||
"offline-edit-remote-rename": offlineEditRemoteRenameTest,
|
"offline-edit-remote-rename": offlineEditRemoteRenameTest,
|
||||||
"rename-chain-then-delete": renameChainThenDeleteTest,
|
"rename-chain-then-delete": renameChainThenDeleteTest,
|
||||||
"offline-delete-remote-rename": offlineDeleteRemoteRenameTest,
|
"offline-delete-remote-rename": offlineDeleteRemoteRenameTest,
|
||||||
"rename-to-recently-deleted-path": renameToRecentlyDeletedPathTest,
|
|
||||||
"overlapping-edits-same-section": overlappingEditsSameSectionTest,
|
"overlapping-edits-same-section": overlappingEditsSameSectionTest,
|
||||||
"rapid-updates-after-merge": rapidUpdatesAfterMergeTest,
|
"rapid-updates-after-merge": rapidUpdatesAfterMergeTest,
|
||||||
"delete-recreate-concurrent-update": deleteRecreateConcurrentUpdateTest,
|
"delete-recreate-concurrent-update": deleteRecreateConcurrentUpdateTest,
|
||||||
|
|
@ -140,34 +155,44 @@ export const TESTS: Partial<Record<string, TestDefinition>> = {
|
||||||
"delete-recreate-different-content": deleteRecreateDifferentContentTest,
|
"delete-recreate-different-content": deleteRecreateDifferentContentTest,
|
||||||
"update-during-create-processing": updateDuringCreateProcessingTest,
|
"update-during-create-processing": updateDuringCreateProcessingTest,
|
||||||
"offline-move-then-remote-delete": offlineMoveThenRemoteDeleteTest,
|
"offline-move-then-remote-delete": offlineMoveThenRemoteDeleteTest,
|
||||||
"reset-clears-recently-deleted-resurrection": resetClearsRecentlyDeletedResurrectionTest,
|
"reset-clears-recently-deleted-resurrection":
|
||||||
|
resetClearsRecentlyDeletedResurrectionTest,
|
||||||
"move-then-delete-stale-path": moveThenDeleteStalePathTest,
|
"move-then-delete-stale-path": moveThenDeleteStalePathTest,
|
||||||
"offline-delete-vs-remote-update": offlineDeleteVsRemoteUpdateTest,
|
"offline-delete-vs-remote-update": offlineDeleteVsRemoteUpdateTest,
|
||||||
"interrupted-delete-retry": interruptedDeleteRetryTest,
|
"interrupted-delete-retry": interruptedDeleteRetryTest,
|
||||||
"update-survives-remote-delete": updateDoesNotSurvivesRemoteDeleteTest,
|
"update-survives-remote-delete": updateDoesNotSurvivesRemoteDeleteTest,
|
||||||
"move-preserves-remote-update": movePreservesRemoteUpdateTest,
|
"move-preserves-remote-update": movePreservesRemoteUpdateTest,
|
||||||
"recently-deleted-cleared-on-reconnect": recentlyDeletedClearedOnReconnectTest,
|
"recently-deleted-cleared-on-reconnect":
|
||||||
|
recentlyDeletedClearedOnReconnectTest,
|
||||||
"migrate-key-preserves-existing": migrateKeyPreservesExistingTest,
|
"migrate-key-preserves-existing": migrateKeyPreservesExistingTest,
|
||||||
"failed-vfs-move-falls-back": failedVfsMoveFallsBackTest,
|
|
||||||
"watermark-advances-on-skip": watermarkAdvancesOnSkipTest,
|
"watermark-advances-on-skip": watermarkAdvancesOnSkipTest,
|
||||||
"watermark-gap-remote-update-not-recorded": watermarkGapRemoteUpdateNotRecordedTest,
|
"watermark-gap-remote-update-not-recorded":
|
||||||
"queue-reset-loses-coalesced-local-edit": queueResetLosesCoalescedLocalEditTest,
|
watermarkGapRemoteUpdateNotRecordedTest,
|
||||||
|
"queue-reset-loses-coalesced-local-edit":
|
||||||
|
queueResetLosesCoalescedLocalEditTest,
|
||||||
"rename-to-pending-path-fallback": renameToPendingPathFallbackTest,
|
"rename-to-pending-path-fallback": renameToPendingPathFallbackTest,
|
||||||
"move-remote-update-reverts-rename": moveRemoteUpdateRevertsRenameTest,
|
"move-remote-update-reverts-rename": moveRemoteUpdateRevertsRenameTest,
|
||||||
"local-edit-lost-during-create-merge": localEditLostDuringCreateMergeTest,
|
"local-edit-lost-during-create-merge": localEditLostDuringCreateMergeTest,
|
||||||
"rename-pending-create-before-response": renamePendingCreateBeforeResponseTest,
|
"rename-pending-create-before-response":
|
||||||
|
renamePendingCreateBeforeResponseTest,
|
||||||
"create-rename-response-skips-file": createRenameResponseSkipsFileTest,
|
"create-rename-response-skips-file": createRenameResponseSkipsFileTest,
|
||||||
"online-create-rename-concurrent-create-orphan": onlineCreateRenameConcurrentCreateOrphanTest,
|
"online-create-rename-concurrent-create-orphan":
|
||||||
|
onlineCreateRenameConcurrentCreateOrphanTest,
|
||||||
"concurrent-rename-first-wins": concurrentRenameFirstWinsTest,
|
"concurrent-rename-first-wins": concurrentRenameFirstWinsTest,
|
||||||
"binary-to-text-transition": binaryToTextTransitionTest,
|
"binary-to-text-transition": binaryToTextTransitionTest,
|
||||||
"text-pending-create-not-displaced": textPendingCreateNotDisplacedTest,
|
"text-pending-create-not-displaced": textPendingCreateNotDisplacedTest,
|
||||||
"binary-pending-create-not-displaced": binaryPendingCreateNotDisplacedTest,
|
"binary-pending-create-not-displaced": binaryPendingCreateNotDisplacedTest,
|
||||||
"coalesce-update-remote-update-data-loss": coalesceUpdateRemoteUpdateDataLossTest,
|
"coalesce-update-remote-update-data-loss":
|
||||||
"coalesced-remote-update-watermark-loss": coalescedRemoteUpdateWatermarkLossTest,
|
coalesceUpdateRemoteUpdateDataLossTest,
|
||||||
"concurrent-delete-during-remote-update": concurrentDeleteDuringRemoteUpdateTest,
|
"coalesced-remote-update-watermark-loss":
|
||||||
|
coalescedRemoteUpdateWatermarkLossTest,
|
||||||
|
"concurrent-delete-during-remote-update":
|
||||||
|
concurrentDeleteDuringRemoteUpdateTest,
|
||||||
"concurrent-edit-exact-same-position": concurrentEditExactSamePositionTest,
|
"concurrent-edit-exact-same-position": concurrentEditExactSamePositionTest,
|
||||||
"concurrent-rename-and-create-at-target-rename-first": concurrentRenameAndCreateAtTargetRenameFirstTest,
|
"concurrent-rename-and-create-at-target-rename-first":
|
||||||
"concurrent-rename-and-create-at-target-create-first": concurrentRenameAndCreateAtTargetCreateFirstTest,
|
concurrentRenameAndCreateAtTargetRenameFirstTest,
|
||||||
|
"concurrent-rename-and-create-at-target-create-first":
|
||||||
|
concurrentRenameAndCreateAtTargetCreateFirstTest,
|
||||||
"concurrent-rename-same-target": concurrentRenameSameTargetTest,
|
"concurrent-rename-same-target": concurrentRenameSameTargetTest,
|
||||||
"concurrent-update-diff-consistency": concurrentUpdateDiffConsistencyTest,
|
"concurrent-update-diff-consistency": concurrentUpdateDiffConsistencyTest,
|
||||||
"user-parenthesized-file-not-deleted": userParenthesizedFileNotDeletedTest,
|
"user-parenthesized-file-not-deleted": userParenthesizedFileNotDeletedTest,
|
||||||
|
|
@ -176,15 +201,49 @@ export const TESTS: Partial<Record<string, TestDefinition>> = {
|
||||||
"move-identical-content-ambiguity": moveIdenticalContentAmbiguityTest,
|
"move-identical-content-ambiguity": moveIdenticalContentAmbiguityTest,
|
||||||
"create-update-coalesce-server-pause": createUpdateCoalesceServerPauseTest,
|
"create-update-coalesce-server-pause": createUpdateCoalesceServerPauseTest,
|
||||||
"create-during-reconciliation": createDuringReconciliationTest,
|
"create-during-reconciliation": createDuringReconciliationTest,
|
||||||
"create-merge-preserves-renamed-update": createMergePreservesRenamedUpdateTest,
|
"create-merge-preserves-renamed-update":
|
||||||
|
createMergePreservesRenamedUpdateTest,
|
||||||
"create-rename-create-same-path": createRenameCreateSamePathTest,
|
"create-rename-create-same-path": createRenameCreateSamePathTest,
|
||||||
"move-chain-three-files": moveChainThreeFilesTest,
|
"move-chain-three-files": moveChainThreeFilesTest,
|
||||||
"delete-by-other-client-then-recreate": deleteByOtherClientThenRecreateTest,
|
"delete-by-other-client-then-recreate": deleteByOtherClientThenRecreateTest,
|
||||||
"online-delete-recreate-rapid-cycle": onlineDeleteRecreateRapidCycleTest,
|
"online-delete-recreate-rapid-cycle": onlineDeleteRecreateRapidCycleTest,
|
||||||
"online-edit-vs-delete-convergence": onlineEditVsDeleteConvergenceTest,
|
"online-edit-vs-delete-convergence": onlineEditVsDeleteConvergenceTest,
|
||||||
"rapid-edit-delete-online-convergence": rapidEditDeleteOnlineConvergenceTest,
|
"rapid-edit-delete-online-convergence":
|
||||||
|
rapidEditDeleteOnlineConvergenceTest,
|
||||||
"server-pause-delete-recreate": serverPauseDeleteRecreateTest,
|
"server-pause-delete-recreate": serverPauseDeleteRecreateTest,
|
||||||
"online-both-create-same-path-deconflict": onlineBothCreateSamePathDeconflictTest,
|
"online-both-create-same-path-deconflict":
|
||||||
"online-create-update-while-other-creates-same-path": onlineCreateUpdateWhileOtherCreatesSamePathTest,
|
onlineBothCreateSamePathDeconflictTest,
|
||||||
|
"online-create-update-while-other-creates-same-path":
|
||||||
|
onlineCreateUpdateWhileOtherCreatesSamePathTest,
|
||||||
"displaced-file-not-marked-deleted": displacedFileNotMarkedDeletedTest,
|
"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,8 +1,4 @@
|
||||||
import type {
|
import type { TestDefinition, TestResult, TestStep } from "./test-definition";
|
||||||
TestDefinition,
|
|
||||||
TestResult,
|
|
||||||
TestStep
|
|
||||||
} from "./test-definition";
|
|
||||||
import { DeterministicAgent } from "./deterministic-agent";
|
import { DeterministicAgent } from "./deterministic-agent";
|
||||||
import type { ServerControl } from "./server-control";
|
import type { ServerControl } from "./server-control";
|
||||||
import type { SyncSettings, Logger } from "sync-client";
|
import type { SyncSettings, Logger } from "sync-client";
|
||||||
|
|
@ -113,9 +109,7 @@ export class TestRunner {
|
||||||
// Push before init so cleanup() handles this agent if init fails
|
// Push before init so cleanup() handles this agent if init fails
|
||||||
this.agents.push(agent);
|
this.agents.push(agent);
|
||||||
await withTimeout(
|
await withTimeout(
|
||||||
agent.init(
|
agent.init(fetch),
|
||||||
fetch,
|
|
||||||
),
|
|
||||||
AGENT_INIT_TIMEOUT_MS,
|
AGENT_INIT_TIMEOUT_MS,
|
||||||
`Client ${i} init timed out after ${AGENT_INIT_TIMEOUT_MS}ms`
|
`Client ${i} init timed out after ${AGENT_INIT_TIMEOUT_MS}ms`
|
||||||
);
|
);
|
||||||
|
|
@ -150,6 +144,13 @@ export class TestRunner {
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "rename-next-write":
|
||||||
|
this.getAgent(step.client).renameNextWrite(
|
||||||
|
step.oldPath,
|
||||||
|
step.newPath
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
case "delete":
|
case "delete":
|
||||||
await this.getAgent(step.client).delete(step.path);
|
await this.getAgent(step.client).delete(step.path);
|
||||||
break;
|
break;
|
||||||
|
|
@ -183,6 +184,19 @@ export class TestRunner {
|
||||||
await this.serverControl.waitForReady();
|
await this.serverControl.waitForReady();
|
||||||
break;
|
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":
|
case "barrier":
|
||||||
await this.waitForConvergence();
|
await this.waitForConvergence();
|
||||||
break;
|
break;
|
||||||
|
|
@ -199,6 +213,22 @@ export class TestRunner {
|
||||||
this.getAgent(step.client).resumeWebSocket();
|
this.getAgent(step.client).resumeWebSocket();
|
||||||
break;
|
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: {
|
default: {
|
||||||
const unknownStep = step as { type: string };
|
const unknownStep = step as { type: string };
|
||||||
throw new Error(`Unknown step type: ${unknownStep.type}`);
|
throw new Error(`Unknown step type: ${unknownStep.type}`);
|
||||||
|
|
@ -276,7 +306,10 @@ export class TestRunner {
|
||||||
verify?: (state: AssertableState) => void
|
verify?: (state: AssertableState) => void
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.logger.info("Asserting all clients are consistent...");
|
this.logger.info("Asserting all clients are consistent...");
|
||||||
assert(this.agents.length >= 2, "Need at least 2 agents for consistency check");
|
assert(
|
||||||
|
this.agents.length >= 2,
|
||||||
|
"Need at least 2 agents for consistency check"
|
||||||
|
);
|
||||||
|
|
||||||
// Snapshot all agents' file states upfront to minimize the window
|
// Snapshot all agents' file states upfront to minimize the window
|
||||||
// where background sync could mutate state between reads.
|
// where background sync could mutate state between reads.
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const textPendingCreateNotDisplacedTest: TestDefinition = {
|
export const textPendingCreateNotDisplacedTest: TestDefinition = {
|
||||||
|
|
@ -23,6 +24,13 @@ export const textPendingCreateNotDisplacedTest: TestDefinition = {
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertFileExists("data.txt").assertAnyFileContains("client-0", "client-1") }
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileCount(1)
|
||||||
|
.assertFileExists("data.txt")
|
||||||
|
.assertAnyFileContains("client-0", "client-1");
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const concurrentUpdateDiffConsistencyTest: TestDefinition = {
|
export const concurrentUpdateDiffConsistencyTest: TestDefinition = {
|
||||||
|
|
@ -35,6 +36,16 @@ export const concurrentUpdateDiffConsistencyTest: TestDefinition = {
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (state) => state.assertFileCount(1).assertContent("doc.md", "header by 0\nmiddle\nfooter by 1") }
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (state: AssertableState): void => {
|
||||||
|
state
|
||||||
|
.assertFileCount(1)
|
||||||
|
.assertContent(
|
||||||
|
"doc.md",
|
||||||
|
"header by 0\nmiddle\nfooter by 1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const userParenthesizedFileNotDeletedTest: TestDefinition = {
|
export const userParenthesizedFileNotDeletedTest: TestDefinition = {
|
||||||
|
|
@ -34,7 +35,7 @@ export const userParenthesizedFileNotDeletedTest: TestDefinition = {
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (state) => {
|
verify: (state: AssertableState): void => {
|
||||||
state
|
state
|
||||||
.assertFileCount(3)
|
.assertFileCount(3)
|
||||||
.assertFileExists("Chapter.bin")
|
.assertFileExists("Chapter.bin")
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const createDeleteNoopTest: TestDefinition = {
|
export const createDeleteNoopTest: TestDefinition = {
|
||||||
|
|
@ -16,6 +17,11 @@ export const createDeleteNoopTest: TestDefinition = {
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertFileNotExists("temp.md") }
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileNotExists("temp.md");
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const createMergeDeleteTest: TestDefinition = {
|
export const createMergeDeleteTest: TestDefinition = {
|
||||||
|
|
@ -16,12 +17,21 @@ export const createMergeDeleteTest: TestDefinition = {
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (state) => state.assertFileCount(1).assertContains("A.md", "from-zero", "from-one")
|
verify: (state: AssertableState): void => {
|
||||||
|
state
|
||||||
|
.assertFileCount(1)
|
||||||
|
.assertContains("A.md", "from-zero", "from-one");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{ type: "delete", client: 0, path: "A.md" },
|
{ type: "delete", client: 0, path: "A.md" },
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertFileCount(0).assertFileNotExists("A.md") }
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileCount(0).assertFileNotExists("A.md");
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const moveIdenticalContentAmbiguityTest: TestDefinition = {
|
export const moveIdenticalContentAmbiguityTest: TestDefinition = {
|
||||||
|
|
@ -31,7 +32,7 @@ export const moveIdenticalContentAmbiguityTest: TestDefinition = {
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (state) => {
|
verify: (state: AssertableState): void => {
|
||||||
state
|
state
|
||||||
.assertFileCount(1)
|
.assertFileCount(1)
|
||||||
.assertFileNotExists("A.md")
|
.assertFileNotExists("A.md")
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const createUpdateCoalesceServerPauseTest: TestDefinition = {
|
export const createUpdateCoalesceServerPauseTest: TestDefinition = {
|
||||||
|
|
@ -19,6 +20,13 @@ export const createUpdateCoalesceServerPauseTest: TestDefinition = {
|
||||||
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (state) => state.assertFileCount(1).assertContent("doc.md", "final version") }
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (state: AssertableState): void => {
|
||||||
|
state
|
||||||
|
.assertFileCount(1)
|
||||||
|
.assertContent("doc.md", "final version");
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const createDuringReconciliationTest: TestDefinition = {
|
export const createDuringReconciliationTest: TestDefinition = {
|
||||||
|
|
@ -37,7 +38,7 @@ export const createDuringReconciliationTest: TestDefinition = {
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (state) => {
|
verify: (state: AssertableState): void => {
|
||||||
state
|
state
|
||||||
.assertFileCount(3)
|
.assertFileCount(3)
|
||||||
.assertContent("A.md", "offline A")
|
.assertContent("A.md", "offline A")
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const createMergePreservesRenamedUpdateTest: TestDefinition = {
|
export const createMergePreservesRenamedUpdateTest: TestDefinition = {
|
||||||
|
|
@ -14,6 +15,13 @@ export const createMergePreservesRenamedUpdateTest: TestDefinition = {
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (state: AssertableState): void => {
|
||||||
|
state.assertContains("doc.md", "alpha", "beta");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
{ type: "disable-sync", client: 1 },
|
{ type: "disable-sync", client: 1 },
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
@ -39,6 +47,13 @@ export const createMergePreservesRenamedUpdateTest: TestDefinition = {
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (state) => state.assertContent("moved.md", "alpha beta extra-update").assertContent("doc.md", "new-content") }
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (state: AssertableState): void => {
|
||||||
|
state
|
||||||
|
.assertContent("moved.md", "alpha beta extra-update")
|
||||||
|
.assertContent("doc.md", "new-content");
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const createRenameCreateSamePathTest: TestDefinition = {
|
export const createRenameCreateSamePathTest: TestDefinition = {
|
||||||
|
|
@ -17,12 +18,11 @@ export const createRenameCreateSamePathTest: TestDefinition = {
|
||||||
|
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (state) => {
|
verify: (state: AssertableState): void => {
|
||||||
state
|
state
|
||||||
.assertFileCount(3)
|
.assertFileCount(3)
|
||||||
.assertContent("B.md", "first file")
|
.assertContent("B.md", "first file")
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const moveChainThreeFilesTest: TestDefinition = {
|
export const moveChainThreeFilesTest: TestDefinition = {
|
||||||
|
|
@ -29,7 +30,7 @@ export const moveChainThreeFilesTest: TestDefinition = {
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (state) => {
|
verify: (state: AssertableState): void => {
|
||||||
state
|
state
|
||||||
.assertFileCount(3)
|
.assertFileCount(3)
|
||||||
.assertContent("A.md", "was C")
|
.assertContent("A.md", "was C")
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const binaryPendingCreateNotDisplacedTest: TestDefinition = {
|
export const binaryPendingCreateNotDisplacedTest: TestDefinition = {
|
||||||
|
|
@ -23,6 +24,17 @@ export const binaryPendingCreateNotDisplacedTest: TestDefinition = {
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertFileCount(2).assertFileExists("data.bin").assertFileExists("data (1).bin").assertAnyFileContains("binary data from client 0", "binary data from client 1") }
|
{
|
||||||
|
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,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const coalesceUpdateRemoteUpdateDataLossTest: TestDefinition = {
|
export const coalesceUpdateRemoteUpdateDataLossTest: TestDefinition = {
|
||||||
|
|
@ -38,10 +39,14 @@ export const coalesceUpdateRemoteUpdateDataLossTest: TestDefinition = {
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (state) => {
|
verify: (state: AssertableState): void => {
|
||||||
state
|
state
|
||||||
.assertFileCount(1)
|
.assertFileCount(1)
|
||||||
.assertContains("doc.md", "client 0 addition", "client 1 addition");
|
.assertContains(
|
||||||
|
"doc.md",
|
||||||
|
"client 0 addition",
|
||||||
|
"client 1 addition"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = {
|
export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = {
|
||||||
|
|
@ -15,10 +16,14 @@ export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = {
|
||||||
{ type: "update", client: 0, path: "doc.md", content: "update 1" },
|
{ 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: "update 2" },
|
||||||
{ type: "update", client: 0, path: "doc.md", content: "final update" },
|
{ type: "update", client: 0, path: "doc.md", content: "final update" },
|
||||||
{ type: "sync", client: 0 },
|
|
||||||
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("doc.md", "final update") },
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileCount(1).assertContent("doc.md", "final update");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
{ type: "disable-sync", client: 0 },
|
{ type: "disable-sync", client: 0 },
|
||||||
{ type: "disable-sync", client: 1 },
|
{ type: "disable-sync", client: 1 },
|
||||||
|
|
@ -26,13 +31,23 @@ export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = {
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("doc.md", "final update") },
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileCount(1).assertContent("doc.md", "final update");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
{ type: "disable-sync", client: 0 },
|
{ type: "disable-sync", client: 0 },
|
||||||
{ type: "disable-sync", client: 1 },
|
{ type: "disable-sync", client: 1 },
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("doc.md", "final update") }
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileCount(1).assertContent("doc.md", "final update");
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const concurrentDeleteDuringRemoteUpdateTest: TestDefinition = {
|
export const concurrentDeleteDuringRemoteUpdateTest: TestDefinition = {
|
||||||
|
|
@ -21,7 +22,11 @@ export const concurrentDeleteDuringRemoteUpdateTest: TestDefinition = {
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (state) => state.assertFileCount(0) }
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (state: AssertableState): void => {
|
||||||
|
state.assertFileCount(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const concurrentEditExactSamePositionTest: TestDefinition = {
|
export const concurrentEditExactSamePositionTest: TestDefinition = {
|
||||||
|
|
@ -38,7 +39,7 @@ export const concurrentEditExactSamePositionTest: TestDefinition = {
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (state) => {
|
verify: (state: AssertableState): void => {
|
||||||
state
|
state
|
||||||
.assertFileCount(1)
|
.assertFileCount(1)
|
||||||
.assertContains("doc.md", "slow", "fast", "brown fox");
|
.assertContains("doc.md", "slow", "fast", "brown fox");
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const concurrentRenameAndCreateAtTargetTest: TestDefinition = {
|
export const concurrentRenameAndCreateAtTargetTest: TestDefinition = {
|
||||||
description:
|
description:
|
||||||
"One client renames X to Y while another creates a new file at Y, " +
|
"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 offline. We can't merge the create because it would result in a cycle",
|
||||||
"both the renamed file and the newly created file.",
|
|
||||||
clients: 2,
|
clients: 2,
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
|
|
@ -37,10 +37,15 @@ export const concurrentRenameAndCreateAtTargetTest: TestDefinition = {
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (state) => {
|
verify: (state: AssertableState): void => {
|
||||||
state
|
state
|
||||||
.assertFileNotExists("X.md")
|
.assertFileNotExists("X.md")
|
||||||
.assertContains("Y.md", "original file X", "brand new Y content");
|
.assertFileExists("Y.md")
|
||||||
|
.assertFileExists("Y (1).md")
|
||||||
|
.assertAnyFileContains(
|
||||||
|
"original file X",
|
||||||
|
"brand new Y content"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const concurrentRenameAndCreateAtTargetTest: TestDefinition = {
|
export const concurrentRenameAndCreateAtTargetTest: TestDefinition = {
|
||||||
|
|
@ -37,7 +38,7 @@ export const concurrentRenameAndCreateAtTargetTest: TestDefinition = {
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (state) => {
|
verify: (state: AssertableState): void => {
|
||||||
state
|
state
|
||||||
.assertFileCount(2)
|
.assertFileCount(2)
|
||||||
.assertContains("Y (1).md", "original file X")
|
.assertContains("Y (1).md", "original file X")
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const concurrentRenameSameTargetTest: TestDefinition = {
|
export const concurrentRenameSameTargetTest: TestDefinition = {
|
||||||
|
|
@ -20,12 +21,11 @@ export const concurrentRenameSameTargetTest: TestDefinition = {
|
||||||
{ type: "rename", client: 1, oldPath: "B.md", newPath: "C.md" },
|
{ type: "rename", client: 1, oldPath: "B.md", newPath: "C.md" },
|
||||||
|
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync", client: 1 },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (state) => {
|
verify: (state: AssertableState): void => {
|
||||||
state
|
state
|
||||||
.assertFileCount(2)
|
.assertFileCount(2)
|
||||||
.assertFileNotExists("A.md")
|
.assertFileNotExists("A.md")
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const binaryToTextTransitionTest: TestDefinition = {
|
export const binaryToTextTransitionTest: TestDefinition = {
|
||||||
|
|
@ -8,11 +9,21 @@ export const binaryToTextTransitionTest: TestDefinition = {
|
||||||
"offline. The text merge should preserve both edits.",
|
"offline. The text merge should preserve both edits.",
|
||||||
clients: 2,
|
clients: 2,
|
||||||
steps: [
|
steps: [
|
||||||
{ type: "create", client: 0, path: "data.bin", content: "original content" },
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 0,
|
||||||
|
path: "data.bin",
|
||||||
|
content: "original content"
|
||||||
|
},
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertContent("data.bin", "original content") },
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertContent("data.bin", "original content");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
{ type: "disable-sync", client: 0 },
|
{ type: "disable-sync", client: 0 },
|
||||||
{ type: "disable-sync", client: 1 },
|
{ type: "disable-sync", client: 1 },
|
||||||
|
|
@ -24,26 +35,63 @@ export const binaryToTextTransitionTest: TestDefinition = {
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContainsAny("data.bin", "version A", "version B") },
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileCount(1).assertContainsAny(
|
||||||
|
"data.bin",
|
||||||
|
"version A",
|
||||||
|
"version B"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
{ type: "disable-sync", client: 1 },
|
{ type: "disable-sync", client: 1 },
|
||||||
{ type: "rename", client: 0, oldPath: "data.bin", newPath: "data.md" },
|
{ 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: "update",
|
||||||
|
client: 0,
|
||||||
|
path: "data.md",
|
||||||
|
content: "top line\nmiddle line\nbottom line"
|
||||||
|
},
|
||||||
{ type: "sync", client: 0 },
|
{ type: "sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertContent("data.md", "top line\nmiddle line\nbottom line") },
|
{
|
||||||
|
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: 0 },
|
||||||
{ type: "disable-sync", client: 1 },
|
{ 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: "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: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("data.md", "alpha", "beta") },
|
{
|
||||||
],
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileCount(1).assertContains("data.md", "alpha", "beta");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
|
export const catchupCreateAndUpdateNotSkippedTest: TestDefinition = {
|
||||||
|
description:
|
||||||
|
"Client 1 disconnects (sync disabled). Client 0 creates a doc and " +
|
||||||
|
"then updates it. When Client 1 reconnects, the server's catch-up " +
|
||||||
|
"stream sends only the doc's *latest* version (the update), not the " +
|
||||||
|
"full history. Pre-fix the wire's `is_new_file` was set to " +
|
||||||
|
"`creation == latest_version`, so the catch-up flagged the doc as " +
|
||||||
|
"non-new even though Client 1 had never seen its creation. Client " +
|
||||||
|
"1's `processRemoteChange` then dropped it as a 'stale RemoteChange " +
|
||||||
|
"for untracked, non-new document' and the doc was silently lost. " +
|
||||||
|
"Post-fix `is_new_file` in the catch-up stream means 'new relative " +
|
||||||
|
"to the recipient's watermark' (`creation > last_seen_vault_update_id`).",
|
||||||
|
clients: 2,
|
||||||
|
steps: [
|
||||||
|
{ type: "enable-sync", client: 0 },
|
||||||
|
{ type: "enable-sync", client: 1 },
|
||||||
|
// Establish a baseline so Client 1's last_seen is non-zero before
|
||||||
|
// we take it offline. This makes the bug genuinely about catch-up
|
||||||
|
// missing the create rather than just an empty-vault first sync.
|
||||||
|
{ type: "create", client: 0, path: "warmup.md", content: "w\n" },
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
// Client 1 goes offline.
|
||||||
|
{ type: "disable-sync", client: 1 },
|
||||||
|
|
||||||
|
// Client 0 creates the doc (vault_update_id v_C, after Client 1's
|
||||||
|
// watermark). Client 1 doesn't see this because it's offline.
|
||||||
|
{ type: "create", client: 0, path: "doc.md", content: "v1\n" },
|
||||||
|
// Wait for the create's HTTP to land before the update; otherwise
|
||||||
|
// both writes are coalesced into a single POST and the server
|
||||||
|
// never sees the doc as "create followed by update".
|
||||||
|
{ type: "sync", client: 0 },
|
||||||
|
|
||||||
|
// Client 0 updates the doc (vault_update_id v_X > v_C). The
|
||||||
|
// server's `latest_document_versions` view now returns the
|
||||||
|
// *update* row — its `creation_vault_update_id != vault_update_id`.
|
||||||
|
{
|
||||||
|
type: "update",
|
||||||
|
client: 0,
|
||||||
|
path: "doc.md",
|
||||||
|
content: "v1\nupdate\n"
|
||||||
|
},
|
||||||
|
{ type: "sync", client: 0 },
|
||||||
|
|
||||||
|
// Client 1 reconnects. Server's catch-up replays docs with
|
||||||
|
// `vault_update_id > last_seen`. For doc.md it sends v_X with
|
||||||
|
// `is_new_file` derived from `creation_vault_update_id >
|
||||||
|
// last_seen_vault_update_id` (post-fix) — so Client 1 treats it
|
||||||
|
// as a fresh create and downloads the latest content.
|
||||||
|
{ type: "enable-sync", client: 1 },
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (state: AssertableState): void => {
|
||||||
|
state.assertFileCount(2);
|
||||||
|
state.assertFileExists("doc.md");
|
||||||
|
state.assertContent("doc.md", "v1\nupdate\n");
|
||||||
|
state.assertContent("warmup.md", "w\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const concurrentRenameFirstWinsTest: TestDefinition = {
|
export const concurrentRenameFirstWinsTest: TestDefinition = {
|
||||||
|
|
@ -8,29 +9,53 @@ export const concurrentRenameFirstWinsTest: TestDefinition = {
|
||||||
"edits are merged.",
|
"edits are merged.",
|
||||||
clients: 2,
|
clients: 2,
|
||||||
steps: [
|
steps: [
|
||||||
{ type: "create", client: 0, path: "A.md", content: "line 1\nline 2\nline 3" },
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 0,
|
||||||
|
path: "A.md",
|
||||||
|
content: "line 1\nline 2\nline 3"
|
||||||
|
},
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertContent("A.md", "line 1\nline 2\nline 3") },
|
{
|
||||||
|
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: 0 },
|
||||||
{ type: "disable-sync", client: 1 },
|
{ type: "disable-sync", client: 1 },
|
||||||
|
|
||||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
{ 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: "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: "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: "update",
|
||||||
|
client: 1,
|
||||||
|
path: "C.md",
|
||||||
|
content: "line 1\nline 2\nedit from 1"
|
||||||
|
},
|
||||||
|
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (s) => {
|
{
|
||||||
s.assertFileNotExists("A.md");
|
type: "assert-consistent",
|
||||||
s.assertFileCount(1);
|
verify: (s: AssertableState): void => {
|
||||||
s.assertAnyFileContains("edit from 0", "edit from 1");
|
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,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const createRenameResponseSkipsFileTest: TestDefinition = {
|
export const createRenameResponseSkipsFileTest: TestDefinition = {
|
||||||
|
|
@ -8,8 +9,6 @@ export const createRenameResponseSkipsFileTest: TestDefinition = {
|
||||||
steps: [
|
steps: [
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "create",
|
type: "create",
|
||||||
|
|
@ -25,10 +24,13 @@ export const createRenameResponseSkipsFileTest: TestDefinition = {
|
||||||
newPath: "renamed.md"
|
newPath: "renamed.md"
|
||||||
},
|
},
|
||||||
|
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertAnyFileContains("the-content") }
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileCount(1).assertAnyFileContains("the-content");
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const deleteByOtherClientThenRecreateTest: TestDefinition = {
|
export const deleteByOtherClientThenRecreateTest: TestDefinition = {
|
||||||
|
|
@ -14,11 +15,26 @@ export const deleteByOtherClientThenRecreateTest: TestDefinition = {
|
||||||
{ type: "delete", client: 1, path: "A.md" },
|
{ type: "delete", client: 1, path: "A.md" },
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertFileNotExists("A.md") },
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileNotExists("A.md");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
{ type: "create", client: 0, path: "A.md", content: "recreated by client 0" },
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 0,
|
||||||
|
path: "A.md",
|
||||||
|
content: "recreated by client 0"
|
||||||
|
},
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertContent("A.md", "recreated by client 0") },
|
{
|
||||||
],
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertContent("A.md", "recreated by client 0");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const deleteDuringPendingCreateTest: TestDefinition = {
|
export const deleteDuringPendingCreateTest: TestDefinition = {
|
||||||
|
|
@ -8,7 +9,6 @@ export const deleteDuringPendingCreateTest: TestDefinition = {
|
||||||
steps: [
|
steps: [
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "pause-server" },
|
{ type: "pause-server" },
|
||||||
|
|
@ -23,9 +23,13 @@ export const deleteDuringPendingCreateTest: TestDefinition = {
|
||||||
{ type: "delete", client: 0, path: "ephemeral.md" },
|
{ type: "delete", client: 0, path: "ephemeral.md" },
|
||||||
|
|
||||||
{ type: "resume-server" },
|
{ type: "resume-server" },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertFileCount(0).assertFileNotExists("ephemeral.md") }
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileCount(0).assertFileNotExists("ephemeral.md");
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const deleteRecreateConcurrentUpdateTest: TestDefinition = {
|
export const deleteRecreateConcurrentUpdateTest: TestDefinition = {
|
||||||
|
|
@ -9,12 +10,16 @@ export const deleteRecreateConcurrentUpdateTest: TestDefinition = {
|
||||||
{ type: "create", client: 0, path: "A.md", content: "original" },
|
{ type: "create", client: 0, path: "A.md", content: "original" },
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "disable-sync", client: 0 },
|
{ type: "disable-sync", client: 0 },
|
||||||
{ type: "delete", client: 0, path: "A.md" },
|
{ type: "delete", client: 0, path: "A.md" },
|
||||||
{ type: "create", client: 0, path: "A.md", content: "recreated by client 0" },
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 0,
|
||||||
|
path: "A.md",
|
||||||
|
content: "recreated by client 0"
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "update",
|
type: "update",
|
||||||
|
|
@ -25,9 +30,13 @@ export const deleteRecreateConcurrentUpdateTest: TestDefinition = {
|
||||||
{ type: "sync", client: 1 },
|
{ type: "sync", client: 1 },
|
||||||
|
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertFileExists("A.md").assertContains("A.md", "recreated") }
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileExists("A.md").assertContains("A.md", "recreated");
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const deleteRecreateDifferentContentTest: TestDefinition = {
|
export const deleteRecreateDifferentContentTest: TestDefinition = {
|
||||||
|
|
@ -14,7 +15,6 @@ export const deleteRecreateDifferentContentTest: TestDefinition = {
|
||||||
},
|
},
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "disable-sync", client: 0 },
|
{ type: "disable-sync", client: 0 },
|
||||||
|
|
@ -38,9 +38,17 @@ export const deleteRecreateDifferentContentTest: TestDefinition = {
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "sync", client: 0 },
|
{ type: "sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("A.md", "brand new", "client 1") }
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileCount(1).assertContains(
|
||||||
|
"A.md",
|
||||||
|
"brand new",
|
||||||
|
"client 1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const deleteRecreateSamePathTest: TestDefinition = {
|
export const deleteRecreateSamePathTest: TestDefinition = {
|
||||||
|
|
@ -9,17 +10,25 @@ export const deleteRecreateSamePathTest: TestDefinition = {
|
||||||
{ type: "create", client: 0, path: "A.md", content: "version 1" },
|
{ type: "create", client: 0, path: "A.md", content: "version 1" },
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertContent("A.md", "version 1") },
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertContent("A.md", "version 1");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
{ type: "disable-sync", client: 0 },
|
{ type: "disable-sync", client: 0 },
|
||||||
{ type: "delete", client: 0, path: "A.md" },
|
{ type: "delete", client: 0, path: "A.md" },
|
||||||
{ type: "create", client: 0, path: "A.md", content: "version 2" },
|
{ type: "create", client: 0, path: "A.md", content: "version 2" },
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertContent("A.md", "version 2") }
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertContent("A.md", "version 2");
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
|
export const deleteRecreatedPendingCreateWithStaleDeletingRecordTest: TestDefinition =
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"A local delete for a recreated pending create must target the " +
|
||||||
|
"new pending create, not an older same-path record whose server " +
|
||||||
|
"delete has been acked but whose WebSocket delete receipt is " +
|
||||||
|
"still paused.",
|
||||||
|
clients: 2,
|
||||||
|
steps: [
|
||||||
|
{ type: "enable-sync", client: 0 },
|
||||||
|
{ type: "enable-sync", client: 1 },
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{ type: "pause-websocket", client: 0 },
|
||||||
|
{ type: "pause-server" },
|
||||||
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 0,
|
||||||
|
path: "binary-14.bin",
|
||||||
|
content: "BINARY:first"
|
||||||
|
},
|
||||||
|
{ type: "sleep", ms: 100 },
|
||||||
|
{ type: "delete", client: 0, path: "binary-14.bin" },
|
||||||
|
{ type: "resume-server" },
|
||||||
|
{ type: "sync", client: 0 },
|
||||||
|
|
||||||
|
{ type: "pause-server" },
|
||||||
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 0,
|
||||||
|
path: "binary-14.bin",
|
||||||
|
content: "BINARY:second"
|
||||||
|
},
|
||||||
|
{ type: "sleep", ms: 100 },
|
||||||
|
{ type: "delete", client: 0, path: "binary-14.bin" },
|
||||||
|
{ type: "resume-server" },
|
||||||
|
{ type: "sync", client: 0 },
|
||||||
|
|
||||||
|
{ type: "resume-websocket", client: 0 },
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (state: AssertableState): void => {
|
||||||
|
state.assertFileCount(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const deleteRenameConflictTest: TestDefinition = {
|
export const deleteRenameConflictTest: TestDefinition = {
|
||||||
|
|
@ -10,9 +11,13 @@ export const deleteRenameConflictTest: TestDefinition = {
|
||||||
{ type: "create", client: 0, path: "B.md", content: "content-b" },
|
{ type: "create", client: 0, path: "B.md", content: "content-b" },
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertFileExists("A.md").assertFileExists("B.md") },
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileExists("A.md").assertFileExists("B.md");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
{ type: "disable-sync", client: 1 },
|
{ type: "disable-sync", client: 1 },
|
||||||
|
|
||||||
|
|
@ -22,13 +27,17 @@ export const deleteRenameConflictTest: TestDefinition = {
|
||||||
{ type: "rename", client: 1, oldPath: "A.md", newPath: "C.md" },
|
{ type: "rename", client: 1, oldPath: "A.md", newPath: "C.md" },
|
||||||
|
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync", client: 1 },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (s) => {
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
s.assertContent("B.md", "content-b");
|
s.assertContent("B.md", "content-b");
|
||||||
s.assertFileNotExists("A.md");
|
s.assertFileNotExists("A.md");
|
||||||
s.ifFileExists("C.md", (s) => s.assertContent("C.md", "content-a"));
|
s.ifFileExists("C.md", (inner) =>
|
||||||
} },
|
inner.assertContent("C.md", "content-a")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const displacedFileNotMarkedDeletedTest: TestDefinition = {
|
export const displacedFileNotMarkedDeletedTest: TestDefinition = {
|
||||||
|
|
@ -15,25 +16,22 @@ export const displacedFileNotMarkedDeletedTest: TestDefinition = {
|
||||||
|
|
||||||
{ type: "disable-sync", client: 1 },
|
{ type: "disable-sync", client: 1 },
|
||||||
|
|
||||||
{ type: "create", client: 0, path: "B.md", content: "new file B" },
|
{ type: "create", client: 0, path: "B.md", content: "content of B" },
|
||||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "C.md" },
|
{ type: "rename", client: 0, oldPath: "A.md", newPath: "C.md" },
|
||||||
{ type: "sync", client: 0 },
|
{ type: "sync", client: 0 },
|
||||||
|
|
||||||
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
|
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
|
||||||
{ type: "update", client: 1, path: "B.md", content: "edited A content" },
|
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (state) => {
|
verify: (state: AssertableState): void => {
|
||||||
state
|
state
|
||||||
.assertFileNotExists("A.md")
|
.assertFileCount(2)
|
||||||
.assertFileExists("B.md")
|
.assertContent("B.md", "content of B")
|
||||||
.assertContains("B.md", "new file B")
|
.assertContent("C.md", "content of A");
|
||||||
.assertFileExists("C.md")
|
|
||||||
.assertContains("C.md", "edited A content");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const doubleOfflineCycleTest: TestDefinition = {
|
export const doubleOfflineCycleTest: TestDefinition = {
|
||||||
|
|
@ -14,9 +15,13 @@ export const doubleOfflineCycleTest: TestDefinition = {
|
||||||
},
|
},
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertContent("doc.md", "initial") },
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertContent("doc.md", "initial");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
{ type: "disable-sync", client: 0 },
|
{ type: "disable-sync", client: 0 },
|
||||||
{
|
{
|
||||||
|
|
@ -27,9 +32,13 @@ export const doubleOfflineCycleTest: TestDefinition = {
|
||||||
},
|
},
|
||||||
|
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertContent("doc.md", "first edit") },
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertContent("doc.md", "first edit");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
{ type: "disable-sync", client: 0 },
|
{ type: "disable-sync", client: 0 },
|
||||||
{
|
{
|
||||||
|
|
@ -40,9 +49,13 @@ export const doubleOfflineCycleTest: TestDefinition = {
|
||||||
},
|
},
|
||||||
|
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertContent("doc.md", "second edit") },
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertContent("doc.md", "second edit");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
{ type: "disable-sync", client: 0 },
|
{ type: "disable-sync", client: 0 },
|
||||||
{
|
{
|
||||||
|
|
@ -53,8 +66,12 @@ export const doubleOfflineCycleTest: TestDefinition = {
|
||||||
},
|
},
|
||||||
|
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("doc.md", "third edit") }
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileCount(1).assertContent("doc.md", "third edit");
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import type { TestDefinition } from "../test-definition";
|
|
||||||
|
|
||||||
export const failedVfsMoveFallsBackTest: TestDefinition = {
|
|
||||||
description:
|
|
||||||
"File A is renamed to B's path (overwriting B). Both clients " +
|
|
||||||
"should converge on a single file at B.md with A's content.",
|
|
||||||
clients: 2,
|
|
||||||
steps: [
|
|
||||||
{ type: "create", client: 0, path: "A.md", content: "content A" },
|
|
||||||
{ type: "create", client: 0, path: "B.md", content: "content B" },
|
|
||||||
{ type: "enable-sync", client: 0 },
|
|
||||||
{ type: "enable-sync", client: 1 },
|
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
|
||||||
|
|
||||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("B.md", "content A") }
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const idempotencyAfterServerPauseTest: TestDefinition = {
|
export const idempotencyAfterServerPauseTest: TestDefinition = {
|
||||||
|
|
@ -8,17 +9,25 @@ export const idempotencyAfterServerPauseTest: TestDefinition = {
|
||||||
steps: [
|
steps: [
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "create", client: 0, path: "doc.md", content: "important data" },
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 0,
|
||||||
|
path: "doc.md",
|
||||||
|
content: "important data"
|
||||||
|
},
|
||||||
{ type: "pause-server" },
|
{ type: "pause-server" },
|
||||||
|
|
||||||
{ type: "resume-server" },
|
{ type: "resume-server" },
|
||||||
|
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("doc.md", "important data") }
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileCount(1).assertContent("doc.md", "important data");
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const interruptedDeleteRetryTest: TestDefinition = {
|
export const interruptedDeleteRetryTest: TestDefinition = {
|
||||||
|
|
@ -9,7 +10,6 @@ export const interruptedDeleteRetryTest: TestDefinition = {
|
||||||
{ type: "create", client: 0, path: "doc.md", content: "to be deleted" },
|
{ type: "create", client: 0, path: "doc.md", content: "to be deleted" },
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "delete", client: 0, path: "doc.md" },
|
{ type: "delete", client: 0, path: "doc.md" },
|
||||||
|
|
@ -17,9 +17,13 @@ export const interruptedDeleteRetryTest: TestDefinition = {
|
||||||
{ type: "pause-server" },
|
{ type: "pause-server" },
|
||||||
|
|
||||||
{ type: "resume-server" },
|
{ type: "resume-server" },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertFileCount(0) },
|
{
|
||||||
],
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileCount(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const keyMigrationEventDropTest: TestDefinition = {
|
export const keyMigrationEventDropTest: TestDefinition = {
|
||||||
|
|
@ -8,7 +9,6 @@ export const keyMigrationEventDropTest: TestDefinition = {
|
||||||
steps: [
|
steps: [
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "pause-server" },
|
{ type: "pause-server" },
|
||||||
|
|
@ -27,9 +27,13 @@ export const keyMigrationEventDropTest: TestDefinition = {
|
||||||
},
|
},
|
||||||
|
|
||||||
{ type: "resume-server" },
|
{ type: "resume-server" },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("A.md", "updated content") }
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileCount(1).assertContent("A.md", "updated content");
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const localEditLostDuringCreateMergeTest: TestDefinition = {
|
export const localEditLostDuringCreateMergeTest: TestDefinition = {
|
||||||
|
|
@ -28,12 +29,13 @@ export const localEditLostDuringCreateMergeTest: TestDefinition = {
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) =>
|
verify: (s: AssertableState): void => {
|
||||||
s.assertFileCount(1).assertContains(
|
s.assertFileCount(1).assertContains(
|
||||||
"doc.md",
|
"doc.md",
|
||||||
"from-client-1",
|
"from-client-1",
|
||||||
"local-edit-during-create"
|
"local-edit-during-create"
|
||||||
),
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
|
export const localRenameSurvivesRemoteRenameTest: TestDefinition = {
|
||||||
|
description:
|
||||||
|
"Drain processes a RemoteChange (remote rename for doc D) while a " +
|
||||||
|
"LocalUpdate (user rename of D) is also queued behind it. " +
|
||||||
|
"`processRemoteUpdate` moves the disk file and, because there is a " +
|
||||||
|
"pending LocalUpdate, takes the else branch — but its setDocument " +
|
||||||
|
"uses the stale `record.path` (= the user-rename target) instead of " +
|
||||||
|
"the actualPath the file just moved to. The queued LocalUpdate then " +
|
||||||
|
"reads from `record.path`, throws FileNotFoundError, and is " +
|
||||||
|
"silently dropped. Setup pins the queue order: a sentinel " +
|
||||||
|
"LocalUpdate keeps drain busy on a SIGSTOPped HTTP roundtrip while " +
|
||||||
|
"we resume client 0's WebSocket (enqueues RemoteChange) and then " +
|
||||||
|
"user-rename D (enqueues LocalUpdate after the RemoteChange). On " +
|
||||||
|
"server resume the drain pops the sentinel, then RemoteChange, then " +
|
||||||
|
"LocalUpdate — exactly the order that triggers the bug.",
|
||||||
|
clients: 2,
|
||||||
|
steps: [
|
||||||
|
{ type: "enable-sync", client: 0 },
|
||||||
|
{ type: "enable-sync", client: 1 },
|
||||||
|
{ type: "create", client: 0, path: "doc.md", content: "v1\n" },
|
||||||
|
{ type: "create", client: 0, path: "sentinel.md", content: "s\n" },
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
// Pause client 0's WebSocket so the upcoming remote rename buffers.
|
||||||
|
{ type: "pause-websocket", client: 0 },
|
||||||
|
|
||||||
|
// Server applies remote rename of doc.md -> remote.md. Broadcast
|
||||||
|
// is buffered on client 0's WebSocket.
|
||||||
|
{ type: "rename", client: 1, oldPath: "doc.md", newPath: "remote.md" },
|
||||||
|
{ type: "sync", client: 1 },
|
||||||
|
|
||||||
|
// Pause the server BEFORE arming the sentinel, so the sentinel's
|
||||||
|
// HTTP request will buffer at the kernel and keep drain occupied.
|
||||||
|
{ type: "pause-server" },
|
||||||
|
|
||||||
|
// Sentinel: a LocalUpdate on a *different* doc that drain pops
|
||||||
|
// first. Its HTTP roundtrip stalls on SIGSTOP, freezing drain
|
||||||
|
// until we resume the server. While drain is frozen we can grow
|
||||||
|
// the queue with additional events whose order we control.
|
||||||
|
{
|
||||||
|
type: "update",
|
||||||
|
client: 0,
|
||||||
|
path: "sentinel.md",
|
||||||
|
content: "s\nedit\n"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Resume the WebSocket — buffered remote rename enqueues as a
|
||||||
|
// RemoteChange. Drain is still stuck on the sentinel HTTP.
|
||||||
|
{ type: "resume-websocket", client: 0 },
|
||||||
|
|
||||||
|
// User renames doc.md -> local.md on client 0. queue.enqueue
|
||||||
|
// mutates the doc's record.path to "local.md" and pushes a
|
||||||
|
// LocalUpdate(rename) onto the tail of the queue. Queue is now
|
||||||
|
// [sentinel-update (in-flight), RemoteChange, LocalUpdate-rename].
|
||||||
|
{ type: "rename", client: 0, oldPath: "doc.md", newPath: "local.md" },
|
||||||
|
|
||||||
|
// Resume the server. Drain pops sentinel-update (succeeds), then
|
||||||
|
// RemoteChange. Pre-fix: processRemoteUpdate moves disk
|
||||||
|
// local.md -> remote.md, takes the else branch, and
|
||||||
|
// setDocument(record.path = "local.md", …) leaves record.path
|
||||||
|
// stale. Drain pops the LocalUpdate-rename and reads from the
|
||||||
|
// stale record.path, hits FileNotFoundError, silent skip.
|
||||||
|
// Post-fix: when a local event is pending, we re-queue the
|
||||||
|
// remote update without touching disk or record, so the local
|
||||||
|
// rename drains first and both ends converge.
|
||||||
|
{ type: "resume-server" },
|
||||||
|
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (state: AssertableState): void => {
|
||||||
|
state.assertFileCount(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
|
export const localUpdateSurvivesRemoteRenameTest: TestDefinition = {
|
||||||
|
description:
|
||||||
|
"Client 0 has a local content edit pending while a remote rename for " +
|
||||||
|
"the same doc arrives over the WebSocket. The remote rename's internal " +
|
||||||
|
"move relocates the disk file from the old path (where the user wrote) " +
|
||||||
|
"to the new server path. Previously, the queued LocalUpdate's " +
|
||||||
|
"`event.path` was left pointing at the now-vacated old path, so " +
|
||||||
|
"`skipIfOversized`'s `getFileSize(event.path)` threw " +
|
||||||
|
"`FileNotFoundError`, which `processEvent`'s catch silently swallowed " +
|
||||||
|
"as 'Skipping sync event 'local-update' because the file no longer " +
|
||||||
|
"exists' — and the user's edit was lost. The fix routes the size " +
|
||||||
|
"check through `tracked.path` (the doc's current disk path), " +
|
||||||
|
"matching the path `processLocalUpdate` itself reads from.",
|
||||||
|
clients: 2,
|
||||||
|
steps: [
|
||||||
|
{ type: "create", client: 0, path: "doc.md", content: "v1\n" },
|
||||||
|
{ type: "enable-sync", client: 0 },
|
||||||
|
{ type: "enable-sync", client: 1 },
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
// Pause client 0's WebSocket so the upcoming remote rename buffers
|
||||||
|
// there until we've already enqueued client 0's local content
|
||||||
|
// edit. This guarantees the LocalUpdate sits in client 0's queue
|
||||||
|
// when the rename's RemoteChange drains.
|
||||||
|
{ type: "pause-websocket", client: 0 },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "rename",
|
||||||
|
client: 1,
|
||||||
|
oldPath: "doc.md",
|
||||||
|
newPath: "renamed.md"
|
||||||
|
},
|
||||||
|
{ type: "sync", client: 1 },
|
||||||
|
|
||||||
|
// Client 0 still believes the file is at `doc.md` (its WebSocket is
|
||||||
|
// paused, so the rename hasn't reached it). The user edits content
|
||||||
|
// at `doc.md`. This pushes a LocalUpdate(D, path=doc.md,
|
||||||
|
// originalPath=doc.md, isUserRename=false) into client 0's queue.
|
||||||
|
{
|
||||||
|
type: "update",
|
||||||
|
client: 0,
|
||||||
|
path: "doc.md",
|
||||||
|
content: "v1\nclient 0 edit\n"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Resume the WebSocket. The buffered remote rename (server-broadcast)
|
||||||
|
// drains. `processRemoteUpdate` does an internal `move(doc.md,
|
||||||
|
// renamed.md)` and, because there's a pending LocalUpdate for D,
|
||||||
|
// takes the else branch (re-enqueue v_K, setDocument(renamed.md, …)).
|
||||||
|
// Then drain reaches the LocalUpdate. Pre-fix: skipped silently.
|
||||||
|
// Post-fix: PUTs the user's content to the doc (at its new path,
|
||||||
|
// since this is a content-only edit, not a user rename).
|
||||||
|
{ type: "resume-websocket", client: 0 },
|
||||||
|
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (state: AssertableState): void => {
|
||||||
|
state.assertFileCount(1);
|
||||||
|
state.assertFileExists("renamed.md");
|
||||||
|
state.assertContent("renamed.md", "v1\nclient 0 edit\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const mcCrossCreateRenameSameTargetTest: TestDefinition = {
|
export const mcCrossCreateRenameSameTargetTest: TestDefinition = {
|
||||||
|
|
@ -12,12 +13,13 @@ export const mcCrossCreateRenameSameTargetTest: TestDefinition = {
|
||||||
|
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) => s.assertFileExists("X.md").assertFileExists("Y.md")
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileExists("X.md").assertFileExists("Y.md");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{ type: "disable-sync", client: 1 },
|
{ type: "disable-sync", client: 1 },
|
||||||
|
|
@ -28,12 +30,11 @@ export const mcCrossCreateRenameSameTargetTest: TestDefinition = {
|
||||||
{ type: "rename", client: 1, oldPath: "Y.md", newPath: "Z.md" },
|
{ type: "rename", client: 1, oldPath: "Y.md", newPath: "Z.md" },
|
||||||
|
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) => {
|
verify: (s: AssertableState): void => {
|
||||||
s.assertFileCount(2)
|
s.assertFileCount(2)
|
||||||
.assertFileNotExists("X.md")
|
.assertFileNotExists("X.md")
|
||||||
.assertFileNotExists("Y.md")
|
.assertFileNotExists("Y.md")
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const mcDeleteThenOfflineRenameTest: TestDefinition = {
|
export const mcDeleteThenOfflineRenameTest: TestDefinition = {
|
||||||
|
|
@ -11,7 +12,6 @@ export const mcDeleteThenOfflineRenameTest: TestDefinition = {
|
||||||
{ type: "create", client: 0, path: "C.md", content: "unrelated" },
|
{ type: "create", client: 0, path: "C.md", content: "unrelated" },
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "disable-sync", client: 1 },
|
{ type: "disable-sync", client: 1 },
|
||||||
|
|
@ -22,15 +22,17 @@ export const mcDeleteThenOfflineRenameTest: TestDefinition = {
|
||||||
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
|
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
|
||||||
|
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) => {
|
verify: (s: AssertableState): void => {
|
||||||
s.assertContent("C.md", "unrelated")
|
s.assertContent("C.md", "unrelated").assertFileNotExists(
|
||||||
.assertFileNotExists("A.md");
|
"A.md"
|
||||||
s.ifFileExists("B.md", (s) => s.assertContent("B.md", "original"));
|
);
|
||||||
|
s.ifFileExists("B.md", (inner) =>
|
||||||
|
inner.assertContent("B.md", "original")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const mcMultiDeleteOfflineRenameTest: TestDefinition = {
|
export const mcMultiDeleteOfflineRenameTest: TestDefinition = {
|
||||||
|
|
@ -13,7 +14,6 @@ export const mcMultiDeleteOfflineRenameTest: TestDefinition = {
|
||||||
{ type: "create", client: 0, path: "file-5.md", content: "content-5" },
|
{ type: "create", client: 0, path: "file-5.md", content: "content-5" },
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "disable-sync", client: 0 },
|
{ type: "disable-sync", client: 0 },
|
||||||
|
|
@ -22,21 +22,27 @@ export const mcMultiDeleteOfflineRenameTest: TestDefinition = {
|
||||||
{ type: "delete", client: 1, path: "file-4.md" },
|
{ type: "delete", client: 1, path: "file-4.md" },
|
||||||
{ type: "sync", client: 1 },
|
{ type: "sync", client: 1 },
|
||||||
|
|
||||||
{ type: "rename", client: 0, oldPath: "file-2.md", newPath: "renamed.md" },
|
{
|
||||||
|
type: "rename",
|
||||||
|
client: 0,
|
||||||
|
oldPath: "file-2.md",
|
||||||
|
newPath: "renamed.md"
|
||||||
|
},
|
||||||
|
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) => {
|
verify: (s: AssertableState): void => {
|
||||||
s.assertFileExists("file-1.md")
|
s.assertFileExists("file-1.md")
|
||||||
.assertFileExists("file-3.md")
|
.assertFileExists("file-3.md")
|
||||||
.assertFileExists("file-5.md")
|
.assertFileExists("file-5.md")
|
||||||
.assertFileNotExists("file-2.md")
|
.assertFileNotExists("file-2.md")
|
||||||
.assertFileNotExists("file-4.md");
|
.assertFileNotExists("file-4.md");
|
||||||
s.ifFileExists("renamed.md", (s) => s.assertContent("renamed.md", "content-2"));
|
s.ifFileExists("renamed.md", (inner) =>
|
||||||
|
inner.assertContent("renamed.md", "content-2")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const mcThreeClientRenameOfflineUpdateTest: TestDefinition = {
|
export const mcThreeClientRenameOfflineUpdateTest: TestDefinition = {
|
||||||
|
|
@ -10,7 +11,6 @@ export const mcThreeClientRenameOfflineUpdateTest: TestDefinition = {
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "enable-sync", client: 2 },
|
{ type: "enable-sync", client: 2 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "disable-sync", client: 2 },
|
{ type: "disable-sync", client: 2 },
|
||||||
|
|
@ -19,12 +19,23 @@ export const mcThreeClientRenameOfflineUpdateTest: TestDefinition = {
|
||||||
{ type: "sync", client: 1 },
|
{ type: "sync", client: 1 },
|
||||||
{ type: "sync", client: 0 },
|
{ type: "sync", client: 0 },
|
||||||
|
|
||||||
{ type: "update", client: 2, path: "A.md", content: "updated-by-client-2" },
|
{
|
||||||
|
type: "update",
|
||||||
|
client: 2,
|
||||||
|
path: "A.md",
|
||||||
|
content: "updated-by-client-2"
|
||||||
|
},
|
||||||
|
|
||||||
{ type: "enable-sync", client: 2 },
|
{ type: "enable-sync", client: 2 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertFileNotExists("A.md").assertContains("B.md", "updated-by-client-2") }
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileCount(1)
|
||||||
|
.assertFileNotExists("A.md")
|
||||||
|
.assertContains("B.md", "updated-by-client-2");
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
|
export const mergingUpdateResponseSurvivesUserRenameTest: TestDefinition = {
|
||||||
|
description:
|
||||||
|
"Client 1 sends a content update with a stale `parent_version_id` " +
|
||||||
|
"(its WebSocket is paused, so it hasn't seen Client 0's intervening " +
|
||||||
|
"edit). The server merges and replies with `MergingUpdate` carrying " +
|
||||||
|
"the merged text. Before the response lands, the user renames the " +
|
||||||
|
"doc on Client 1, vacating the disk path the in-flight " +
|
||||||
|
"`processLocalUpdate` captured. Pre-fix: " +
|
||||||
|
"`handleMaybeMergingResponse`'s `operations.write(diskPath, …)` " +
|
||||||
|
"hits the `we wont recreate it` early-return inside `write`, " +
|
||||||
|
"silently dropping the server-merged content — Client 0's edit is " +
|
||||||
|
"lost on Client 1's disk, and Client 1's next local-update PUT " +
|
||||||
|
"(rebased on the now-untracked merged version) deletes Client 0's " +
|
||||||
|
"edit on the server too. Post-fix: the response is written to the " +
|
||||||
|
"doc's current tracked disk path, preserving both edits.",
|
||||||
|
clients: 2,
|
||||||
|
steps: [
|
||||||
|
{ type: "enable-sync", client: 0 },
|
||||||
|
{ type: "enable-sync", client: 1 },
|
||||||
|
{ type: "create", client: 0, path: "doc.md", content: "0\n" },
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
// Stop Client 1 from seeing Client 0's next edit, so its next
|
||||||
|
// outbound PUT carries a stale `parent_version_id` and the server
|
||||||
|
// is forced to merge.
|
||||||
|
{ type: "pause-websocket", client: 1 },
|
||||||
|
|
||||||
|
// Server now holds v_b = "0\nA\n". Client 1's tracked parent
|
||||||
|
// version stays at v_a = "0\n".
|
||||||
|
{ type: "update", client: 0, path: "doc.md", content: "0\nA\n" },
|
||||||
|
{ type: "sync", client: 0 },
|
||||||
|
|
||||||
|
// Pause the server. Subsequent HTTP PUTs from Client 1 buffer at
|
||||||
|
// the OS layer until resume. This guarantees the merge response
|
||||||
|
// for Client 1's update is still in flight when the rename below
|
||||||
|
// mutates `queue.documents`.
|
||||||
|
{ type: "pause-server" },
|
||||||
|
|
||||||
|
// Client 1 edits doc.md with "B". The drain pops the LocalUpdate,
|
||||||
|
// captures `diskPath = "doc.md"`, reads the file, and sends the
|
||||||
|
// HTTP PUT — which buffers because the server is SIGSTOPped.
|
||||||
|
{ type: "update", client: 1, path: "doc.md", content: "0\nB\n" },
|
||||||
|
|
||||||
|
// User renames the file while the previous PUT is still in flight.
|
||||||
|
// `queue.enqueue`'s rename branch updates `documents` to point at
|
||||||
|
// `renamed.md` synchronously, but `processLocalUpdate`'s captured
|
||||||
|
// `diskPath` ("doc.md") is a local — it can't be retargeted.
|
||||||
|
{ type: "rename", client: 1, oldPath: "doc.md", newPath: "renamed.md" },
|
||||||
|
|
||||||
|
// Resume the server. It reconciles parent=v_a, latest=v_b,
|
||||||
|
// new="0\nB\n" → v_c with both edits, replies `MergingUpdate`.
|
||||||
|
// Pre-fix: write("doc.md", …) sees no file at that path
|
||||||
|
// (renamed.md now holds the data) and bails out without ever
|
||||||
|
// writing the merged bytes. Post-fix: the merged bytes land at
|
||||||
|
// the tracked path (renamed.md).
|
||||||
|
{ type: "resume-server" },
|
||||||
|
{ type: "resume-websocket", client: 1 },
|
||||||
|
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (state: AssertableState): void => {
|
||||||
|
state.assertFileCount(1);
|
||||||
|
state.assertFileExists("renamed.md");
|
||||||
|
state.assertFileNotExists("doc.md");
|
||||||
|
// Both edits survive: Client 0's "A" and Client 1's "B".
|
||||||
|
// The reconcile may interleave them either way; assert
|
||||||
|
// both tokens are present in the converged content.
|
||||||
|
state.assertContains("renamed.md", "A", "B");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const migrateKeyPreservesExistingTest: TestDefinition = {
|
export const migrateKeyPreservesExistingTest: TestDefinition = {
|
||||||
|
|
@ -8,7 +9,6 @@ export const migrateKeyPreservesExistingTest: TestDefinition = {
|
||||||
steps: [
|
steps: [
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "pause-server" },
|
{ type: "pause-server" },
|
||||||
|
|
@ -22,9 +22,16 @@ export const migrateKeyPreservesExistingTest: TestDefinition = {
|
||||||
},
|
},
|
||||||
|
|
||||||
{ type: "resume-server" },
|
{ type: "resume-server" },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("A.md", "updated by client 0") }
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileCount(1).assertContains(
|
||||||
|
"A.md",
|
||||||
|
"updated by client 0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const moveAndConcurrentRemoteUpdateTest: TestDefinition = {
|
export const moveAndConcurrentRemoteUpdateTest: TestDefinition = {
|
||||||
|
|
@ -14,7 +15,6 @@ export const moveAndConcurrentRemoteUpdateTest: TestDefinition = {
|
||||||
},
|
},
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "disable-sync", client: 0 },
|
{ type: "disable-sync", client: 0 },
|
||||||
|
|
@ -29,9 +29,15 @@ export const moveAndConcurrentRemoteUpdateTest: TestDefinition = {
|
||||||
{ type: "sync", client: 1 },
|
{ type: "sync", client: 1 },
|
||||||
|
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertFileNotExists("A.md").assertContains("B.md", "updated by client 1") }
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileCount(1)
|
||||||
|
.assertFileNotExists("A.md")
|
||||||
|
.assertContains("B.md", "updated by client 1");
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const movePreservesRemoteUpdateTest: TestDefinition = {
|
export const movePreservesRemoteUpdateTest: TestDefinition = {
|
||||||
|
|
@ -6,32 +7,42 @@ export const movePreservesRemoteUpdateTest: TestDefinition = {
|
||||||
"After both reconnect, the renamed file should contain client 1's edit.",
|
"After both reconnect, the renamed file should contain client 1's edit.",
|
||||||
clients: 2,
|
clients: 2,
|
||||||
steps: [
|
steps: [
|
||||||
{ type: "create", client: 0, path: "doc.md", content: "line 1\nline 2" },
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 0,
|
||||||
|
path: "doc.md",
|
||||||
|
content: "line 1\nline 2"
|
||||||
|
},
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "disable-sync", client: 0 },
|
{ type: "disable-sync", client: 0 },
|
||||||
{ type: "disable-sync", client: 1 },
|
{ type: "disable-sync", client: 1 },
|
||||||
|
|
||||||
{ type: "rename", client: 0, oldPath: "doc.md", newPath: "renamed.md" },
|
{ 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: "update",
|
||||||
|
client: 1,
|
||||||
|
path: "doc.md",
|
||||||
|
content: "line 1\nclient 1 edit\nline 2"
|
||||||
|
},
|
||||||
|
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) => {
|
verify: (s: AssertableState): void => {
|
||||||
s.assertFileCount(1);
|
s.assertFileCount(1);
|
||||||
const content = Array.from(s.files.values())[0];
|
const [content] = Array.from(s.files.values());
|
||||||
if (!content.includes("client 1 edit")) {
|
if (!content.includes("client 1 edit")) {
|
||||||
throw new Error(`Expected merged content to include "client 1 edit", got: "${content}"`);
|
throw new Error(
|
||||||
|
`Expected merged content to include "client 1 edit", got: "${content}"`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
],
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const moveRemoteUpdateRevertsRenameTest: TestDefinition = {
|
export const moveRemoteUpdateRevertsRenameTest: TestDefinition = {
|
||||||
|
|
@ -9,26 +10,28 @@ export const moveRemoteUpdateRevertsRenameTest: TestDefinition = {
|
||||||
{ type: "create", client: 0, path: "doc.md", content: "original" },
|
{ type: "create", client: 0, path: "doc.md", content: "original" },
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "disable-sync", client: 0 },
|
{ type: "disable-sync", client: 0 },
|
||||||
{ type: "update", client: 1, path: "doc.md", content: "updated by client 1" },
|
{
|
||||||
|
type: "update",
|
||||||
|
client: 1,
|
||||||
|
path: "doc.md",
|
||||||
|
content: "updated by client 1"
|
||||||
|
},
|
||||||
{ type: "sync", client: 1 },
|
{ type: "sync", client: 1 },
|
||||||
|
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "rename", client: 0, oldPath: "doc.md", newPath: "renamed.md" },
|
{ type: "rename", client: 0, oldPath: "doc.md", newPath: "renamed.md" },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) => {
|
verify: (s: AssertableState): void => {
|
||||||
s.assertFileCount(1);
|
s.assertFileCount(1).assertContent(
|
||||||
const content = Array.from(s.files.values())[0];
|
"renamed.md",
|
||||||
if (content !== "updated by client 1") {
|
"updated by client 1"
|
||||||
throw new Error(`Expected "updated by client 1", got: "${content}"`);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const moveThenDeleteStalePathTest: TestDefinition = {
|
export const moveThenDeleteStalePathTest: TestDefinition = {
|
||||||
|
|
@ -14,15 +15,20 @@ export const moveThenDeleteStalePathTest: TestDefinition = {
|
||||||
},
|
},
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
||||||
{ type: "delete", client: 0, path: "B.md" },
|
{ type: "delete", client: 0, path: "B.md" },
|
||||||
|
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertFileCount(0).assertFileNotExists("A.md").assertFileNotExists("B.md") }
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileCount(0)
|
||||||
|
.assertFileNotExists("A.md")
|
||||||
|
.assertFileNotExists("B.md");
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const multiFileOperationsTest: TestDefinition = {
|
export const multiFileOperationsTest: TestDefinition = {
|
||||||
|
|
@ -11,7 +12,6 @@ export const multiFileOperationsTest: TestDefinition = {
|
||||||
{ type: "create", client: 0, path: "C.md", content: "content-c" },
|
{ type: "create", client: 0, path: "C.md", content: "content-c" },
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "disable-sync", client: 1 },
|
{ type: "disable-sync", client: 1 },
|
||||||
|
|
@ -19,20 +19,26 @@ export const multiFileOperationsTest: TestDefinition = {
|
||||||
{ type: "delete", client: 0, path: "A.md" },
|
{ type: "delete", client: 0, path: "A.md" },
|
||||||
{ type: "sync", client: 0 },
|
{ type: "sync", client: 0 },
|
||||||
|
|
||||||
{ type: "update", client: 1, path: "B.md", content: "updated by client 1" },
|
{
|
||||||
|
type: "update",
|
||||||
|
client: 1,
|
||||||
|
path: "B.md",
|
||||||
|
content: "updated by client 1"
|
||||||
|
},
|
||||||
{ type: "rename", client: 1, oldPath: "A.md", newPath: "D.md" },
|
{ type: "rename", client: 1, oldPath: "A.md", newPath: "D.md" },
|
||||||
|
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync", client: 1 },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) => {
|
verify: (s: AssertableState): void => {
|
||||||
s.assertContains("B.md", "updated")
|
s.assertContains("B.md", "updated")
|
||||||
.assertFileExists("C.md")
|
.assertFileExists("C.md")
|
||||||
.assertFileNotExists("A.md");
|
.assertFileNotExists("A.md");
|
||||||
s.ifFileExists("D.md", (s) => s.assertContent("D.md", "content-a"));
|
s.ifFileExists("D.md", (inner) =>
|
||||||
|
inner.assertContent("D.md", "content-a")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const offlineConcurrentRenamesTest: TestDefinition = {
|
export const offlineConcurrentRenamesTest: TestDefinition = {
|
||||||
|
|
@ -11,11 +12,12 @@ export const offlineConcurrentRenamesTest: TestDefinition = {
|
||||||
{ type: "create", client: 0, path: "A.md", content: "shared-content" },
|
{ type: "create", client: 0, path: "A.md", content: "shared-content" },
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) => s.assertContent("A.md", "shared-content")
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertContent("A.md", "shared-content");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{ type: "disable-sync", client: 0 },
|
{ type: "disable-sync", client: 0 },
|
||||||
|
|
@ -37,20 +39,19 @@ export const offlineConcurrentRenamesTest: TestDefinition = {
|
||||||
|
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) => {
|
verify: (s: AssertableState): void => {
|
||||||
s.assertFileNotExists("A.md")
|
s.assertFileNotExists("A.md")
|
||||||
.assertFileCount(1)
|
.assertFileCount(1)
|
||||||
.assertAnyFileContains("shared-content");
|
.assertAnyFileContains("shared-content");
|
||||||
s.ifFileExists("B.md", (s) =>
|
s.ifFileExists("B.md", (inner) =>
|
||||||
s.assertContent("B.md", "shared-content")
|
inner.assertContent("B.md", "shared-content")
|
||||||
);
|
);
|
||||||
s.ifFileExists("C.md", (s) =>
|
s.ifFileExists("C.md", (inner) =>
|
||||||
s.assertContent("C.md", "shared-content")
|
inner.assertContent("C.md", "shared-content")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const offlineCreateSamePathMergeableTest: TestDefinition = {
|
export const offlineCreateSamePathMergeableTest: TestDefinition = {
|
||||||
|
|
@ -22,20 +23,19 @@ export const offlineCreateSamePathMergeableTest: TestDefinition = {
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "sync", client: 0 },
|
{ type: "sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) =>
|
verify: (s: AssertableState): void => {
|
||||||
s
|
s.assertFileCount(1)
|
||||||
.assertFileCount(1)
|
|
||||||
.assertFileExists("notes.md")
|
.assertFileExists("notes.md")
|
||||||
.assertContains(
|
.assertContains(
|
||||||
"notes.md",
|
"notes.md",
|
||||||
"alpha wrote this line",
|
"alpha wrote this line",
|
||||||
"beta wrote this different line"
|
"beta wrote this different line"
|
||||||
)
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const offlineDeleteRemoteRenameTest: TestDefinition = {
|
export const offlineDeleteRemoteRenameTest: TestDefinition = {
|
||||||
|
|
@ -27,9 +28,10 @@ export const offlineDeleteRemoteRenameTest: TestDefinition = {
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) => {
|
verify: (s: AssertableState): void => {
|
||||||
s.assertFileNotExists("A.md")
|
s.assertFileNotExists("A.md").assertFileNotExists(
|
||||||
.assertFileNotExists("A_renamed.md");
|
"A_renamed.md"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const offlineDeleteVsRemoteUpdateTest: TestDefinition = {
|
export const offlineDeleteVsRemoteUpdateTest: TestDefinition = {
|
||||||
|
|
@ -13,11 +14,12 @@ export const offlineDeleteVsRemoteUpdateTest: TestDefinition = {
|
||||||
},
|
},
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) => s.assertContent("A.md", "original content")
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertContent("A.md", "original content");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{ type: "disable-sync", client: 0 },
|
{ type: "disable-sync", client: 0 },
|
||||||
|
|
@ -32,12 +34,13 @@ export const offlineDeleteVsRemoteUpdateTest: TestDefinition = {
|
||||||
{ type: "sync", client: 1 },
|
{ type: "sync", client: 1 },
|
||||||
|
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) => s.assertFileCount(0)
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileCount(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const offlineEditRemoteRenameTest: TestDefinition = {
|
export const offlineEditRemoteRenameTest: TestDefinition = {
|
||||||
|
|
@ -9,11 +10,12 @@ export const offlineEditRemoteRenameTest: TestDefinition = {
|
||||||
{ type: "create", client: 0, path: "A.md", content: "original" },
|
{ type: "create", client: 0, path: "A.md", content: "original" },
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) => s.assertContent("A.md", "original")
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertContent("A.md", "original");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{ type: "disable-sync", client: 0 },
|
{ type: "disable-sync", client: 0 },
|
||||||
|
|
@ -33,16 +35,15 @@ export const offlineEditRemoteRenameTest: TestDefinition = {
|
||||||
{ type: "sync", client: 1 },
|
{ type: "sync", client: 1 },
|
||||||
|
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) =>
|
verify: (s: AssertableState): void => {
|
||||||
s
|
s.assertFileNotExists("A.md")
|
||||||
.assertFileNotExists("A.md")
|
|
||||||
.assertFileCount(1)
|
.assertFileCount(1)
|
||||||
.assertContains("B.md", "edited by client 0")
|
.assertContains("B.md", "edited by client 0");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const offlineEditThenMoveSameContentTest: TestDefinition = {
|
export const offlineEditThenMoveSameContentTest: TestDefinition = {
|
||||||
|
|
@ -19,7 +20,6 @@ export const offlineEditThenMoveSameContentTest: TestDefinition = {
|
||||||
},
|
},
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "disable-sync", client: 0 },
|
{ type: "disable-sync", client: 0 },
|
||||||
|
|
@ -36,17 +36,16 @@ export const offlineEditThenMoveSameContentTest: TestDefinition = {
|
||||||
},
|
},
|
||||||
|
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) =>
|
verify: (s: AssertableState): void => {
|
||||||
s
|
s.assertFileNotExists("A.md")
|
||||||
.assertFileNotExists("A.md")
|
|
||||||
.assertFileNotExists("B.md")
|
.assertFileNotExists("B.md")
|
||||||
.assertContent("C.md", "content A")
|
.assertContent("C.md", "content A")
|
||||||
.assertFileCount(1)
|
.assertFileCount(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const offlineMixedOperationsTest: TestDefinition = {
|
export const offlineMixedOperationsTest: TestDefinition = {
|
||||||
|
|
@ -12,16 +13,15 @@ export const offlineMixedOperationsTest: TestDefinition = {
|
||||||
{ type: "create", client: 0, path: "file3.md", content: "content-3" },
|
{ type: "create", client: 0, path: "file3.md", content: "content-3" },
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) =>
|
verify: (s: AssertableState): void => {
|
||||||
s
|
s.assertContent("file1.md", "content-1")
|
||||||
.assertContent("file1.md", "content-1")
|
|
||||||
.assertContent("file2.md", "content-2")
|
.assertContent("file2.md", "content-2")
|
||||||
.assertContent("file3.md", "content-3")
|
.assertContent("file3.md", "content-3");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{ type: "disable-sync", client: 0 },
|
{ type: "disable-sync", client: 0 },
|
||||||
|
|
@ -41,18 +41,17 @@ export const offlineMixedOperationsTest: TestDefinition = {
|
||||||
},
|
},
|
||||||
|
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) =>
|
verify: (s: AssertableState): void => {
|
||||||
s
|
s.assertFileNotExists("file1.md")
|
||||||
.assertFileNotExists("file1.md")
|
|
||||||
.assertFileNotExists("file2.md")
|
.assertFileNotExists("file2.md")
|
||||||
.assertContent("moved.md", "content-2")
|
.assertContent("moved.md", "content-2")
|
||||||
.assertContent("file3.md", "updated-content-3")
|
.assertContent("file3.md", "updated-content-3")
|
||||||
.assertFileCount(2)
|
.assertFileCount(2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const offlineMoveThenRemoteDeleteTest: TestDefinition = {
|
export const offlineMoveThenRemoteDeleteTest: TestDefinition = {
|
||||||
|
|
@ -14,7 +15,6 @@ export const offlineMoveThenRemoteDeleteTest: TestDefinition = {
|
||||||
},
|
},
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "disable-sync", client: 0 },
|
{ type: "disable-sync", client: 0 },
|
||||||
|
|
@ -24,16 +24,13 @@ export const offlineMoveThenRemoteDeleteTest: TestDefinition = {
|
||||||
{ type: "sync", client: 1 },
|
{ type: "sync", client: 1 },
|
||||||
|
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) =>
|
verify: (s: AssertableState): void => {
|
||||||
s
|
s.assertFileCount(0);
|
||||||
.assertFileNotExists("A.md")
|
}
|
||||||
.assertFileNotExists("B.md")
|
|
||||||
.assertFileCount(0)
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const offlineMultipleEditsTest: TestDefinition = {
|
export const offlineMultipleEditsTest: TestDefinition = {
|
||||||
|
|
@ -10,11 +11,12 @@ export const offlineMultipleEditsTest: TestDefinition = {
|
||||||
{ type: "create", client: 0, path: "doc.md", content: "original" },
|
{ type: "create", client: 0, path: "doc.md", content: "original" },
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) => s.assertContent("doc.md", "original")
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertContent("doc.md", "original");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{ type: "disable-sync", client: 0 },
|
{ type: "disable-sync", client: 0 },
|
||||||
|
|
@ -26,13 +28,13 @@ export const offlineMultipleEditsTest: TestDefinition = {
|
||||||
{ type: "update", client: 0, path: "doc.md", content: "edit-5-final" },
|
{ type: "update", client: 0, path: "doc.md", content: "edit-5-final" },
|
||||||
|
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) =>
|
verify: (s: AssertableState): void => {
|
||||||
s.assertFileCount(1).assertContent("doc.md", "edit-5-final")
|
s.assertFileCount(1).assertContent("doc.md", "edit-5-final");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const offlineRenameAndEditTest: TestDefinition = {
|
export const offlineRenameAndEditTest: TestDefinition = {
|
||||||
|
|
@ -10,28 +11,33 @@ export const offlineRenameAndEditTest: TestDefinition = {
|
||||||
{ type: "create", client: 0, path: "A.md", content: "original" },
|
{ type: "create", client: 0, path: "A.md", content: "original" },
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) => s.assertContent("A.md", "original")
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertContent("A.md", "original");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{ type: "disable-sync", client: 0 },
|
{ type: "disable-sync", client: 0 },
|
||||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
||||||
{ type: "update", client: 0, path: "B.md", content: "edited after rename" },
|
{
|
||||||
|
type: "update",
|
||||||
|
client: 0,
|
||||||
|
path: "B.md",
|
||||||
|
content: "edited after rename"
|
||||||
|
},
|
||||||
|
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) =>
|
verify: (s: AssertableState): void => {
|
||||||
s
|
s.assertFileNotExists("A.md")
|
||||||
.assertFileNotExists("A.md")
|
|
||||||
.assertFileCount(1)
|
.assertFileCount(1)
|
||||||
.assertContent("B.md", "edited after rename")
|
.assertContent("B.md", "edited after rename");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const offlineRenameRemoteCreateOldPathTest: TestDefinition = {
|
export const offlineRenameRemoteCreateOldPathTest: TestDefinition = {
|
||||||
|
|
@ -10,11 +11,12 @@ export const offlineRenameRemoteCreateOldPathTest: TestDefinition = {
|
||||||
{ type: "create", client: 0, path: "X.md", content: "original" },
|
{ type: "create", client: 0, path: "X.md", content: "original" },
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) => s.assertContent("X.md", "original")
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertContent("X.md", "original");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{ type: "disable-sync", client: 0 },
|
{ type: "disable-sync", client: 0 },
|
||||||
|
|
@ -34,15 +36,16 @@ export const offlineRenameRemoteCreateOldPathTest: TestDefinition = {
|
||||||
{ type: "sync", client: 1 },
|
{ type: "sync", client: 1 },
|
||||||
|
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) =>
|
verify: (s: AssertableState): void => {
|
||||||
s
|
s.assertFileCount(1).assertContains(
|
||||||
.assertFileCount(1)
|
"Y.md",
|
||||||
.assertContains("Y.md", "updated-by-client-1")
|
"updated-by-client-1"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const offlineUpdateBothThenDeleteOneTest: TestDefinition = {
|
export const offlineUpdateBothThenDeleteOneTest: TestDefinition = {
|
||||||
|
|
@ -22,14 +23,15 @@ export const offlineUpdateBothThenDeleteOneTest: TestDefinition = {
|
||||||
},
|
},
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) =>
|
verify: (s: AssertableState): void => {
|
||||||
s
|
s.assertContent("A.md", "A original").assertContent(
|
||||||
.assertContent("A.md", "A original")
|
"B.md",
|
||||||
.assertContent("B.md", "B original")
|
"B original"
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{ type: "disable-sync", client: 0 },
|
{ type: "disable-sync", client: 0 },
|
||||||
|
|
@ -58,15 +60,16 @@ export const offlineUpdateBothThenDeleteOneTest: TestDefinition = {
|
||||||
{ type: "sync", client: 1 },
|
{ type: "sync", client: 1 },
|
||||||
|
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) =>
|
verify: (s: AssertableState): void => {
|
||||||
s
|
s.assertContent(
|
||||||
.assertContent("A.md", "A updated by client 0")
|
"A.md",
|
||||||
.assertFileNotExists("B.md")
|
"A updated by client 0"
|
||||||
|
).assertFileNotExists("B.md");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const onlineBothCreateSamePathDeconflictTest: TestDefinition = {
|
export const onlineBothCreateSamePathDeconflictTest: TestDefinition = {
|
||||||
|
|
@ -23,7 +24,7 @@ export const onlineBothCreateSamePathDeconflictTest: TestDefinition = {
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (state) => {
|
verify: (state: AssertableState): void => {
|
||||||
state
|
state
|
||||||
.assertFileCount(1)
|
.assertFileCount(1)
|
||||||
.assertContains("A.md", "updated-by-0", "from-client-1 ");
|
.assertContains("A.md", "updated-by-0", "from-client-1 ");
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const onlineCreateRenameConcurrentCreateOrphanTest: TestDefinition = {
|
export const onlineCreateRenameConcurrentCreateOrphanTest: TestDefinition = {
|
||||||
|
|
@ -12,8 +13,18 @@ export const onlineCreateRenameConcurrentCreateOrphanTest: TestDefinition = {
|
||||||
|
|
||||||
{ type: "disable-sync", client: 0 },
|
{ 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: "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: "enable-sync", client: 0 },
|
||||||
{ type: "delete", client: 0, path: "moved.bin" },
|
{ type: "delete", client: 0, path: "moved.bin" },
|
||||||
|
|
@ -22,7 +33,7 @@ export const onlineCreateRenameConcurrentCreateOrphanTest: TestDefinition = {
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (state) => {
|
verify: (state: AssertableState): void => {
|
||||||
state.assertFileCount(0);
|
state.assertFileCount(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const onlineCreateUpdateWhileOtherCreatesSamePathTest: TestDefinition = {
|
export const onlineCreateUpdateWhileOtherCreatesSamePathTest: TestDefinition = {
|
||||||
|
|
@ -11,18 +12,36 @@ export const onlineCreateUpdateWhileOtherCreatesSamePathTest: TestDefinition = {
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
|
|
||||||
{ type: "pause-websocket", 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",
|
||||||
{ type: "create", client: 1, path: "data.bin", content: "BINARY:other-content" },
|
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: "resume-websocket", client: 1 },
|
||||||
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent", verify: (state) => {
|
type: "assert-consistent",
|
||||||
state.assertFileCount(2)
|
verify: (state: AssertableState): void => {
|
||||||
.assertContains("data.bin", "content-v2")
|
state
|
||||||
.assertContains("data (1).bin", "other-content");
|
.assertFileCount(2)
|
||||||
|
.assertNoFileContains("content-v1")
|
||||||
|
.assertAnyFileContains("content-v2")
|
||||||
|
.assertAnyFileContains("other-content");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const onlineDeleteRecreateRapidCycleTest: TestDefinition = {
|
export const onlineDeleteRecreateRapidCycleTest: TestDefinition = {
|
||||||
|
|
@ -28,7 +29,9 @@ export const onlineDeleteRecreateRapidCycleTest: TestDefinition = {
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) => s.assertContent("A.md", "round 3"),
|
verify: (s: AssertableState): void => {
|
||||||
},
|
s.assertContent("A.md", "round 3");
|
||||||
],
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const onlineEditVsDeleteConvergenceTest: TestDefinition = {
|
export const onlineEditVsDeleteConvergenceTest: TestDefinition = {
|
||||||
|
|
@ -11,17 +12,20 @@ export const onlineEditVsDeleteConvergenceTest: TestDefinition = {
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "update", client: 0, path: "A.md", content: "edited by client 0" },
|
{
|
||||||
|
type: "update",
|
||||||
|
client: 0,
|
||||||
|
path: "A.md",
|
||||||
|
content: "edited by client 0"
|
||||||
|
},
|
||||||
{ type: "delete", client: 1, path: "A.md" },
|
{ type: "delete", client: 1, path: "A.md" },
|
||||||
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (state) => {
|
verify: (state: AssertableState): void => {
|
||||||
state.ifFileExists("A.md", (s) =>
|
state.assertFileCount(0);
|
||||||
s.assertContainsAny("A.md", "edited by client 0")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
],
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const overlappingEditsSameSectionTest: TestDefinition = {
|
export const overlappingEditsSameSectionTest: TestDefinition = {
|
||||||
|
|
@ -14,7 +15,6 @@ export const overlappingEditsSameSectionTest: TestDefinition = {
|
||||||
},
|
},
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "disable-sync", client: 0 },
|
{ type: "disable-sync", client: 0 },
|
||||||
|
|
@ -36,14 +36,19 @@ export const overlappingEditsSameSectionTest: TestDefinition = {
|
||||||
|
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) =>
|
verify: (s: AssertableState): void => {
|
||||||
s.assertFileCount(1)
|
s.assertFileCount(1).assertContains(
|
||||||
.assertContains("doc.md", "# Title", "alpha addition", "beta addition", "footer"),
|
"doc.md",
|
||||||
|
"# Title",
|
||||||
|
"alpha addition",
|
||||||
|
"beta addition",
|
||||||
|
"footer"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const queueResetLosesCoalescedLocalEditTest: TestDefinition = {
|
export const queueResetLosesCoalescedLocalEditTest: TestDefinition = {
|
||||||
|
|
@ -23,8 +24,13 @@ export const queueResetLosesCoalescedLocalEditTest: TestDefinition = {
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) =>
|
verify: (s: AssertableState): void => {
|
||||||
s.assertFileCount(1).assertContains("doc.md", "alpha", "charlie"),
|
s.assertFileCount(1).assertContains(
|
||||||
|
"doc.md",
|
||||||
|
"alpha",
|
||||||
|
"charlie"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
|
export const queuedCreateDeleteDoesNotHijackReusedPathTest: TestDefinition = {
|
||||||
|
description:
|
||||||
|
"A create/delete pair that is still queued behind another request " +
|
||||||
|
"must collapse locally. It must not later read a different file " +
|
||||||
|
"that reused the same path before the queued create drained.",
|
||||||
|
clients: 2,
|
||||||
|
steps: [
|
||||||
|
{ type: "enable-sync", client: 0 },
|
||||||
|
{ type: "enable-sync", client: 1 },
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{ type: "pause-server" },
|
||||||
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 1,
|
||||||
|
path: "blocker.bin",
|
||||||
|
content: "BINARY:blocker"
|
||||||
|
},
|
||||||
|
{ type: "sleep", ms: 100 },
|
||||||
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 1,
|
||||||
|
path: "target.bin",
|
||||||
|
content: "BINARY:old"
|
||||||
|
},
|
||||||
|
{ type: "delete", client: 1, path: "target.bin" },
|
||||||
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 1,
|
||||||
|
path: "source.bin",
|
||||||
|
content: "BINARY:new"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "rename",
|
||||||
|
client: 1,
|
||||||
|
oldPath: "source.bin",
|
||||||
|
newPath: "target.bin"
|
||||||
|
},
|
||||||
|
{ type: "resume-server" },
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (state: AssertableState): void => {
|
||||||
|
state
|
||||||
|
.assertFileCount(2)
|
||||||
|
.assertContent("blocker.bin", "BINARY:blocker")
|
||||||
|
.assertContent("target.bin", "BINARY:new")
|
||||||
|
.assertFileNotExists("source.bin");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const rapidCreateUpdateDeleteCycleTest: TestDefinition = {
|
export const rapidCreateUpdateDeleteCycleTest: TestDefinition = {
|
||||||
|
|
@ -8,7 +9,6 @@ export const rapidCreateUpdateDeleteCycleTest: TestDefinition = {
|
||||||
steps: [
|
steps: [
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "pause-server" },
|
{ type: "pause-server" },
|
||||||
|
|
@ -41,7 +41,12 @@ export const rapidCreateUpdateDeleteCycleTest: TestDefinition = {
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) => s.assertFileCount(1).assertContent("cycle.md", "final creation"),
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileCount(1).assertContent(
|
||||||
|
"cycle.md",
|
||||||
|
"final creation"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const rapidEditDeleteOnlineConvergenceTest: TestDefinition = {
|
export const rapidEditDeleteOnlineConvergenceTest: TestDefinition = {
|
||||||
|
|
@ -28,17 +29,20 @@ export const rapidEditDeleteOnlineConvergenceTest: TestDefinition = {
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) => {
|
verify: (s: AssertableState): void => {
|
||||||
for (const [path, content] of s.files) {
|
for (const [path, content] of s.files) {
|
||||||
for (const clientFiles of s.clientFiles) {
|
for (const clientFiles of s.clientFiles) {
|
||||||
if (clientFiles.has(path) && clientFiles.get(path) !== content) {
|
if (
|
||||||
|
clientFiles.has(path) &&
|
||||||
|
clientFiles.get(path) !== content
|
||||||
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Content mismatch for ${path}: "${clientFiles.get(path)}" vs "${content}"`
|
`Content mismatch for ${path}: "${clientFiles.get(path)}" vs "${content}"`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
],
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const rapidUpdatesAfterMergeTest: TestDefinition = {
|
export const rapidUpdatesAfterMergeTest: TestDefinition = {
|
||||||
|
|
@ -11,7 +12,6 @@ export const rapidUpdatesAfterMergeTest: TestDefinition = {
|
||||||
|
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
@ -36,13 +36,14 @@ export const rapidUpdatesAfterMergeTest: TestDefinition = {
|
||||||
path: "doc.md",
|
path: "doc.md",
|
||||||
content: "update 3"
|
content: "update 3"
|
||||||
},
|
},
|
||||||
{ type: "sync", client: 0 },
|
|
||||||
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) => s.assertFileCount(1).assertContains("doc.md", "update 3"),
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileCount(1).assertContains("doc.md", "update 3");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const recentlyDeletedClearedOnReconnectTest: TestDefinition = {
|
export const recentlyDeletedClearedOnReconnectTest: TestDefinition = {
|
||||||
|
|
@ -19,7 +20,12 @@ export const recentlyDeletedClearedOnReconnectTest: TestDefinition = {
|
||||||
{ type: "disable-sync", client: 0 },
|
{ type: "disable-sync", client: 0 },
|
||||||
{ type: "disable-sync", client: 1 },
|
{ type: "disable-sync", client: 1 },
|
||||||
|
|
||||||
{ type: "create", client: 1, path: "doc.md", content: "new content from client 1" },
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 1,
|
||||||
|
path: "doc.md",
|
||||||
|
content: "new content from client 1"
|
||||||
|
},
|
||||||
|
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync", client: 1 },
|
{ type: "sync", client: 1 },
|
||||||
|
|
@ -28,8 +34,12 @@ export const recentlyDeletedClearedOnReconnectTest: TestDefinition = {
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) =>
|
verify: (s: AssertableState): void => {
|
||||||
s.assertFileCount(1).assertContent("doc.md", "new content from client 1"),
|
s.assertFileCount(1).assertContent(
|
||||||
},
|
"doc.md",
|
||||||
],
|
"new content from client 1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
|
export const remoteQuickWriteRenameBeforeRecordTest: TestDefinition = {
|
||||||
|
description:
|
||||||
|
"Client 0 receives a remote create and the user renames the new " +
|
||||||
|
"file immediately after the syncer writes it. The watcher event " +
|
||||||
|
"must bind to the new document instead of being dropped before " +
|
||||||
|
"the remote-create handler persists the record.",
|
||||||
|
clients: 2,
|
||||||
|
steps: [
|
||||||
|
{ type: "enable-sync", client: 0 },
|
||||||
|
{ type: "enable-sync", client: 1 },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "rename-next-write",
|
||||||
|
client: 0,
|
||||||
|
oldPath: "doc.md",
|
||||||
|
newPath: "renamed.md"
|
||||||
|
},
|
||||||
|
|
||||||
|
{ type: "create", client: 1, path: "doc.md", content: "v1\n" },
|
||||||
|
{ type: "sync", client: 1 },
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileCount(1);
|
||||||
|
s.assertFileExists("renamed.md");
|
||||||
|
s.assertFileNotExists("doc.md");
|
||||||
|
s.assertContent("renamed.md", "v1\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
|
export const remoteRenameCollidesWithPendingLocalCreateTest: TestDefinition = {
|
||||||
|
// TODO(refactor): the failure mode described below is the
|
||||||
|
// pre-refactor "deflect-to-conflict-uuid" path that no longer
|
||||||
|
// exists. Under the new model the wire loop never moves files for
|
||||||
|
// path placement, so the remote rename can't deflect anywhere; the
|
||||||
|
// reconciler waits for the slot to free. Convergence assertion is
|
||||||
|
// still valid (no conflict-uuid stashes, both files present, the
|
||||||
|
// local create lands at a server-deconflicted sibling).
|
||||||
|
description:
|
||||||
|
"Client 0 has doc D tracked at `original.md`. Client 1 owns doc E " +
|
||||||
|
"and renames it to `target.md` server-side. Before client 0's " +
|
||||||
|
"drain processes the WS broadcast for E, the user creates a new " +
|
||||||
|
"local file `target.md` (a different doc, untracked). When the " +
|
||||||
|
"buffered RemoteChange for E drains, the engine has to reconcile " +
|
||||||
|
"doc E onto `target.md` even though the slot is held by client " +
|
||||||
|
"0's pending LocalCreate. Convergence requires both clients end " +
|
||||||
|
"up with [target.md = E] and the local create lands at a " +
|
||||||
|
"server-deconflicted sibling (e.g. `target (1).md`).",
|
||||||
|
clients: 2,
|
||||||
|
steps: [
|
||||||
|
{ type: "enable-sync", client: 0 },
|
||||||
|
{ type: "enable-sync", client: 1 },
|
||||||
|
|
||||||
|
{ type: "create", client: 1, path: "original.md", content: "v1\n" },
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
// Pause client 0's WS so the upcoming remote rename buffers and
|
||||||
|
// we can stage a colliding local create before the rename
|
||||||
|
// drains on client 0.
|
||||||
|
{ type: "pause-websocket", client: 0 },
|
||||||
|
|
||||||
|
// Client 1 renames the doc. Server commits, broadcasts to
|
||||||
|
// client 0 (buffered).
|
||||||
|
{
|
||||||
|
type: "rename",
|
||||||
|
client: 1,
|
||||||
|
oldPath: "original.md",
|
||||||
|
newPath: "target.md"
|
||||||
|
},
|
||||||
|
{ type: "sync", client: 1 },
|
||||||
|
|
||||||
|
// Client 0 still believes the doc is at `original.md`. The user
|
||||||
|
// creates a NEW file at `target.md` (an unrelated untracked
|
||||||
|
// doc). Disk on client 0 now has both `original.md` (the
|
||||||
|
// tracked doc) and `target.md` (the new untracked file).
|
||||||
|
{ type: "create", client: 0, path: "target.md", content: "extra\n" },
|
||||||
|
|
||||||
|
// Resume client 0's WS. The buffered RemoteChange drains.
|
||||||
|
// The reconciler must converge without ever leaving a
|
||||||
|
// conflict-uuid stash on disk.
|
||||||
|
{ type: "resume-websocket", client: 0 },
|
||||||
|
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (state: AssertableState): void => {
|
||||||
|
state.assertFileCount(2);
|
||||||
|
for (const path of state.files.keys()) {
|
||||||
|
if (path.startsWith("conflict-")) {
|
||||||
|
throw new Error(
|
||||||
|
`Unexpected conflict-uuid stash on a converged client: ${path}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.assertFileExists("target.md");
|
||||||
|
state.assertContent("target.md", "v1\n");
|
||||||
|
// The local create gets server-deconflicted to a
|
||||||
|
// sibling path (e.g. `target (1).md`).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
|
export const remoteUpdateResurrectsDeletedDocTest: TestDefinition = {
|
||||||
|
description:
|
||||||
|
"Client 1 updates, deletes, and recreates P (with a new docId D2). " +
|
||||||
|
"While the buffered remote events are being processed by client 0, " +
|
||||||
|
"client 0 also makes a local edit to P. The local edit lands in the " +
|
||||||
|
"queue while v17 is mid-process, sending v17 down processRemoteUpdate's " +
|
||||||
|
"re-enqueue branch. The deferred v17 must NOT later resurrect D1 as a " +
|
||||||
|
"conflict-… file at P after the delete and the D2 create have drained.",
|
||||||
|
clients: 2,
|
||||||
|
steps: [
|
||||||
|
{ type: "enable-sync", client: 0 },
|
||||||
|
{ type: "enable-sync", client: 1 },
|
||||||
|
|
||||||
|
{ type: "create", client: 1, path: "P.md", content: "v8 content\n" },
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{ type: "pause-websocket", client: 0 },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "update",
|
||||||
|
client: 1,
|
||||||
|
path: "P.md",
|
||||||
|
content: "v17 content from client 1\n"
|
||||||
|
},
|
||||||
|
{ type: "sync", client: 1 },
|
||||||
|
{ type: "delete", client: 1, path: "P.md" },
|
||||||
|
{ type: "sync", client: 1 },
|
||||||
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 1,
|
||||||
|
path: "P.md",
|
||||||
|
content: "v21 content (D2)\n"
|
||||||
|
},
|
||||||
|
{ type: "sync", client: 1 },
|
||||||
|
|
||||||
|
{ type: "resume-websocket", client: 0 },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "update",
|
||||||
|
client: 0,
|
||||||
|
path: "P.md",
|
||||||
|
content: "local edit by client 0\n"
|
||||||
|
},
|
||||||
|
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (state: AssertableState): void => {
|
||||||
|
state
|
||||||
|
.assertFileCount(1)
|
||||||
|
.assertContent("P.md", "v21 content (D2)\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
|
export const remoteUpdateSurvivesUserRenameTest: TestDefinition = {
|
||||||
|
description:
|
||||||
|
"Client 0 updates a tracked doc; while Client 1 is processing the " +
|
||||||
|
"broadcast and parked on the GET for the new version's content, the " +
|
||||||
|
"user renames the doc on Client 1. Pre-fix: `processRemoteUpdate` " +
|
||||||
|
"captures `actualPath` before the await and, after the GET returns, " +
|
||||||
|
"calls `write(actualPath, …)` (no-op — file was renamed away), " +
|
||||||
|
"`updateCache(actualPath, …)`, and `setDocument(actualPath, …)`. " +
|
||||||
|
"`setDocument` mutates the same record in place so its `path` is " +
|
||||||
|
"yanked from the user's renamed slot back to the pre-rename path, " +
|
||||||
|
"wiping the rename out of the queue's documents map. The queued " +
|
||||||
|
"`LocalUpdate` then reads from the now-stale `record.path`, hits " +
|
||||||
|
"`FileNotFoundError`, and is silently dropped — the user's rename " +
|
||||||
|
"never reaches the server. Post-fix: the handler defers when a " +
|
||||||
|
"local event landed mid-await, so the rename drains first and " +
|
||||||
|
"the deferred remote update is folded into the broadcast that " +
|
||||||
|
"follows the rename round-trip.",
|
||||||
|
clients: 2,
|
||||||
|
steps: [
|
||||||
|
{ type: "create", client: 0, path: "doc.md", content: "v1\n" },
|
||||||
|
{ type: "enable-sync", client: 0 },
|
||||||
|
{ type: "enable-sync", client: 1 },
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
// Buffer Client 1's incoming broadcasts so it doesn't see
|
||||||
|
// Client 0's update until we've paused the server.
|
||||||
|
{ type: "pause-websocket", client: 1 },
|
||||||
|
|
||||||
|
// Server now holds v=2 of doc.md.
|
||||||
|
{ type: "update", client: 0, path: "doc.md", content: "v2\n" },
|
||||||
|
{ type: "sync", client: 0 },
|
||||||
|
|
||||||
|
// Pause the server. Client 1's upcoming GET for the new version
|
||||||
|
// content blocks at the OS layer until resume.
|
||||||
|
{ type: "pause-server" },
|
||||||
|
|
||||||
|
// Release the buffered broadcast. Client 1's drain enters
|
||||||
|
// `processRemoteUpdate`, captures `actualPath`, fires the GET,
|
||||||
|
// and parks awaiting the response.
|
||||||
|
{ type: "resume-websocket", client: 1 },
|
||||||
|
|
||||||
|
// Yield long enough for the drain to traverse all microtask
|
||||||
|
// hops between the WS handler and the GET, so the HTTP request
|
||||||
|
// is queued at the (paused) server before the rename runs.
|
||||||
|
// Without this yield the rename would be enqueued before
|
||||||
|
// `processRemoteUpdate`'s entry-time `hasPendingLocalEvents`
|
||||||
|
// check and the early-defer branch would mask the bug.
|
||||||
|
{ type: "sleep", ms: 50 },
|
||||||
|
|
||||||
|
// While the GET is in flight the user renames the doc. The queue
|
||||||
|
// mutates `record.path` to "renamed.md" in place and pushes a
|
||||||
|
// LocalUpdate carrying the rename target.
|
||||||
|
{
|
||||||
|
type: "rename",
|
||||||
|
client: 1,
|
||||||
|
oldPath: "doc.md",
|
||||||
|
newPath: "renamed.md"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Resume the server. The GET response unblocks
|
||||||
|
// `processRemoteUpdate`. With the fix in place it sees the
|
||||||
|
// queued LocalUpdate and defers; without the fix it walks past
|
||||||
|
// the rename and clobbers the documents map, dropping the
|
||||||
|
// pending LocalUpdate's read on the way back through.
|
||||||
|
{ type: "resume-server" },
|
||||||
|
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileCount(1);
|
||||||
|
s.assertFileExists("renamed.md");
|
||||||
|
s.assertFileNotExists("doc.md");
|
||||||
|
// Both edits survive: the user's rename and Client 0's
|
||||||
|
// content update at v=2.
|
||||||
|
s.assertContent("renamed.md", "v2\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
|
export const renameChainDuringPendingCreateTest: TestDefinition = {
|
||||||
|
description:
|
||||||
|
"User creates a doc, then renames it twice while the LocalCreate's " +
|
||||||
|
"HTTP roundtrip is still in flight (server paused). Each rename " +
|
||||||
|
"pushes a LocalUpdate whose `documentId` is the create's Promise " +
|
||||||
|
"(see `pendingDocumentId` in `SyncEventQueue.enqueue`). After the " +
|
||||||
|
"create resolves, the first rename drains successfully and " +
|
||||||
|
"`setDocument` walks `events[]` to retarget queued LocalUpdates' " +
|
||||||
|
"`event.path` to the new disk location — but the comparison " +
|
||||||
|
"`e.documentId === record.documentId` mismatches the still-Promise " +
|
||||||
|
"references, so the second rename's `event.path` stays at the " +
|
||||||
|
"vacated previous slot. On the next drain step `skipIfOversized`'s " +
|
||||||
|
"`getFileSize(event.path)` throws FileNotFoundError, which " +
|
||||||
|
"`processEvent` swallows as 'Skipping sync event ... because the " +
|
||||||
|
"file no longer exists' — losing the user's final rename. " +
|
||||||
|
"Post-fix: `resolveCreate` (and the displacement-merge branch in " +
|
||||||
|
"`processCreate`) swap the Promise references for the resolved id " +
|
||||||
|
"before `setDocument` runs, so retarget works.",
|
||||||
|
clients: 2,
|
||||||
|
steps: [
|
||||||
|
{ type: "enable-sync", client: 0 },
|
||||||
|
{ type: "enable-sync", client: 1 },
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
// Pause the server so client 0's create stalls on the HTTP PUT
|
||||||
|
// while we queue rename events behind it.
|
||||||
|
{ type: "pause-server" },
|
||||||
|
|
||||||
|
{ type: "create", client: 0, path: "first.md", content: "v1\n" },
|
||||||
|
{
|
||||||
|
type: "rename",
|
||||||
|
client: 0,
|
||||||
|
oldPath: "first.md",
|
||||||
|
newPath: "second.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "rename",
|
||||||
|
client: 0,
|
||||||
|
oldPath: "second.md",
|
||||||
|
newPath: "third.md"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Resume — drain pops LocalCreate (now resolves), then the two
|
||||||
|
// queued LocalUpdates. Pre-fix: only the first rename's
|
||||||
|
// file-system effect lands; the second is silently dropped.
|
||||||
|
// The server ends up with the doc at second.md, leaving
|
||||||
|
// client 0's local third.md untracked / out-of-sync.
|
||||||
|
{ type: "resume-server" },
|
||||||
|
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (state: AssertableState): void => {
|
||||||
|
state.assertFileCount(1);
|
||||||
|
state.assertFileExists("third.md");
|
||||||
|
state.assertContent("third.md", "v1\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const renameChainThenDeleteTest: TestDefinition = {
|
export const renameChainThenDeleteTest: TestDefinition = {
|
||||||
|
|
@ -9,11 +10,12 @@ export const renameChainThenDeleteTest: TestDefinition = {
|
||||||
{ type: "create", client: 0, path: "X.md", content: "chain-content" },
|
{ type: "create", client: 0, path: "X.md", content: "chain-content" },
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) => s.assertContent("X.md", "chain-content"),
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertContent("X.md", "chain-content");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{ type: "disable-sync", client: 1 },
|
{ type: "disable-sync", client: 1 },
|
||||||
|
|
@ -36,9 +38,13 @@ export const renameChainThenDeleteTest: TestDefinition = {
|
||||||
{ type: "sync", client: 0 },
|
{ type: "sync", client: 0 },
|
||||||
|
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{ type: "assert-consistent", verify: (s) => s.assertFileCount(0) }
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertFileCount(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const renameChainTest: TestDefinition = {
|
export const renameChainTest: TestDefinition = {
|
||||||
|
|
@ -9,20 +10,25 @@ export const renameChainTest: TestDefinition = {
|
||||||
steps: [
|
steps: [
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
|
|
||||||
{ type: "create", client: 0, path: "A.md", content: "important content" },
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 0,
|
||||||
|
path: "A.md",
|
||||||
|
content: "important content"
|
||||||
|
},
|
||||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
||||||
{ type: "rename", client: 0, oldPath: "B.md", newPath: "C.md" },
|
{ type: "rename", client: 0, oldPath: "B.md", newPath: "C.md" },
|
||||||
|
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) =>
|
verify: (s: AssertableState): void => {
|
||||||
s.assertFileNotExists("A.md")
|
s.assertFileNotExists("A.md")
|
||||||
.assertFileNotExists("B.md")
|
.assertFileNotExists("B.md")
|
||||||
.assertContent("C.md", "important content"),
|
.assertContent("C.md", "important content");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const renameCircularTest: TestDefinition = {
|
export const renameCircularTest: TestDefinition = {
|
||||||
|
|
@ -13,10 +14,11 @@ export const renameCircularTest: TestDefinition = {
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) =>
|
verify: (s: AssertableState): void => {
|
||||||
s.assertContent("A.md", "content-a")
|
s.assertContent("A.md", "content-a")
|
||||||
.assertContent("B.md", "content-b")
|
.assertContent("B.md", "content-b")
|
||||||
.assertContent("C.md", "content-c"),
|
.assertContent("C.md", "content-c");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{ type: "disable-sync", client: 0 },
|
{ type: "disable-sync", client: 0 },
|
||||||
|
|
@ -26,17 +28,17 @@ export const renameCircularTest: TestDefinition = {
|
||||||
{ type: "rename", client: 0, oldPath: "temp-a.md", newPath: "B.md" },
|
{ type: "rename", client: 0, oldPath: "temp-a.md", newPath: "B.md" },
|
||||||
|
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) =>
|
verify: (s: AssertableState): void => {
|
||||||
s.assertFileNotExists("temp-a.md")
|
s.assertFileNotExists("temp-a.md")
|
||||||
.assertFileCount(3)
|
.assertFileCount(3)
|
||||||
.assertContent("A.md", "content-c")
|
.assertAnyFileContains("content-c")
|
||||||
.assertContent("B.md", "content-a")
|
.assertAnyFileContains("content-a")
|
||||||
.assertContent("C.md", "content-b"),
|
.assertAnyFileContains("content-b");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const renameCreateConflictTest: TestDefinition = {
|
export const renameCreateConflictTest: TestDefinition = {
|
||||||
|
|
@ -8,23 +9,26 @@ export const renameCreateConflictTest: TestDefinition = {
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "create", client: 0, path: "A.md", content: "hi" },
|
{ type: "create", client: 0, path: "A.md", content: "hi" },
|
||||||
{ type: "sync", client: 0 },
|
{ type: "barrier" },
|
||||||
{ type: "sync", client: 1 },
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) => s.assertContent("A.md", "hi"),
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertContent("A.md", "hi");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ type: "disable-sync", client: 0 },
|
{ type: "disable-sync", client: 0 },
|
||||||
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
|
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
|
||||||
{ type: "sync", client: 1 },
|
{ type: "sync", client: 1 },
|
||||||
{ type: "create", client: 0, path: "B.md", content: "hi" },
|
{ type: "create", client: 0, path: "B.md", content: "hi" },
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "sync", client: 0 },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) =>
|
verify: (s: AssertableState): void => {
|
||||||
s.assertFileNotExists("A.md").assertContent("B.md", "hi"),
|
s.assertFileCount(2)
|
||||||
|
.assertContent("B.md", "hi")
|
||||||
|
.assertContent("B (1).md", "hi");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
|
export const renameOverwritesPendingCreateThenDeleteTest: TestDefinition = {
|
||||||
|
description:
|
||||||
|
"A pending local create at a path must not mask a synced document renamed onto that path; later rename/delete events still belong to the synced document.",
|
||||||
|
clients: 2,
|
||||||
|
steps: [
|
||||||
|
{ type: "enable-sync", client: 0 },
|
||||||
|
{ type: "enable-sync", client: 1 },
|
||||||
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 0,
|
||||||
|
path: "tracked.bin",
|
||||||
|
content: "BINARY:tracked"
|
||||||
|
},
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{ type: "pause-server" },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 0,
|
||||||
|
path: "pending.bin",
|
||||||
|
content: "BINARY:pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "rename",
|
||||||
|
client: 0,
|
||||||
|
oldPath: "tracked.bin",
|
||||||
|
newPath: "pending.bin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "rename",
|
||||||
|
client: 0,
|
||||||
|
oldPath: "pending.bin",
|
||||||
|
newPath: "final.bin"
|
||||||
|
},
|
||||||
|
{ type: "delete", client: 0, path: "final.bin" },
|
||||||
|
|
||||||
|
{ type: "resume-server" },
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (state: AssertableState): void => {
|
||||||
|
state.assertFileCount(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const renamePendingCreateBeforeResponseTest: TestDefinition = {
|
export const renamePendingCreateBeforeResponseTest: TestDefinition = {
|
||||||
|
|
@ -7,8 +8,6 @@ export const renamePendingCreateBeforeResponseTest: TestDefinition = {
|
||||||
steps: [
|
steps: [
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
|
||||||
|
|
||||||
{ type: "pause-server" },
|
{ type: "pause-server" },
|
||||||
|
|
||||||
|
|
@ -28,14 +27,16 @@ export const renamePendingCreateBeforeResponseTest: TestDefinition = {
|
||||||
|
|
||||||
{ type: "resume-server" },
|
{ type: "resume-server" },
|
||||||
|
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) =>
|
verify: (s: AssertableState): void => {
|
||||||
s.assertFileCount(1).assertContent("renamed.md", "original-content"),
|
s.assertFileCount(1).assertContent(
|
||||||
|
"renamed.md",
|
||||||
|
"original-content"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
|
export const renamePendingCreateOntoPendingDeletePathTest: TestDefinition = {
|
||||||
|
description:
|
||||||
|
"A pending create is renamed onto a path whose old server document " +
|
||||||
|
"has a queued delete. The delete must reach the server before the " +
|
||||||
|
"new create so the new generation is not merged into the soon-to-be " +
|
||||||
|
"deleted document.",
|
||||||
|
clients: 2,
|
||||||
|
steps: [
|
||||||
|
{ type: "enable-sync", client: 0 },
|
||||||
|
{ type: "enable-sync", client: 1 },
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 1,
|
||||||
|
path: "file-17.md",
|
||||||
|
content: "old\n"
|
||||||
|
},
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{ type: "pause-server" },
|
||||||
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 1,
|
||||||
|
path: "blocker.md",
|
||||||
|
content: "blocker\n"
|
||||||
|
},
|
||||||
|
{ type: "sleep", ms: 100 },
|
||||||
|
{
|
||||||
|
type: "create",
|
||||||
|
client: 1,
|
||||||
|
path: "file-23.md",
|
||||||
|
content: "new\n"
|
||||||
|
},
|
||||||
|
{ type: "delete", client: 1, path: "file-17.md" },
|
||||||
|
{
|
||||||
|
type: "rename",
|
||||||
|
client: 1,
|
||||||
|
oldPath: "file-23.md",
|
||||||
|
newPath: "file-17.md"
|
||||||
|
},
|
||||||
|
{ type: "resume-server" },
|
||||||
|
{ type: "barrier" },
|
||||||
|
|
||||||
|
{
|
||||||
|
type: "assert-consistent",
|
||||||
|
verify: (state: AssertableState): void => {
|
||||||
|
state
|
||||||
|
.assertFileCount(2)
|
||||||
|
.assertContent("blocker.md", "blocker\n")
|
||||||
|
.assertContent("file-17.md", "new\n")
|
||||||
|
.assertFileNotExists("file-23.md");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const renameRoundtripTest: TestDefinition = {
|
export const renameRoundtripTest: TestDefinition = {
|
||||||
|
|
@ -8,31 +9,32 @@ export const renameRoundtripTest: TestDefinition = {
|
||||||
{ type: "create", client: 0, path: "A.md", content: "original" },
|
{ type: "create", client: 0, path: "A.md", content: "original" },
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) => s.assertContent("A.md", "original"),
|
verify: (s: AssertableState): void => {
|
||||||
|
s.assertContent("A.md", "original");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) =>
|
verify: (s: AssertableState): void => {
|
||||||
s.assertFileNotExists("A.md").assertContent("B.md", "original"),
|
s.assertFileNotExists("A.md").assertContent("B.md", "original");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{ type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" },
|
{ type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" },
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) =>
|
verify: (s: AssertableState): void => {
|
||||||
s.assertFileNotExists("B.md").assertContent("A.md", "original"),
|
s.assertFileNotExists("B.md").assertContent("A.md", "original");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const renameSwapTest: TestDefinition = {
|
export const renameSwapTest: TestDefinition = {
|
||||||
description:
|
description:
|
||||||
"Client 0 has A.md and B.md synced. Goes offline and swaps them using " +
|
"Client 0 has A.md and B.md synced. Goes offline and swaps them using " +
|
||||||
"a temp file: A.md -> temp.md, B.md -> A.md, temp.md -> B.md. " +
|
"a temp file: A.md -> temp.md, B.md -> A.md, temp.md -> B.md. " +
|
||||||
"When Client 0 reconnects, both contents should exist across two files " +
|
"When Client 0 reconnects, both contents should exist across two files.",
|
||||||
"but paths may be deconflicted since atomic swaps are not supported.",
|
|
||||||
clients: 2,
|
clients: 2,
|
||||||
steps: [
|
steps: [
|
||||||
{ type: "create", client: 0, path: "A.md", content: "content-a" },
|
{ type: "create", client: 0, path: "A.md", content: "content-a" },
|
||||||
|
|
@ -15,8 +15,12 @@ export const renameSwapTest: TestDefinition = {
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) =>
|
verify: (s: AssertableState): void => {
|
||||||
s.assertContent("A.md", "content-a").assertContent("B.md", "content-b"),
|
s.assertContent("A.md", "content-a").assertContent(
|
||||||
|
"B.md",
|
||||||
|
"content-b"
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{ type: "disable-sync", client: 0 },
|
{ type: "disable-sync", client: 0 },
|
||||||
|
|
@ -29,12 +33,12 @@ export const renameSwapTest: TestDefinition = {
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s) =>
|
verify: (s: AssertableState): void => {
|
||||||
s
|
s.assertFileNotExists("temp.md")
|
||||||
.assertFileNotExists("temp.md")
|
|
||||||
.assertFileCount(2)
|
.assertFileCount(2)
|
||||||
.assertContent("A.md", "content-b")
|
.assertAnyFileContains("content-b")
|
||||||
.assertContent("B.md", "content-a"),
|
.assertAnyFileContains("content-a");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import type { TestDefinition } from "../test-definition";
|
|
||||||
|
|
||||||
export const renameToExistingPathTest: TestDefinition = {
|
|
||||||
description:
|
|
||||||
"Client 0 has A.md and B.md. Client 0 renames A.md to B.md (overwriting B.md). " +
|
|
||||||
"Both clients should converge: A.md gone, B.md has A.md's content.",
|
|
||||||
clients: 2,
|
|
||||||
steps: [
|
|
||||||
{ type: "create", client: 0, path: "A.md", content: "alpha" },
|
|
||||||
{ type: "create", client: 0, path: "B.md", content: "beta" },
|
|
||||||
{ type: "enable-sync", client: 0 },
|
|
||||||
{ type: "enable-sync", client: 1 },
|
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
|
||||||
|
|
||||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
|
||||||
{ type: "sync" },
|
|
||||||
{ type: "barrier" },
|
|
||||||
|
|
||||||
{
|
|
||||||
type: "assert-consistent",
|
|
||||||
verify: (s) =>
|
|
||||||
s.assertFileNotExists("A.md").assertContent("B.md", "alpha"),
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
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