Compare commits

..

51 commits

Author SHA1 Message Date
f2337dbbd0 actually works
Some checks failed
Check / build (pull_request) Failing after 7s
E2E tests / build (pull_request) Failing after 6s
Publish CLI / publish-docker (pull_request) Failing after 4m59s
Publish server Docker image / publish-docker (pull_request) Failing after 35m13s
2026-05-08 16:40:32 +01:00
fb71622e40 delete test 2026-05-07 20:11:39 +01:00
9151e0b2d6 claude claims it woorks 2026-05-07 07:56:46 +01:00
8e87537e49 claude 2026-05-05 21:50:27 +01:00
8aeb0d6027 codex 2026-05-05 21:50:24 +01:00
35877b69da claude 2026-05-04 13:07:18 +01:00
39c5591d36 more tests 2026-05-03 09:35:56 +01:00
b5f448706e . 2026-05-02 07:51:42 +01:00
7198639db4 loop 2026-04-29 19:51:49 +01:00
0d9aebf900 eeeeh 2026-04-28 22:20:57 +01:00
5776a37dc9 eh 2026-04-28 22:20:31 +01:00
1163da826e lgtm 2026-04-27 22:50:01 +01:00
cc44b66fcd More tests 2026-04-27 22:46:14 +01:00
5707add47c Return new & smar rename 2026-04-27 22:46:02 +01:00
3cfe095fa7 Fix deadlock 2026-04-26 20:56:39 +01:00
debe7cfc37 Good catches 2026-04-26 19:35:46 +01:00
0ab6984cdf Clear up index 2026-04-26 18:25:05 +01:00
439de6a264 Remove clutter 2026-04-26 18:19:01 +01:00
81c7e0c984 Fix test 2026-04-26 15:35:58 +01:00
039affff09 More fixes 2026-04-26 13:59:44 +01:00
3d285b0b6e No rate limiting saves 2026-04-26 13:13:55 +01:00
2a6d824cc9 Fix tests 2026-04-26 13:08:10 +01:00
fc0ff0df1c Remove useless syncs 2026-04-26 12:46:46 +01:00
8b7be48522 Linting 2026-04-26 12:46:12 +01:00
8eae770621 Revie ai fixes 2026-04-26 12:29:02 +01:00
fe2b4751bd More test improvements 2026-04-25 23:26:41 +01:00
56070912e8 Fix tests 2026-04-25 23:12:42 +01:00
14f25b4f2c fix tests 2026-04-25 22:33:47 +01:00
d23750f15b . 2026-04-25 21:59:32 +01:00
a5b3cc5f3a Fix compile 2026-04-25 20:47:10 +01:00
7a8c497462 more fixes 2026-04-25 20:42:34 +01:00
8ce33541a3 more ai changes 2026-04-25 20:29:38 +01:00
bff3f5a5e9 ai fixes 2026-04-25 19:13:26 +01:00
7f62273e72 Format & lint 2026-04-25 17:55:46 +01:00
fefac224b0 Fix tests 2026-04-25 16:00:07 +01:00
081e35be5c fix conflict path handling 2026-04-25 15:39:56 +01:00
321b503379 add min covered 2026-04-25 14:24:39 +01:00
addaa1699f missing ensure and covered 2026-04-25 13:53:16 +01:00
b52c09fecc Small changes 2026-04-25 13:40:34 +01:00
7293c58a71 good 2026-04-25 12:43:47 +01:00
aecbcd1d2c return paths 2026-04-25 08:40:40 +01:00
c9cf3239db .. 2026-04-24 21:59:00 +01:00
17a1f4d060 no remote path chacnge 2026-04-24 21:33:00 +01:00
19d5dc1999 . 2026-04-24 20:56:03 +01:00
a7b588da97 fmt 2026-04-23 21:14:29 +01:00
d715d94b6d . 2026-04-23 20:35:42 +01:00
6a8c7635f1 looks ok 2026-04-21 22:35:30 +01:00
5ee9db0007 store creation id and implement moves 2026-04-21 20:30:04 +01:00
dca59a18dc Add path change to server 2026-04-21 20:09:36 +01:00
9183f30b5d fmt 2026-04-21 20:01:28 +01:00
5ec523234b . 2026-04-21 19:42:45 +01:00
254 changed files with 9514 additions and 3885 deletions

155
CLAUDE.md Normal file
View 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`.

View file

@ -17,30 +17,35 @@ All tests run in parallel up to a concurrency limit.
Clients always start with syncing disabled.
**File operations** (per-client, fire-and-forget — sync is enqueued but not awaited):
- `create`, `update`, `rename`, `delete`
**Sync control:**
- `sync` — wait for a specific client or all clients to finish pending operations
- `barrier` — retry until all clients converge to identical file state (60s timeout)
- `enable-sync` / `disable-sync` — simulate going online/offline
**WebSocket control** (per-client):
- `pause-websocket` / `resume-websocket` — buffer/release WebSocket messages for a specific client
**Server control:**
- `pause-server` / `resume-server` — SIGSTOP/SIGCONT the server process
**Assertions:**
- `assert-consistent` — all clients have identical files; optionally takes a custom `verify(state: AssertableState)` callback
## Running
```sh
# Build server first
cd sync-server && cargo build --release
cd sync-server && cargo build --release && cd -
# 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
npm run test -w deterministic-tests -- --filter=rename
@ -57,15 +62,19 @@ npm run test -w deterministic-tests -- -j 4
import type { TestDefinition } from "../test-definition";
export const myScenarioTest: TestDefinition = {
description: "Client 0 creates A.md offline. After syncing, both clients should have the file.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "A.md", content: "hello" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("A.md", "hello") }
]
description:
"Client 0 creates A.md offline. After syncing, both clients should have the file.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "A.md", content: "hello" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) => s.assertFileCount(1).assertContent("A.md", "hello")
}
]
};
```
@ -88,7 +97,7 @@ s.ifFileExists("path", (s) => ...) // conditional assertion
import { myScenarioTest } from "./tests/my-scenario.test";
const TESTS = {
// ...
"my-scenario": myScenarioTest
// ...
"my-scenario": myScenarioTest
};
```

View file

@ -38,137 +38,6 @@ interface NamedTestResult {
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(
name: string,
test: TestDefinition,
@ -229,3 +98,132 @@ async function runDedicatedServerTest(
serverManager.untrack(server);
}
}
async function main(): Promise<void> {
const cwd = process.cwd();
let projectRoot = cwd;
if (cwd.endsWith("frontend/deterministic-tests")) {
projectRoot = path.resolve(cwd, "../..");
} else if (cwd.endsWith("frontend")) {
projectRoot = path.resolve(cwd, "..");
}
const serverPath = path.join(projectRoot, SERVER_BINARY_PATH);
if (!fs.existsSync(serverPath)) {
logger.error(`Server binary not found at: ${serverPath}`);
process.exit(1);
}
const configPath = path.join(projectRoot, CONFIG_PATH);
if (!fs.existsSync(configPath)) {
logger.error(`Config file not found at: ${configPath}`);
process.exit(1);
}
const filterArg = process.argv.find((a) => a.startsWith("--filter="));
const filter = filterArg?.slice("--filter=".length);
const testsToRun: [string, TestDefinition][] = [];
for (const [key, test] of Object.entries(TESTS)) {
if (test) {
if (
filter !== undefined &&
filter.length > 0 &&
!key.includes(filter)
) {
continue;
}
testsToRun.push([key, test]);
}
}
if (testsToRun.length === 0) {
logger.error(
filter !== undefined && filter.length > 0
? `No tests matched filter "${filter}"`
: "No tests found"
);
process.exit(1);
}
const concurrency = parseConcurrency();
const regularTests = testsToRun.filter(([, t]) => !testUsesPauseServer(t));
const pauseTests = testsToRun.filter(([, t]) => testUsesPauseServer(t));
logger.info(`Server: ${serverPath}`);
logger.info(`Config: ${configPath}`);
logger.info(
`Tests: ${testsToRun.length} total (${regularTests.length} regular, ${pauseTests.length} server-pause)`
);
logger.info(`Concurrency: ${concurrency}`);
const allResults: NamedTestResult[] = [];
if (regularTests.length > 0) {
logger.info(
`\n--- Running ${regularTests.length} regular tests (shared server, concurrency ${concurrency}) ---`
);
const sharedServer = new ServerControl(serverPath, configPath, logger);
serverManager.track(sharedServer);
try {
await sharedServer.start();
const results = await runWithConcurrency(
regularTests,
concurrency,
async ([name, test]) =>
runSharedServerTest(name, test, sharedServer)
);
allResults.push(...results);
} finally {
try {
await sharedServer.stop();
} catch (error) {
logger.warn(
`Error stopping shared server: ${error instanceof Error ? error.message : String(error)}`
);
}
serverManager.untrack(sharedServer);
}
}
if (pauseTests.length > 0) {
logger.info(
`\n--- Running ${pauseTests.length} server-pause tests (dedicated servers, concurrency ${concurrency}) ---`
);
const results = await runWithConcurrency(
pauseTests,
concurrency,
async ([name, test]) =>
runDedicatedServerTest(name, test, serverPath, configPath)
);
allResults.push(...results);
}
const passed = allResults.filter((r) => r.result.success);
const failed = allResults.filter((r) => !r.result.success);
logger.info(
`\n--- Results: ${passed.length}/${allResults.length} passed ---`
);
if (failed.length > 0) {
for (const { name, result } of failed) {
logger.error(` FAILED: ${name}: ${result.error}`);
}
process.exit(1);
} else {
logger.info("All tests passed!");
process.exit(0);
}
}
main().catch((err: unknown) => {
logger.error(`Unexpected error: ${err}`);
process.exit(1);
});

View file

@ -1,13 +1,28 @@
import type { StoredDatabase, SyncSettings, RelativePath, TextWithCursors } from "sync-client";
import { SyncClient, debugging, LogLevel } from "sync-client";
import type {
HistoryEntry,
StoredDatabase,
SyncSettings,
RelativePath,
TextWithCursors
} from "sync-client";
import {
SyncClient,
SyncResetError,
debugging,
LogLevel,
utils
} from "sync-client";
import { assert } from "./utils/assert";
import { sleep } from "./utils/sleep";
import { withTimeout } from "./utils/with-timeout";
import { IS_SYNC_ENABLED_BY_DEFAULT, WAIT_TIMEOUT_MS, WEBSOCKET_CONNECT_TIMEOUT_MS, WEBSOCKET_POLL_INTERVAL_MS } from "./consts";
import {
IS_SYNC_ENABLED_BY_DEFAULT,
WAIT_TIMEOUT_MS,
WEBSOCKET_CONNECT_TIMEOUT_MS,
WEBSOCKET_POLL_INTERVAL_MS
} from "./consts";
import { ManagedWebSocketFactory } from "./managed-websocket";
export class DeterministicAgent extends debugging.InMemoryFileSystem {
public readonly clientId: number;
private readonly logger: (msg: string) => void;
@ -20,6 +35,18 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
private readonly syncErrors: Error[] = [];
private readonly pendingSyncOperations = new Set<Promise<void>>();
private readonly wsFactory = new ManagedWebSocketFactory();
private nextWriteRename:
| {
oldPath: RelativePath;
newPath: RelativePath;
}
| undefined;
private nextCreateResponseDrop:
| {
dropped: Promise<void>;
resolveDropped: () => void;
}
| undefined;
public constructor(
clientId: number,
@ -33,7 +60,7 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
}
public async init(
fetchImplementation: typeof globalThis.fetch,
fetchImplementation: typeof globalThis.fetch
): Promise<void> {
this.client = await SyncClient.create({
fs: this,
@ -41,7 +68,7 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
load: async () => this.data,
save: async (data) => void (this.data = data)
},
fetch: fetchImplementation,
fetch: this.wrapFetch(fetchImplementation),
webSocket: this.wsFactory.constructorFn
});
@ -86,6 +113,65 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
this.wsFactory.resume();
}
public dropNextCreateResponse(): void {
assert(
this.nextCreateResponseDrop === undefined,
`Client ${this.clientId} already has a create response drop armed`
);
let resolveDropped!: () => void;
const dropped = new Promise<void>((resolve) => {
resolveDropped = resolve;
});
this.nextCreateResponseDrop = {
dropped,
resolveDropped
};
this.log("Armed next create response drop");
}
public async waitForDroppedCreateResponse(): Promise<void> {
assert(
this.nextCreateResponseDrop !== undefined,
`Client ${this.clientId} has no create response drop armed`
);
await withTimeout(
this.nextCreateResponseDrop.dropped,
WAIT_TIMEOUT_MS,
`Client ${this.clientId} timed out waiting for create response drop`
);
this.log("Create response was dropped after server commit");
}
public async waitForHistoryEntry(
matches: (entry: HistoryEntry) => boolean,
onMatch?: (entry: HistoryEntry) => void
): Promise<void> {
const existing = this.client.getHistoryEntries().find(matches);
if (existing !== undefined) {
onMatch?.(existing);
return;
}
await withTimeout(
new Promise<void>((resolve) => {
const unsubscribe = this.client.onSyncHistoryUpdated.add(() => {
const entry = this.client
.getHistoryEntries()
.find(matches);
if (entry === undefined) {
return;
}
unsubscribe();
onMatch?.(entry);
resolve();
});
}),
WAIT_TIMEOUT_MS,
`Client ${this.clientId} timed out waiting for history entry`
);
}
public async waitForSync(): Promise<void> {
this.log("Waiting for sync to complete...");
// Drain agent-level sync operations first. These are the fire-and-forget
@ -107,6 +193,15 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
this.log("Sync complete");
}
public async reset(): Promise<void> {
this.log("Resetting client (clears tracked state, keeps disk files)");
await this.drainPendingSyncOperations();
await this.client.reset();
if (this.isSyncEnabled) {
await this.waitForWebSocket();
}
}
public async disableSync(): Promise<void> {
this.log("Disabling sync");
// Drain pending enqueued operations before disabling so the SyncClient
@ -138,17 +233,27 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
await this.waitForWebSocket();
}
public async getFileContent(path: string): Promise<string> {
const bytes = await this.read(path);
return new TextDecoder().decode(bytes);
}
public renameNextWrite(oldPath: RelativePath, newPath: RelativePath): void {
assert(
this.nextWriteRename === undefined,
`Client ${this.clientId} already has a next-write rename armed`
);
this.nextWriteRename = { oldPath, newPath };
this.log(`Armed next write rename: ${oldPath} -> ${newPath}`);
}
public async cleanup(): Promise<void> {
this.log("Cleaning up...");
// Guard against uninitialized client (init() failed partway)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!this.client) {
// Guard against uninitialized client (init() failed partway).
// The class field uses `!:` so TS thinks this is always defined,
// but at runtime it can be undefined when init() throws partway.
const maybeClient = this.client as SyncClient | undefined;
if (maybeClient === undefined) {
this.log("Client not initialized, nothing to clean up");
return;
}
@ -183,12 +288,40 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
const isNew = !this.files.has(path);
await super.write(path, content);
if (isNew) {
this.enqueueSync(async () => { this.client.syncLocallyCreatedFile(path); }
);
} else {
this.enqueueSync(async () => { this.client.syncLocallyUpdatedFile({ relativePath: path }); }
if (this.isSyncEnabled && isNew) {
this.enqueueSync(async () => {
this.client.syncLocallyCreatedFile(path);
});
}
const nextWriteRename = this.nextWriteRename;
if (
nextWriteRename !== undefined &&
nextWriteRename.oldPath === path
) {
this.nextWriteRename = undefined;
await super.rename(
nextWriteRename.oldPath,
nextWriteRename.newPath
);
if (this.isSyncEnabled) {
this.enqueueSync(async () => {
this.client.syncLocallyUpdatedFile({
oldPath: nextWriteRename.oldPath,
relativePath: nextWriteRename.newPath
});
});
}
}
if (!this.isSyncEnabled) {
return;
}
if (!isNew) {
this.enqueueSync(async () => {
this.client.syncLocallyUpdatedFile({ relativePath: path });
});
}
}
@ -197,18 +330,20 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
updater: (current: TextWithCursors) => TextWithCursors
): Promise<string> {
const result = await super.atomicUpdateText(path, updater);
this.enqueueSync(async () => { this.client.syncLocallyUpdatedFile({ relativePath: path }); }
);
if (this.isSyncEnabled) {
this.enqueueSync(async () => {
this.client.syncLocallyUpdatedFile({ relativePath: path });
});
}
return result;
}
public override async delete(path: RelativePath): Promise<void> {
await super.delete(path);
if (this.isSyncEnabled) {
this.enqueueSync(async () => { this.client.syncLocallyDeletedFile(path); }
);
this.enqueueSync(async () => {
this.client.syncLocallyDeletedFile(path);
});
}
}
@ -217,13 +352,14 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
newPath: RelativePath
): Promise<void> {
await super.rename(oldPath, newPath);
this.enqueueSync(async () => {
this.client.syncLocallyUpdatedFile({
oldPath,
relativePath: newPath
if (this.isSyncEnabled) {
this.enqueueSync(async () => {
this.client.syncLocallyUpdatedFile({
oldPath,
relativePath: newPath
});
});
}
);
}
private async waitForWebSocket(): Promise<void> {
@ -243,7 +379,7 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
*/
private async drainPendingSyncOperations(): Promise<void> {
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 {
this.logger(`[Client ${this.clientId}] ${message}`);
}
private wrapFetch(
fetchImplementation: typeof globalThis.fetch
): typeof globalThis.fetch {
return async (input, init) => {
const response = await fetchImplementation(input, init);
const drop = this.nextCreateResponseDrop;
if (
drop !== undefined &&
DeterministicAgent.isCreateDocumentRequest(input, init)
) {
this.nextCreateResponseDrop = undefined;
drop.resolveDropped();
throw new SyncResetError();
}
return response;
};
}
private static isCreateDocumentRequest(
input: RequestInfo | URL,
init: RequestInit | undefined
): boolean {
const method =
init?.method ??
(typeof Request !== "undefined" && input instanceof Request
? input.method
: "GET");
if (method.toUpperCase() !== "POST") {
return false;
}
const url =
input instanceof URL
? input
: new URL(typeof input === "string" ? input : input.url);
return /\/documents\/?$/.test(url.pathname);
}
}

View file

@ -2,16 +2,129 @@
* A WebSocket wrapper that can pause and resume message delivery.
* When paused, incoming messages are buffered. When resumed, buffered
* messages are delivered in order via the onmessage handler.
*
* Member layout follows typescript-eslint default member-ordering: all
* accessor properties are declared with `declare` and wired through the
* constructor using Object.defineProperty so we don't need conflicting
* get/set accessor pairs.
*/
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 paused = false;
private readonly bufferedMessages: MessageEvent[] = [];
private paused = false;
private externalOnMessage: ((event: MessageEvent) => unknown) | null = null;
public constructor(url: string | URL, protocols?: string | string[]) {
this.ws = new WebSocket(url, protocols);
const { ws } = this;
Object.defineProperties(this, {
readyState: {
get: (): number => ws.readyState,
enumerable: true,
configurable: true
},
url: {
get: (): string => ws.url,
enumerable: true,
configurable: true
},
protocol: {
get: (): string => ws.protocol,
enumerable: true,
configurable: true
},
extensions: {
get: (): string => ws.extensions,
enumerable: true,
configurable: true
},
bufferedAmount: {
get: (): number => ws.bufferedAmount,
enumerable: true,
configurable: true
},
binaryType: {
get: (): BinaryType => ws.binaryType,
set: (v: BinaryType): void => {
ws.binaryType = v;
},
enumerable: true,
configurable: true
},
onopen: {
get: (): ((this: WebSocket, ev: Event) => unknown) | null =>
ws.onopen,
set: (
h: ((this: WebSocket, ev: Event) => unknown) | null
): void => {
ws.onopen = h;
},
enumerable: true,
configurable: true
},
onclose: {
get: ():
| ((this: WebSocket, ev: CloseEvent) => unknown)
| null => ws.onclose,
set: (
h: ((this: WebSocket, ev: CloseEvent) => unknown) | null
): void => {
ws.onclose = h;
},
enumerable: true,
configurable: true
},
onerror: {
get: (): ((this: WebSocket, ev: Event) => unknown) | null =>
ws.onerror,
set: (
h: ((this: WebSocket, ev: Event) => unknown) | null
): void => {
ws.onerror = h;
},
enumerable: true,
configurable: true
},
onmessage: {
get: ():
| ((this: WebSocket, ev: MessageEvent) => unknown)
| null => this.externalOnMessage,
set: (
h: ((this: WebSocket, ev: MessageEvent) => unknown) | null
): void => {
this.externalOnMessage = h;
},
enumerable: true,
configurable: true
}
});
this.ws.onmessage = (event: MessageEvent): void => {
if (this.paused) {
this.bufferedMessages.push(event);
@ -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 {
this.ws.send(data);
}
@ -118,16 +169,6 @@ export class ManagedWebSocket implements WebSocket {
public dispatchEvent(event: Event): boolean {
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 {
private readonly instances: ManagedWebSocket[] = [];
// Sticky pause state: applied to current instances on `pause()` AND
// to any new instance created later (e.g. WS reconnect after a
// `disable-sync` / `reset` cycle). Without this, a test pausing the
// WS before the agent reconnects would silently see the new socket
// start un-paused and miss the messages it meant to buffer.
private currentlyPaused = false;
public get constructorFn(): typeof globalThis.WebSocket {
const factory = this;
const ctor = function ManagedWS(
url: string | URL,
protocols?: string | string[]
): ManagedWebSocket {
const ws = new ManagedWebSocket(url, protocols);
factory.instances.push(ws);
return ws;
} as unknown as typeof globalThis.WebSocket;
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;
const trackInstance = (instance: ManagedWebSocket): void => {
this.instances.push(instance);
if (this.currentlyPaused) {
instance.pause();
}
};
class TrackedManagedWebSocket extends ManagedWebSocket {
public constructor(
url: string | URL,
protocols?: string | string[]
) {
super(url, protocols);
trackInstance(this);
}
}
return TrackedManagedWebSocket;
}
public pause(): void {
this.currentlyPaused = true;
for (const ws of this.instances) {
ws.pause();
}
}
public resume(): void {
this.currentlyPaused = false;
for (const ws of this.instances) {
ws.resume();
}

View file

@ -8,7 +8,9 @@ export function parseConcurrency(): number {
i + 1 < args.length
) {
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;

View file

@ -42,9 +42,7 @@ export class ServerControl {
this._port = reservation.port;
// Prefer tmpfs (/host/tmp) over disk-backed /tmp for faster SQLite I/O
const tmpBase = fs.existsSync("/host/tmp") ? "/host/tmp" : os.tmpdir();
this.tempDir = fs.mkdtempSync(
path.join(tmpBase, "vault-link-test-")
);
this.tempDir = fs.mkdtempSync(path.join(tmpBase, "vault-link-test-"));
const tempConfigPath = path.join(this.tempDir, "config.yml");
const dbDir = path.join(this.tempDir, "databases");
@ -225,7 +223,7 @@ export class ServerControl {
}
private cleanupTempDir(): void {
if (this.tempDir) {
if (this.tempDir !== undefined) {
try {
fs.rmSync(this.tempDir, { recursive: true, force: true });
} catch {
@ -234,5 +232,4 @@ export class ServerControl {
this.tempDir = undefined;
}
}
}

View file

@ -19,7 +19,9 @@ export class ServerManager {
}
public async stopAll(): Promise<void> {
if (this.isShuttingDown) return;
if (this.isShuttingDown) {
return;
}
this.isShuttingDown = true;
const servers = Array.from(this.activeServers);
@ -39,14 +41,18 @@ export class ServerManager {
process.on("SIGINT", () => {
this.logger.info("Received SIGINT, shutting down...");
void this.stopAll()
.catch(() => {})
.catch(() => {
/* no-op */
})
.then(() => process.exit(130));
});
process.on("SIGTERM", () => {
this.logger.info("Received SIGTERM, shutting down...");
void this.stopAll()
.catch(() => {})
.catch(() => {
/* no-op */
})
.then(() => process.exit(143));
});
}

View file

@ -9,16 +9,32 @@ export type TestStep =
| { type: "create"; client: number; path: string; content: string }
| { type: "update"; client: number; path: string; content: string }
| { type: "rename"; client: number; oldPath: string; newPath: string }
| {
type: "rename-next-write";
client: number;
oldPath: string;
newPath: string;
}
| { type: "delete"; client: number; path: string }
| { type: "sync"; client?: number }
| { type: "disable-sync"; client: number }
| { type: "enable-sync"; client: number }
| { type: "pause-server" }
| { type: "resume-server" }
| {
type: "resume-server-until-history-then-pause";
client: number;
syncType: "CREATE" | "UPDATE" | "DELETE";
path: string;
}
| { type: "barrier" }
| { type: "assert-consistent"; verify?: (state: AssertableState) => void }
| { type: "pause-websocket"; client: number }
| { type: "resume-websocket"; client: number };
| { type: "resume-websocket"; client: number }
| { type: "drop-next-create-response"; client: number }
| { type: "wait-for-dropped-create-response"; client: number }
| { type: "sleep"; ms: number }
| { type: "reset"; client: number };
export interface TestDefinition {
description?: string;

View file

@ -6,7 +6,6 @@ import { deleteRenameConflictTest } from "./tests/delete-rename-conflict.test";
import { multiFileOperationsTest } from "./tests/multi-file-operations.test";
import { deleteRecreateSamePathTest } from "./tests/delete-recreate-same-path.test";
import { offlineRenameAndEditTest } from "./tests/offline-rename-and-edit.test";
import { renameToExistingPathTest } from "./tests/rename-to-existing-path.test";
import { simultaneousCreateDeleteSamePathTest } from "./tests/simultaneous-create-delete-same-path.test";
import { idempotencyAfterServerPauseTest } from "./tests/idempotency-after-server-pause.test";
import { sequentialCreateDuplicateContentTest } from "./tests/sequential-create-duplicate-content.test";
@ -26,7 +25,6 @@ import { offlineRenameRemoteCreateOldPathTest } from "./tests/offline-rename-rem
import { offlineEditRemoteRenameTest } from "./tests/offline-edit-remote-rename.test";
import { renameChainThenDeleteTest } from "./tests/rename-chain-then-delete.test";
import { offlineDeleteRemoteRenameTest } from "./tests/offline-delete-remote-rename.test";
import { renameToRecentlyDeletedPathTest } from "./tests/rename-to-recently-deleted-path.test";
import { overlappingEditsSameSectionTest } from "./tests/overlapping-edits-same-section.test";
import { rapidUpdatesAfterMergeTest } from "./tests/rapid-updates-after-merge.test";
import { deleteRecreateConcurrentUpdateTest } from "./tests/delete-recreate-concurrent-update.test";
@ -53,7 +51,6 @@ import { updateDoesNotSurvivesRemoteDeleteTest } from "./tests/update-survives-r
import { movePreservesRemoteUpdateTest } from "./tests/move-preserves-remote-update.test";
import { recentlyDeletedClearedOnReconnectTest } from "./tests/recently-deleted-cleared-on-reconnect.test";
import { migrateKeyPreservesExistingTest } from "./tests/migrate-key-preserves-existing.test";
import { failedVfsMoveFallsBackTest } from "./tests/failed-vfs-move-falls-back.test";
import { watermarkAdvancesOnSkipTest } from "./tests/watermark-advances-on-skip.test";
import { watermarkGapRemoteUpdateNotRecordedTest } from "./tests/watermark-gap-remote-update-not-recorded.test";
import { queueResetLosesCoalescedLocalEditTest } from "./tests/queue-reset-loses-coalesced-local-edit.test";
@ -92,6 +89,23 @@ import { serverPauseDeleteRecreateTest } from "./tests/server-pause-delete-recre
import { onlineBothCreateSamePathDeconflictTest } from "./tests/online-both-create-same-path-deconflict.test";
import { onlineCreateUpdateWhileOtherCreatesSamePathTest } from "./tests/online-create-update-while-other-creates-same-path.test";
import { displacedFileNotMarkedDeletedTest } from "./tests/displaced-file-not-marked-deleted.test";
import { remoteUpdateResurrectsDeletedDocTest } from "./tests/remote-update-resurrects-deleted-doc.test";
import { localUpdateSurvivesRemoteRenameTest } from "./tests/local-update-survives-remote-rename.test";
import { mergingUpdateResponseSurvivesUserRenameTest } from "./tests/merging-update-response-survives-user-rename.test";
import { catchupCreateAndUpdateNotSkippedTest } from "./tests/catchup-create-and-update-not-skipped.test";
import { localRenameSurvivesRemoteRenameTest } from "./tests/local-rename-survives-remote-rename.test";
import { renameChainDuringPendingCreateTest } from "./tests/rename-chain-during-pending-create.test";
import { remoteRenameCollidesWithPendingLocalCreateTest } from "./tests/remote-rename-collides-with-pending-local-create.test";
import { remoteUpdateSurvivesUserRenameTest } from "./tests/remote-update-survives-user-rename.test";
import { sameDocIdCollapseOnLocalCreateAfterRemoteCreateTest } from "./tests/same-doc-id-collapse-on-local-create-after-remote-create.test";
import { sameDocIdCollapseAfterRemoteQuickWriteAndPendingRenameTest } from "./tests/same-doc-id-collapse-after-remote-quick-write-and-pending-rename.test";
import { renameOverwritesPendingCreateThenDeleteTest } from "./tests/rename-overwrites-pending-create-then-delete.test";
import { deleteRecreatedPendingCreateWithStaleDeletingRecordTest } from "./tests/delete-recreated-pending-create-with-stale-deleting-record.test";
import { queuedCreateDeleteDoesNotHijackReusedPathTest } from "./tests/queued-create-delete-does-not-hijack-reused-path.test";
import { renamedPendingCreateReusedPathThenDeleteTest } from "./tests/renamed-pending-create-reused-path-then-delete.test";
import { renamePendingCreateOntoPendingDeletePathTest } from "./tests/rename-pending-create-onto-pending-delete-path.test";
import { remoteQuickWriteRenameBeforeRecordTest } from "./tests/remote-quick-write-rename-before-record.test";
import { selfMergePendingRenameAliasesSecondCreateTest } from "./tests/self-merge-pending-rename-aliases-second-create.test";
export const TESTS: Partial<Record<string, TestDefinition>> = {
"rename-create-conflict": renameCreateConflictTest,
@ -101,11 +115,12 @@ export const TESTS: Partial<Record<string, TestDefinition>> = {
"multi-file-operations": multiFileOperationsTest,
"delete-recreate-same-path": deleteRecreateSamePathTest,
"offline-rename-and-edit": offlineRenameAndEditTest,
"rename-to-existing-path": renameToExistingPathTest,
"simultaneous-create-delete-same-path": simultaneousCreateDeleteSamePathTest,
"simultaneous-create-delete-same-path":
simultaneousCreateDeleteSamePathTest,
"idempotency-after-server-pause": idempotencyAfterServerPauseTest,
"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-cross-create-rename-same-target": mcCrossCreateRenameSameTargetTest,
"mc-delete-then-offline-rename": mcDeleteThenOfflineRenameTest,
@ -117,11 +132,11 @@ export const TESTS: Partial<Record<string, TestDefinition>> = {
"rename-swap": renameSwapTest,
"rename-circular": renameCircularTest,
"rename-roundtrip": renameRoundtripTest,
"offline-rename-remote-create-old-path": offlineRenameRemoteCreateOldPathTest,
"offline-rename-remote-create-old-path":
offlineRenameRemoteCreateOldPathTest,
"offline-edit-remote-rename": offlineEditRemoteRenameTest,
"rename-chain-then-delete": renameChainThenDeleteTest,
"offline-delete-remote-rename": offlineDeleteRemoteRenameTest,
"rename-to-recently-deleted-path": renameToRecentlyDeletedPathTest,
"overlapping-edits-same-section": overlappingEditsSameSectionTest,
"rapid-updates-after-merge": rapidUpdatesAfterMergeTest,
"delete-recreate-concurrent-update": deleteRecreateConcurrentUpdateTest,
@ -140,34 +155,44 @@ export const TESTS: Partial<Record<string, TestDefinition>> = {
"delete-recreate-different-content": deleteRecreateDifferentContentTest,
"update-during-create-processing": updateDuringCreateProcessingTest,
"offline-move-then-remote-delete": offlineMoveThenRemoteDeleteTest,
"reset-clears-recently-deleted-resurrection": resetClearsRecentlyDeletedResurrectionTest,
"reset-clears-recently-deleted-resurrection":
resetClearsRecentlyDeletedResurrectionTest,
"move-then-delete-stale-path": moveThenDeleteStalePathTest,
"offline-delete-vs-remote-update": offlineDeleteVsRemoteUpdateTest,
"interrupted-delete-retry": interruptedDeleteRetryTest,
"update-survives-remote-delete": updateDoesNotSurvivesRemoteDeleteTest,
"move-preserves-remote-update": movePreservesRemoteUpdateTest,
"recently-deleted-cleared-on-reconnect": recentlyDeletedClearedOnReconnectTest,
"recently-deleted-cleared-on-reconnect":
recentlyDeletedClearedOnReconnectTest,
"migrate-key-preserves-existing": migrateKeyPreservesExistingTest,
"failed-vfs-move-falls-back": failedVfsMoveFallsBackTest,
"watermark-advances-on-skip": watermarkAdvancesOnSkipTest,
"watermark-gap-remote-update-not-recorded": watermarkGapRemoteUpdateNotRecordedTest,
"queue-reset-loses-coalesced-local-edit": queueResetLosesCoalescedLocalEditTest,
"watermark-gap-remote-update-not-recorded":
watermarkGapRemoteUpdateNotRecordedTest,
"queue-reset-loses-coalesced-local-edit":
queueResetLosesCoalescedLocalEditTest,
"rename-to-pending-path-fallback": renameToPendingPathFallbackTest,
"move-remote-update-reverts-rename": moveRemoteUpdateRevertsRenameTest,
"local-edit-lost-during-create-merge": localEditLostDuringCreateMergeTest,
"rename-pending-create-before-response": renamePendingCreateBeforeResponseTest,
"rename-pending-create-before-response":
renamePendingCreateBeforeResponseTest,
"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,
"binary-to-text-transition": binaryToTextTransitionTest,
"text-pending-create-not-displaced": textPendingCreateNotDisplacedTest,
"binary-pending-create-not-displaced": binaryPendingCreateNotDisplacedTest,
"coalesce-update-remote-update-data-loss": coalesceUpdateRemoteUpdateDataLossTest,
"coalesced-remote-update-watermark-loss": coalescedRemoteUpdateWatermarkLossTest,
"concurrent-delete-during-remote-update": concurrentDeleteDuringRemoteUpdateTest,
"coalesce-update-remote-update-data-loss":
coalesceUpdateRemoteUpdateDataLossTest,
"coalesced-remote-update-watermark-loss":
coalescedRemoteUpdateWatermarkLossTest,
"concurrent-delete-during-remote-update":
concurrentDeleteDuringRemoteUpdateTest,
"concurrent-edit-exact-same-position": concurrentEditExactSamePositionTest,
"concurrent-rename-and-create-at-target-rename-first": concurrentRenameAndCreateAtTargetRenameFirstTest,
"concurrent-rename-and-create-at-target-create-first": concurrentRenameAndCreateAtTargetCreateFirstTest,
"concurrent-rename-and-create-at-target-rename-first":
concurrentRenameAndCreateAtTargetRenameFirstTest,
"concurrent-rename-and-create-at-target-create-first":
concurrentRenameAndCreateAtTargetCreateFirstTest,
"concurrent-rename-same-target": concurrentRenameSameTargetTest,
"concurrent-update-diff-consistency": concurrentUpdateDiffConsistencyTest,
"user-parenthesized-file-not-deleted": userParenthesizedFileNotDeletedTest,
@ -176,15 +201,49 @@ export const TESTS: Partial<Record<string, TestDefinition>> = {
"move-identical-content-ambiguity": moveIdenticalContentAmbiguityTest,
"create-update-coalesce-server-pause": createUpdateCoalesceServerPauseTest,
"create-during-reconciliation": createDuringReconciliationTest,
"create-merge-preserves-renamed-update": createMergePreservesRenamedUpdateTest,
"create-merge-preserves-renamed-update":
createMergePreservesRenamedUpdateTest,
"create-rename-create-same-path": createRenameCreateSamePathTest,
"move-chain-three-files": moveChainThreeFilesTest,
"delete-by-other-client-then-recreate": deleteByOtherClientThenRecreateTest,
"online-delete-recreate-rapid-cycle": onlineDeleteRecreateRapidCycleTest,
"online-edit-vs-delete-convergence": onlineEditVsDeleteConvergenceTest,
"rapid-edit-delete-online-convergence": rapidEditDeleteOnlineConvergenceTest,
"rapid-edit-delete-online-convergence":
rapidEditDeleteOnlineConvergenceTest,
"server-pause-delete-recreate": serverPauseDeleteRecreateTest,
"online-both-create-same-path-deconflict": onlineBothCreateSamePathDeconflictTest,
"online-create-update-while-other-creates-same-path": onlineCreateUpdateWhileOtherCreatesSamePathTest,
"online-both-create-same-path-deconflict":
onlineBothCreateSamePathDeconflictTest,
"online-create-update-while-other-creates-same-path":
onlineCreateUpdateWhileOtherCreatesSamePathTest,
"displaced-file-not-marked-deleted": displacedFileNotMarkedDeletedTest,
"remote-update-resurrects-deleted-doc":
remoteUpdateResurrectsDeletedDocTest,
"local-update-survives-remote-rename": localUpdateSurvivesRemoteRenameTest,
"merging-update-response-survives-user-rename":
mergingUpdateResponseSurvivesUserRenameTest,
"catchup-create-and-update-not-skipped":
catchupCreateAndUpdateNotSkippedTest,
"local-rename-survives-remote-rename": localRenameSurvivesRemoteRenameTest,
"rename-chain-during-pending-create": renameChainDuringPendingCreateTest,
"remote-rename-collides-with-pending-local-create":
remoteRenameCollidesWithPendingLocalCreateTest,
"remote-update-survives-user-rename": remoteUpdateSurvivesUserRenameTest,
"same-doc-id-collapse-on-local-create-after-remote-create":
sameDocIdCollapseOnLocalCreateAfterRemoteCreateTest,
"renamed-pending-create-reused-path-then-delete":
renamedPendingCreateReusedPathThenDeleteTest,
"rename-pending-create-onto-pending-delete-path":
renamePendingCreateOntoPendingDeletePathTest,
"rename-overwrites-pending-create-then-delete":
renameOverwritesPendingCreateThenDeleteTest,
"same-doc-id-collapse-after-remote-quick-write-and-pending-rename":
sameDocIdCollapseAfterRemoteQuickWriteAndPendingRenameTest,
"delete-recreated-pending-create-with-stale-deleting-record":
deleteRecreatedPendingCreateWithStaleDeletingRecordTest,
"queued-create-delete-does-not-hijack-reused-path":
queuedCreateDeleteDoesNotHijackReusedPathTest,
"remote-quick-write-rename-before-record":
remoteQuickWriteRenameBeforeRecordTest,
"self-merge-pending-rename-aliases-second-create":
selfMergePendingRenameAliasesSecondCreateTest
};

View file

@ -1,8 +1,4 @@
import type {
TestDefinition,
TestResult,
TestStep
} from "./test-definition";
import type { TestDefinition, TestResult, TestStep } from "./test-definition";
import { DeterministicAgent } from "./deterministic-agent";
import type { ServerControl } from "./server-control";
import type { SyncSettings, Logger } from "sync-client";
@ -113,9 +109,7 @@ export class TestRunner {
// Push before init so cleanup() handles this agent if init fails
this.agents.push(agent);
await withTimeout(
agent.init(
fetch,
),
agent.init(fetch),
AGENT_INIT_TIMEOUT_MS,
`Client ${i} init timed out after ${AGENT_INIT_TIMEOUT_MS}ms`
);
@ -150,6 +144,13 @@ export class TestRunner {
);
break;
case "rename-next-write":
this.getAgent(step.client).renameNextWrite(
step.oldPath,
step.newPath
);
break;
case "delete":
await this.getAgent(step.client).delete(step.path);
break;
@ -183,6 +184,19 @@ export class TestRunner {
await this.serverControl.waitForReady();
break;
case "resume-server-until-history-then-pause": {
const agent = this.getAgent(step.client);
const historySeen = agent.waitForHistoryEntry(
(entry) =>
entry.details.type === step.syncType &&
entry.details.relativePath === step.path,
() => this.serverControl.pause()
);
this.serverControl.resume();
await historySeen;
break;
}
case "barrier":
await this.waitForConvergence();
break;
@ -199,6 +213,22 @@ export class TestRunner {
this.getAgent(step.client).resumeWebSocket();
break;
case "drop-next-create-response":
this.getAgent(step.client).dropNextCreateResponse();
break;
case "wait-for-dropped-create-response":
await this.getAgent(step.client).waitForDroppedCreateResponse();
break;
case "sleep":
await sleep(step.ms);
break;
case "reset":
await this.getAgent(step.client).reset();
break;
default: {
const unknownStep = step as { type: string };
throw new Error(`Unknown step type: ${unknownStep.type}`);
@ -276,7 +306,10 @@ export class TestRunner {
verify?: (state: AssertableState) => void
): Promise<void> {
this.logger.info("Asserting all clients are consistent...");
assert(this.agents.length >= 2, "Need at least 2 agents for consistency check");
assert(
this.agents.length >= 2,
"Need at least 2 agents for consistency check"
);
// Snapshot all agents' file states upfront to minimize the window
// where background sync could mutate state between reads.

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const textPendingCreateNotDisplacedTest: TestDefinition = {
@ -23,6 +24,13 @@ export const textPendingCreateNotDisplacedTest: TestDefinition = {
{ type: "enable-sync", client: 1 },
{ 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");
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const concurrentUpdateDiffConsistencyTest: TestDefinition = {
@ -35,6 +36,16 @@ export const concurrentUpdateDiffConsistencyTest: TestDefinition = {
{ type: "enable-sync", client: 1 },
{ 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"
);
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const userParenthesizedFileNotDeletedTest: TestDefinition = {
@ -34,7 +35,7 @@ export const userParenthesizedFileNotDeletedTest: TestDefinition = {
{
type: "assert-consistent",
verify: (state) => {
verify: (state: AssertableState): void => {
state
.assertFileCount(3)
.assertFileExists("Chapter.bin")

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const createDeleteNoopTest: TestDefinition = {
@ -16,6 +17,11 @@ export const createDeleteNoopTest: TestDefinition = {
{ type: "enable-sync", client: 0 },
{ type: "barrier" },
{ type: "assert-consistent", verify: (s) => s.assertFileNotExists("temp.md") }
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileNotExists("temp.md");
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const createMergeDeleteTest: TestDefinition = {
@ -16,12 +17,21 @@ export const createMergeDeleteTest: TestDefinition = {
{
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: "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");
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const moveIdenticalContentAmbiguityTest: TestDefinition = {
@ -31,7 +32,7 @@ export const moveIdenticalContentAmbiguityTest: TestDefinition = {
{
type: "assert-consistent",
verify: (state) => {
verify: (state: AssertableState): void => {
state
.assertFileCount(1)
.assertFileNotExists("A.md")

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const createUpdateCoalesceServerPauseTest: TestDefinition = {
@ -19,6 +20,13 @@ export const createUpdateCoalesceServerPauseTest: TestDefinition = {
{ 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");
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const createDuringReconciliationTest: TestDefinition = {
@ -37,7 +38,7 @@ export const createDuringReconciliationTest: TestDefinition = {
{
type: "assert-consistent",
verify: (state) => {
verify: (state: AssertableState): void => {
state
.assertFileCount(3)
.assertContent("A.md", "offline A")

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const createMergePreservesRenamedUpdateTest: TestDefinition = {
@ -14,6 +15,13 @@ export const createMergePreservesRenamedUpdateTest: TestDefinition = {
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state: AssertableState): void => {
state.assertContains("doc.md", "alpha", "beta");
}
},
{ type: "disable-sync", client: 1 },
{
@ -39,6 +47,13 @@ export const createMergePreservesRenamedUpdateTest: TestDefinition = {
{ type: "enable-sync", client: 1 },
{ 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");
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const createRenameCreateSamePathTest: TestDefinition = {
@ -17,12 +18,11 @@ export const createRenameCreateSamePathTest: TestDefinition = {
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state) => {
verify: (state: AssertableState): void => {
state
.assertFileCount(3)
.assertContent("B.md", "first file")

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const moveChainThreeFilesTest: TestDefinition = {
@ -29,7 +30,7 @@ export const moveChainThreeFilesTest: TestDefinition = {
{
type: "assert-consistent",
verify: (state) => {
verify: (state: AssertableState): void => {
state
.assertFileCount(3)
.assertContent("A.md", "was C")

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const binaryPendingCreateNotDisplacedTest: TestDefinition = {
@ -23,6 +24,17 @@ export const binaryPendingCreateNotDisplacedTest: TestDefinition = {
{ type: "enable-sync", client: 1 },
{ 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"
);
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const coalesceUpdateRemoteUpdateDataLossTest: TestDefinition = {
@ -38,10 +39,14 @@ export const coalesceUpdateRemoteUpdateDataLossTest: TestDefinition = {
{
type: "assert-consistent",
verify: (state) => {
verify: (state: AssertableState): void => {
state
.assertFileCount(1)
.assertContains("doc.md", "client 0 addition", "client 1 addition");
.assertContains(
"doc.md",
"client 0 addition",
"client 1 addition"
);
}
}
]

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
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 2" },
{ type: "update", client: 0, path: "doc.md", content: "final update" },
{ type: "sync", client: 0 },
{ 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: 1 },
@ -26,13 +31,23 @@ export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = {
{ type: "enable-sync", client: 1 },
{ 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: 1 },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ 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");
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const concurrentDeleteDuringRemoteUpdateTest: TestDefinition = {
@ -21,7 +22,11 @@ export const concurrentDeleteDuringRemoteUpdateTest: TestDefinition = {
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "assert-consistent", verify: (state) => state.assertFileCount(0) }
{
type: "assert-consistent",
verify: (state: AssertableState): void => {
state.assertFileCount(0);
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const concurrentEditExactSamePositionTest: TestDefinition = {
@ -38,7 +39,7 @@ export const concurrentEditExactSamePositionTest: TestDefinition = {
{
type: "assert-consistent",
verify: (state) => {
verify: (state: AssertableState): void => {
state
.assertFileCount(1)
.assertContains("doc.md", "slow", "fast", "brown fox");

View file

@ -1,10 +1,10 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const concurrentRenameAndCreateAtTargetTest: TestDefinition = {
description:
"One client renames X to Y while another creates a new file at Y, " +
"both offline. After syncing, Y should contain merged content from " +
"both the renamed file and the newly created file.",
"both offline. We can't merge the create because it would result in a cycle",
clients: 2,
steps: [
{
@ -37,10 +37,15 @@ export const concurrentRenameAndCreateAtTargetTest: TestDefinition = {
{
type: "assert-consistent",
verify: (state) => {
verify: (state: AssertableState): void => {
state
.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"
);
}
}
]

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const concurrentRenameAndCreateAtTargetTest: TestDefinition = {
@ -37,7 +38,7 @@ export const concurrentRenameAndCreateAtTargetTest: TestDefinition = {
{
type: "assert-consistent",
verify: (state) => {
verify: (state: AssertableState): void => {
state
.assertFileCount(2)
.assertContains("Y (1).md", "original file X")

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const concurrentRenameSameTargetTest: TestDefinition = {
@ -20,12 +21,11 @@ export const concurrentRenameSameTargetTest: TestDefinition = {
{ type: "rename", client: 1, oldPath: "B.md", newPath: "C.md" },
{ type: "enable-sync", client: 1 },
{ type: "sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state) => {
verify: (state: AssertableState): void => {
state
.assertFileCount(2)
.assertFileNotExists("A.md")

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const binaryToTextTransitionTest: TestDefinition = {
@ -8,11 +9,21 @@ export const binaryToTextTransitionTest: TestDefinition = {
"offline. The text merge should preserve both edits.",
clients: 2,
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: 1 },
{ 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: 1 },
@ -24,26 +35,63 @@ export const binaryToTextTransitionTest: TestDefinition = {
{ type: "enable-sync", client: 1 },
{ 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: "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: "enable-sync", client: 1 },
{ 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: 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: 1 },
{ 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");
}
}
]
};

View file

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

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const concurrentRenameFirstWinsTest: TestDefinition = {
@ -8,29 +9,53 @@ export const concurrentRenameFirstWinsTest: TestDefinition = {
"edits are merged.",
clients: 2,
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: 1 },
{ 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: 1 },
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
{ type: "update", client: 0, path: "B.md", content: "edit from 0\nline 2\nline 3" },
{
type: "update",
client: 0,
path: "B.md",
content: "edit from 0\nline 2\nline 3"
},
{ type: "rename", client: 1, oldPath: "A.md", newPath: "C.md" },
{ type: "update", client: 1, path: "C.md", content: "line 1\nline 2\nedit from 1" },
{
type: "update",
client: 1,
path: "C.md",
content: "line 1\nline 2\nedit from 1"
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "assert-consistent", verify: (s) => {
s.assertFileNotExists("A.md");
s.assertFileCount(1);
s.assertAnyFileContains("edit from 0", "edit from 1");
} },
],
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileNotExists("A.md")
.assertFileCount(2)
.assertContent("B.md", "edit from 0\nline 2\nline 3")
.assertContent("C.md", "line 1\nline 2\nedit from 1");
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const createRenameResponseSkipsFileTest: TestDefinition = {
@ -8,8 +9,6 @@ export const createRenameResponseSkipsFileTest: TestDefinition = {
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{
type: "create",
@ -25,10 +24,13 @@ export const createRenameResponseSkipsFileTest: TestDefinition = {
newPath: "renamed.md"
},
{ type: "sync" },
{ type: "sync" },
{ 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");
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const deleteByOtherClientThenRecreateTest: TestDefinition = {
@ -14,11 +15,26 @@ export const deleteByOtherClientThenRecreateTest: TestDefinition = {
{ type: "delete", client: 1, path: "A.md" },
{ 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: "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");
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const deleteDuringPendingCreateTest: TestDefinition = {
@ -8,7 +9,6 @@ export const deleteDuringPendingCreateTest: TestDefinition = {
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "pause-server" },
@ -23,9 +23,13 @@ export const deleteDuringPendingCreateTest: TestDefinition = {
{ type: "delete", client: 0, path: "ephemeral.md" },
{ type: "resume-server" },
{ type: "sync" },
{ 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");
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const deleteRecreateConcurrentUpdateTest: TestDefinition = {
@ -9,12 +10,16 @@ export const deleteRecreateConcurrentUpdateTest: TestDefinition = {
{ type: "create", client: 0, path: "A.md", content: "original" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "disable-sync", client: 0 },
{ type: "delete", client: 0, path: "A.md" },
{ type: "create", client: 0, path: "A.md", content: "recreated by client 0" },
{
type: "create",
client: 0,
path: "A.md",
content: "recreated by client 0"
},
{
type: "update",
@ -25,9 +30,13 @@ export const deleteRecreateConcurrentUpdateTest: TestDefinition = {
{ type: "sync", client: 1 },
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ 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");
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const deleteRecreateDifferentContentTest: TestDefinition = {
@ -14,7 +15,6 @@ export const deleteRecreateDifferentContentTest: TestDefinition = {
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "disable-sync", client: 0 },
@ -38,9 +38,17 @@ export const deleteRecreateDifferentContentTest: TestDefinition = {
{ type: "enable-sync", client: 0 },
{ type: "sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ 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"
);
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const deleteRecreateSamePathTest: TestDefinition = {
@ -9,17 +10,25 @@ export const deleteRecreateSamePathTest: TestDefinition = {
{ type: "create", client: 0, path: "A.md", content: "version 1" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ 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: "delete", client: 0, path: "A.md" },
{ type: "create", client: 0, path: "A.md", content: "version 2" },
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ 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");
}
}
]
};

View file

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

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const deleteRenameConflictTest: TestDefinition = {
@ -10,9 +11,13 @@ export const deleteRenameConflictTest: TestDefinition = {
{ 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: "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 },
@ -22,13 +27,17 @@ export const deleteRenameConflictTest: TestDefinition = {
{ type: "rename", client: 1, oldPath: "A.md", newPath: "C.md" },
{ type: "enable-sync", client: 1 },
{ type: "sync", client: 1 },
{ type: "barrier" },
{ type: "assert-consistent", verify: (s) => {
s.assertContent("B.md", "content-b");
s.assertFileNotExists("A.md");
s.ifFileExists("C.md", (s) => s.assertContent("C.md", "content-a"));
} },
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertContent("B.md", "content-b");
s.assertFileNotExists("A.md");
s.ifFileExists("C.md", (inner) =>
inner.assertContent("C.md", "content-a")
);
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const displacedFileNotMarkedDeletedTest: TestDefinition = {
@ -15,25 +16,22 @@ export const displacedFileNotMarkedDeletedTest: TestDefinition = {
{ 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: "sync", client: 0 },
{ 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: "barrier" },
{
type: "assert-consistent",
verify: (state) => {
verify: (state: AssertableState): void => {
state
.assertFileNotExists("A.md")
.assertFileExists("B.md")
.assertContains("B.md", "new file B")
.assertFileExists("C.md")
.assertContains("C.md", "edited A content");
.assertFileCount(2)
.assertContent("B.md", "content of B")
.assertContent("C.md", "content of A");
}
}
]

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const doubleOfflineCycleTest: TestDefinition = {
@ -14,9 +15,13 @@ export const doubleOfflineCycleTest: TestDefinition = {
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ 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 },
{
@ -27,9 +32,13 @@ export const doubleOfflineCycleTest: TestDefinition = {
},
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ 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 },
{
@ -40,9 +49,13 @@ export const doubleOfflineCycleTest: TestDefinition = {
},
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ 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 },
{
@ -53,8 +66,12 @@ export const doubleOfflineCycleTest: TestDefinition = {
},
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ 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");
}
}
]
};

View file

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

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const idempotencyAfterServerPauseTest: TestDefinition = {
@ -8,17 +9,25 @@ export const idempotencyAfterServerPauseTest: TestDefinition = {
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ 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: "resume-server" },
{ type: "sync" },
{ 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");
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const interruptedDeleteRetryTest: TestDefinition = {
@ -9,7 +10,6 @@ export const interruptedDeleteRetryTest: TestDefinition = {
{ type: "create", client: 0, path: "doc.md", content: "to be deleted" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "delete", client: 0, path: "doc.md" },
@ -17,9 +17,13 @@ export const interruptedDeleteRetryTest: TestDefinition = {
{ type: "pause-server" },
{ type: "resume-server" },
{ type: "sync" },
{ type: "barrier" },
{ type: "assert-consistent", verify: (s) => s.assertFileCount(0) },
],
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(0);
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const keyMigrationEventDropTest: TestDefinition = {
@ -8,7 +9,6 @@ export const keyMigrationEventDropTest: TestDefinition = {
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "pause-server" },
@ -27,9 +27,13 @@ export const keyMigrationEventDropTest: TestDefinition = {
},
{ type: "resume-server" },
{ type: "sync" },
{ 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");
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const localEditLostDuringCreateMergeTest: TestDefinition = {
@ -28,12 +29,13 @@ export const localEditLostDuringCreateMergeTest: TestDefinition = {
{
type: "assert-consistent",
verify: (s) =>
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContains(
"doc.md",
"from-client-1",
"local-edit-during-create"
),
);
}
}
]
};

View file

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

View file

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

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const mcCrossCreateRenameSameTargetTest: TestDefinition = {
@ -12,12 +13,13 @@ export const mcCrossCreateRenameSameTargetTest: TestDefinition = {
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{
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 },
@ -28,12 +30,11 @@ export const mcCrossCreateRenameSameTargetTest: TestDefinition = {
{ type: "rename", client: 1, oldPath: "Y.md", newPath: "Z.md" },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) => {
verify: (s: AssertableState): void => {
s.assertFileCount(2)
.assertFileNotExists("X.md")
.assertFileNotExists("Y.md")

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const mcDeleteThenOfflineRenameTest: TestDefinition = {
@ -11,7 +12,6 @@ export const mcDeleteThenOfflineRenameTest: TestDefinition = {
{ type: "create", client: 0, path: "C.md", content: "unrelated" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "disable-sync", client: 1 },
@ -22,15 +22,17 @@ export const mcDeleteThenOfflineRenameTest: TestDefinition = {
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) => {
s.assertContent("C.md", "unrelated")
.assertFileNotExists("A.md");
s.ifFileExists("B.md", (s) => s.assertContent("B.md", "original"));
verify: (s: AssertableState): void => {
s.assertContent("C.md", "unrelated").assertFileNotExists(
"A.md"
);
s.ifFileExists("B.md", (inner) =>
inner.assertContent("B.md", "original")
);
}
}
]

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const mcMultiDeleteOfflineRenameTest: TestDefinition = {
@ -13,7 +14,6 @@ export const mcMultiDeleteOfflineRenameTest: TestDefinition = {
{ type: "create", client: 0, path: "file-5.md", content: "content-5" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "disable-sync", client: 0 },
@ -22,21 +22,27 @@ export const mcMultiDeleteOfflineRenameTest: TestDefinition = {
{ type: "delete", client: 1, path: "file-4.md" },
{ 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: "sync" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) => {
verify: (s: AssertableState): void => {
s.assertFileExists("file-1.md")
.assertFileExists("file-3.md")
.assertFileExists("file-5.md")
.assertFileNotExists("file-2.md")
.assertFileNotExists("file-4.md");
s.ifFileExists("renamed.md", (s) => s.assertContent("renamed.md", "content-2"));
s.ifFileExists("renamed.md", (inner) =>
inner.assertContent("renamed.md", "content-2")
);
}
}
]

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const mcThreeClientRenameOfflineUpdateTest: TestDefinition = {
@ -10,7 +11,6 @@ export const mcThreeClientRenameOfflineUpdateTest: TestDefinition = {
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "enable-sync", client: 2 },
{ type: "sync" },
{ type: "barrier" },
{ type: "disable-sync", client: 2 },
@ -19,12 +19,23 @@ export const mcThreeClientRenameOfflineUpdateTest: TestDefinition = {
{ type: "sync", client: 1 },
{ 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: "sync" },
{ 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");
}
}
]
};

View file

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

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const migrateKeyPreservesExistingTest: TestDefinition = {
@ -8,7 +9,6 @@ export const migrateKeyPreservesExistingTest: TestDefinition = {
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "pause-server" },
@ -22,9 +22,16 @@ export const migrateKeyPreservesExistingTest: TestDefinition = {
},
{ type: "resume-server" },
{ type: "sync" },
{ 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"
);
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const moveAndConcurrentRemoteUpdateTest: TestDefinition = {
@ -14,7 +15,6 @@ export const moveAndConcurrentRemoteUpdateTest: TestDefinition = {
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "disable-sync", client: 0 },
@ -29,9 +29,15 @@ export const moveAndConcurrentRemoteUpdateTest: TestDefinition = {
{ type: "sync", client: 1 },
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ 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");
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const movePreservesRemoteUpdateTest: TestDefinition = {
@ -6,32 +7,42 @@ export const movePreservesRemoteUpdateTest: TestDefinition = {
"After both reconnect, the renamed file should contain client 1's edit.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "doc.md", content: "line 1\nline 2" },
{
type: "create",
client: 0,
path: "doc.md",
content: "line 1\nline 2"
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
{ type: "rename", client: 0, oldPath: "doc.md", newPath: "renamed.md" },
{ type: "update", client: 1, path: "doc.md", content: "line 1\nclient 1 edit\nline 2" },
{
type: "update",
client: 1,
path: "doc.md",
content: "line 1\nclient 1 edit\nline 2"
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) => {
verify: (s: AssertableState): void => {
s.assertFileCount(1);
const content = Array.from(s.files.values())[0];
const [content] = Array.from(s.files.values());
if (!content.includes("client 1 edit")) {
throw new Error(`Expected merged content to include "client 1 edit", got: "${content}"`);
throw new Error(
`Expected merged content to include "client 1 edit", got: "${content}"`
);
}
}
},
],
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const moveRemoteUpdateRevertsRenameTest: TestDefinition = {
@ -9,26 +10,28 @@ export const moveRemoteUpdateRevertsRenameTest: TestDefinition = {
{ type: "create", client: 0, path: "doc.md", content: "original" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ 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: "enable-sync", client: 0 },
{ type: "rename", client: 0, oldPath: "doc.md", newPath: "renamed.md" },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) => {
s.assertFileCount(1);
const content = Array.from(s.files.values())[0];
if (content !== "updated by client 1") {
throw new Error(`Expected "updated by client 1", got: "${content}"`);
}
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContent(
"renamed.md",
"updated by client 1"
);
}
}
]

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const moveThenDeleteStalePathTest: TestDefinition = {
@ -14,15 +15,20 @@ export const moveThenDeleteStalePathTest: TestDefinition = {
},
{ 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: "delete", client: 0, path: "B.md" },
{ type: "sync" },
{ 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");
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const multiFileOperationsTest: TestDefinition = {
@ -11,7 +12,6 @@ export const multiFileOperationsTest: TestDefinition = {
{ type: "create", client: 0, path: "C.md", content: "content-c" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "disable-sync", client: 1 },
@ -19,20 +19,26 @@ export const multiFileOperationsTest: TestDefinition = {
{ type: "delete", client: 0, path: "A.md" },
{ 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: "enable-sync", client: 1 },
{ type: "sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) => {
verify: (s: AssertableState): void => {
s.assertContains("B.md", "updated")
.assertFileExists("C.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")
);
}
}
]

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const offlineConcurrentRenamesTest: TestDefinition = {
@ -11,11 +12,12 @@ export const offlineConcurrentRenamesTest: TestDefinition = {
{ type: "create", client: 0, path: "A.md", content: "shared-content" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{
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 },
@ -37,20 +39,19 @@ export const offlineConcurrentRenamesTest: TestDefinition = {
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) => {
verify: (s: AssertableState): void => {
s.assertFileNotExists("A.md")
.assertFileCount(1)
.assertAnyFileContains("shared-content");
s.ifFileExists("B.md", (s) =>
s.assertContent("B.md", "shared-content")
s.ifFileExists("B.md", (inner) =>
inner.assertContent("B.md", "shared-content")
);
s.ifFileExists("C.md", (s) =>
s.assertContent("C.md", "shared-content")
s.ifFileExists("C.md", (inner) =>
inner.assertContent("C.md", "shared-content")
);
}
}

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const offlineCreateSamePathMergeableTest: TestDefinition = {
@ -22,20 +23,19 @@ export const offlineCreateSamePathMergeableTest: TestDefinition = {
{ type: "enable-sync", client: 0 },
{ type: "sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) =>
s
.assertFileCount(1)
verify: (s: AssertableState): void => {
s.assertFileCount(1)
.assertFileExists("notes.md")
.assertContains(
"notes.md",
"alpha wrote this line",
"beta wrote this different line"
)
);
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const offlineDeleteRemoteRenameTest: TestDefinition = {
@ -27,9 +28,10 @@ export const offlineDeleteRemoteRenameTest: TestDefinition = {
{
type: "assert-consistent",
verify: (s) => {
s.assertFileNotExists("A.md")
.assertFileNotExists("A_renamed.md");
verify: (s: AssertableState): void => {
s.assertFileNotExists("A.md").assertFileNotExists(
"A_renamed.md"
);
}
}
]

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const offlineDeleteVsRemoteUpdateTest: TestDefinition = {
@ -13,11 +14,12 @@ export const offlineDeleteVsRemoteUpdateTest: TestDefinition = {
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{
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 },
@ -32,12 +34,13 @@ export const offlineDeleteVsRemoteUpdateTest: TestDefinition = {
{ type: "sync", client: 1 },
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) => s.assertFileCount(0)
verify: (s: AssertableState): void => {
s.assertFileCount(0);
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const offlineEditRemoteRenameTest: TestDefinition = {
@ -9,11 +10,12 @@ export const offlineEditRemoteRenameTest: TestDefinition = {
{ type: "create", client: 0, path: "A.md", content: "original" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) => s.assertContent("A.md", "original")
verify: (s: AssertableState): void => {
s.assertContent("A.md", "original");
}
},
{ type: "disable-sync", client: 0 },
@ -33,16 +35,15 @@ export const offlineEditRemoteRenameTest: TestDefinition = {
{ type: "sync", client: 1 },
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) =>
s
.assertFileNotExists("A.md")
verify: (s: AssertableState): void => {
s.assertFileNotExists("A.md")
.assertFileCount(1)
.assertContains("B.md", "edited by client 0")
.assertContains("B.md", "edited by client 0");
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const offlineEditThenMoveSameContentTest: TestDefinition = {
@ -19,7 +20,6 @@ export const offlineEditThenMoveSameContentTest: TestDefinition = {
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "disable-sync", client: 0 },
@ -36,17 +36,16 @@ export const offlineEditThenMoveSameContentTest: TestDefinition = {
},
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) =>
s
.assertFileNotExists("A.md")
verify: (s: AssertableState): void => {
s.assertFileNotExists("A.md")
.assertFileNotExists("B.md")
.assertContent("C.md", "content A")
.assertFileCount(1)
.assertFileCount(1);
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const offlineMixedOperationsTest: TestDefinition = {
@ -12,16 +13,15 @@ export const offlineMixedOperationsTest: TestDefinition = {
{ type: "create", client: 0, path: "file3.md", content: "content-3" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) =>
s
.assertContent("file1.md", "content-1")
verify: (s: AssertableState): void => {
s.assertContent("file1.md", "content-1")
.assertContent("file2.md", "content-2")
.assertContent("file3.md", "content-3")
.assertContent("file3.md", "content-3");
}
},
{ type: "disable-sync", client: 0 },
@ -41,18 +41,17 @@ export const offlineMixedOperationsTest: TestDefinition = {
},
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) =>
s
.assertFileNotExists("file1.md")
verify: (s: AssertableState): void => {
s.assertFileNotExists("file1.md")
.assertFileNotExists("file2.md")
.assertContent("moved.md", "content-2")
.assertContent("file3.md", "updated-content-3")
.assertFileCount(2)
.assertFileCount(2);
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const offlineMoveThenRemoteDeleteTest: TestDefinition = {
@ -14,7 +15,6 @@ export const offlineMoveThenRemoteDeleteTest: TestDefinition = {
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "disable-sync", client: 0 },
@ -24,16 +24,13 @@ export const offlineMoveThenRemoteDeleteTest: TestDefinition = {
{ type: "sync", client: 1 },
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) =>
s
.assertFileNotExists("A.md")
.assertFileNotExists("B.md")
.assertFileCount(0)
verify: (s: AssertableState): void => {
s.assertFileCount(0);
}
}
]
};

View file

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

View file

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

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const offlineRenameRemoteCreateOldPathTest: TestDefinition = {
@ -10,11 +11,12 @@ export const offlineRenameRemoteCreateOldPathTest: TestDefinition = {
{ type: "create", client: 0, path: "X.md", content: "original" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) => s.assertContent("X.md", "original")
verify: (s: AssertableState): void => {
s.assertContent("X.md", "original");
}
},
{ type: "disable-sync", client: 0 },
@ -34,15 +36,16 @@ export const offlineRenameRemoteCreateOldPathTest: TestDefinition = {
{ type: "sync", client: 1 },
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) =>
s
.assertFileCount(1)
.assertContains("Y.md", "updated-by-client-1")
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContains(
"Y.md",
"updated-by-client-1"
);
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const offlineUpdateBothThenDeleteOneTest: TestDefinition = {
@ -22,14 +23,15 @@ export const offlineUpdateBothThenDeleteOneTest: TestDefinition = {
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) =>
s
.assertContent("A.md", "A original")
.assertContent("B.md", "B original")
verify: (s: AssertableState): void => {
s.assertContent("A.md", "A original").assertContent(
"B.md",
"B original"
);
}
},
{ type: "disable-sync", client: 0 },
@ -58,15 +60,16 @@ export const offlineUpdateBothThenDeleteOneTest: TestDefinition = {
{ type: "sync", client: 1 },
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) =>
s
.assertContent("A.md", "A updated by client 0")
.assertFileNotExists("B.md")
verify: (s: AssertableState): void => {
s.assertContent(
"A.md",
"A updated by client 0"
).assertFileNotExists("B.md");
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const onlineBothCreateSamePathDeconflictTest: TestDefinition = {
@ -23,7 +24,7 @@ export const onlineBothCreateSamePathDeconflictTest: TestDefinition = {
{
type: "assert-consistent",
verify: (state) => {
verify: (state: AssertableState): void => {
state
.assertFileCount(1)
.assertContains("A.md", "updated-by-0", "from-client-1 ");

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const onlineCreateRenameConcurrentCreateOrphanTest: TestDefinition = {
@ -12,8 +13,18 @@ export const onlineCreateRenameConcurrentCreateOrphanTest: TestDefinition = {
{ 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: "delete", client: 0, path: "moved.bin" },
@ -22,7 +33,7 @@ export const onlineCreateRenameConcurrentCreateOrphanTest: TestDefinition = {
{
type: "assert-consistent",
verify: (state) => {
verify: (state: AssertableState): void => {
state.assertFileCount(0);
}
}

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const onlineCreateUpdateWhileOtherCreatesSamePathTest: TestDefinition = {
@ -11,18 +12,36 @@ export const onlineCreateUpdateWhileOtherCreatesSamePathTest: TestDefinition = {
{ type: "enable-sync", client: 1 },
{ type: "pause-websocket", client: 1 },
{ type: "create", client: 0, path: "data.bin", content: "BINARY:content-v1" },
{ type: "update", client: 0, path: "data.bin", content: "BINARY:content-v2" },
{ type: "create", client: 1, path: "data.bin", content: "BINARY:other-content" },
{
type: "create",
client: 0,
path: "data.bin",
content: "BINARY:content-v1"
},
{
type: "update",
client: 0,
path: "data.bin",
content: "BINARY:content-v2"
},
{
type: "create",
client: 1,
path: "data.bin",
content: "BINARY:other-content"
},
{ type: "resume-websocket", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent", verify: (state) => {
state.assertFileCount(2)
.assertContains("data.bin", "content-v2")
.assertContains("data (1).bin", "other-content");
type: "assert-consistent",
verify: (state: AssertableState): void => {
state
.assertFileCount(2)
.assertNoFileContains("content-v1")
.assertAnyFileContains("content-v2")
.assertAnyFileContains("other-content");
}
}
]

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const onlineDeleteRecreateRapidCycleTest: TestDefinition = {
@ -28,7 +29,9 @@ export const onlineDeleteRecreateRapidCycleTest: TestDefinition = {
{
type: "assert-consistent",
verify: (s) => s.assertContent("A.md", "round 3"),
},
],
verify: (s: AssertableState): void => {
s.assertContent("A.md", "round 3");
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const onlineEditVsDeleteConvergenceTest: TestDefinition = {
@ -11,17 +12,20 @@ export const onlineEditVsDeleteConvergenceTest: TestDefinition = {
{ type: "enable-sync", client: 1 },
{ 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: "barrier" },
{
type: "assert-consistent",
verify: (state) => {
state.ifFileExists("A.md", (s) =>
s.assertContainsAny("A.md", "edited by client 0")
);
verify: (state: AssertableState): void => {
state.assertFileCount(0);
}
},
],
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const overlappingEditsSameSectionTest: TestDefinition = {
@ -14,7 +15,6 @@ export const overlappingEditsSameSectionTest: TestDefinition = {
},
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "disable-sync", client: 0 },
@ -36,14 +36,19 @@ export const overlappingEditsSameSectionTest: TestDefinition = {
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) =>
s.assertFileCount(1)
.assertContains("doc.md", "# Title", "alpha addition", "beta addition", "footer"),
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContains(
"doc.md",
"# Title",
"alpha addition",
"beta addition",
"footer"
);
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const queueResetLosesCoalescedLocalEditTest: TestDefinition = {
@ -23,8 +24,13 @@ export const queueResetLosesCoalescedLocalEditTest: TestDefinition = {
{
type: "assert-consistent",
verify: (s) =>
s.assertFileCount(1).assertContains("doc.md", "alpha", "charlie"),
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContains(
"doc.md",
"alpha",
"charlie"
);
}
}
]
};

View file

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

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const rapidCreateUpdateDeleteCycleTest: TestDefinition = {
@ -8,7 +9,6 @@ export const rapidCreateUpdateDeleteCycleTest: TestDefinition = {
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "pause-server" },
@ -41,7 +41,12 @@ export const rapidCreateUpdateDeleteCycleTest: TestDefinition = {
{
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"
);
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const rapidEditDeleteOnlineConvergenceTest: TestDefinition = {
@ -28,17 +29,20 @@ export const rapidEditDeleteOnlineConvergenceTest: TestDefinition = {
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) => {
verify: (s: AssertableState): void => {
for (const [path, content] of s.files) {
for (const clientFiles of s.clientFiles) {
if (clientFiles.has(path) && clientFiles.get(path) !== content) {
if (
clientFiles.has(path) &&
clientFiles.get(path) !== content
) {
throw new Error(
`Content mismatch for ${path}: "${clientFiles.get(path)}" vs "${content}"`
);
}
}
}
},
},
],
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const rapidUpdatesAfterMergeTest: TestDefinition = {
@ -11,7 +12,6 @@ export const rapidUpdatesAfterMergeTest: TestDefinition = {
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{
@ -36,13 +36,14 @@ export const rapidUpdatesAfterMergeTest: TestDefinition = {
path: "doc.md",
content: "update 3"
},
{ type: "sync", client: 0 },
{ type: "barrier" },
{
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");
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const recentlyDeletedClearedOnReconnectTest: TestDefinition = {
@ -19,7 +20,12 @@ export const recentlyDeletedClearedOnReconnectTest: TestDefinition = {
{ type: "disable-sync", client: 0 },
{ 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: "sync", client: 1 },
@ -28,8 +34,12 @@ export const recentlyDeletedClearedOnReconnectTest: TestDefinition = {
{
type: "assert-consistent",
verify: (s) =>
s.assertFileCount(1).assertContent("doc.md", "new content from client 1"),
},
],
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContent(
"doc.md",
"new content from client 1"
);
}
}
]
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const renameChainThenDeleteTest: TestDefinition = {
@ -9,11 +10,12 @@ export const renameChainThenDeleteTest: TestDefinition = {
{ type: "create", client: 0, path: "X.md", content: "chain-content" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{
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 },
@ -36,9 +38,13 @@ export const renameChainThenDeleteTest: TestDefinition = {
{ type: "sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "assert-consistent", verify: (s) => s.assertFileCount(0) }
{
type: "assert-consistent",
verify: (s: AssertableState): void => {
s.assertFileCount(0);
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const renameChainTest: TestDefinition = {
@ -9,20 +10,25 @@ export const renameChainTest: TestDefinition = {
steps: [
{ 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: "B.md", newPath: "C.md" },
{ type: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) =>
verify: (s: AssertableState): void => {
s.assertFileNotExists("A.md")
.assertFileNotExists("B.md")
.assertContent("C.md", "important content"),
.assertContent("C.md", "important content");
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const renameCircularTest: TestDefinition = {
@ -13,10 +14,11 @@ export const renameCircularTest: TestDefinition = {
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) =>
verify: (s: AssertableState): void => {
s.assertContent("A.md", "content-a")
.assertContent("B.md", "content-b")
.assertContent("C.md", "content-c"),
.assertContent("C.md", "content-c");
}
},
{ 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: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) =>
verify: (s: AssertableState): void => {
s.assertFileNotExists("temp-a.md")
.assertFileCount(3)
.assertContent("A.md", "content-c")
.assertContent("B.md", "content-a")
.assertContent("C.md", "content-b"),
.assertAnyFileContains("content-c")
.assertAnyFileContains("content-a")
.assertAnyFileContains("content-b");
}
}
]
};

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const renameCreateConflictTest: TestDefinition = {
@ -8,23 +9,26 @@ export const renameCreateConflictTest: TestDefinition = {
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "create", client: 0, path: "A.md", content: "hi" },
{ type: "sync", client: 0 },
{ type: "sync", client: 1 },
{ type: "barrier" },
{
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: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
{ type: "sync", client: 1 },
{ type: "create", client: 0, path: "B.md", content: "hi" },
{ type: "enable-sync", client: 0 },
{ type: "sync", client: 0 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) =>
s.assertFileNotExists("A.md").assertContent("B.md", "hi"),
verify: (s: AssertableState): void => {
s.assertFileCount(2)
.assertContent("B.md", "hi")
.assertContent("B (1).md", "hi");
}
}
]
};

View file

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

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const renamePendingCreateBeforeResponseTest: TestDefinition = {
@ -7,8 +8,6 @@ export const renamePendingCreateBeforeResponseTest: TestDefinition = {
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "pause-server" },
@ -28,14 +27,16 @@ export const renamePendingCreateBeforeResponseTest: TestDefinition = {
{ type: "resume-server" },
{ type: "sync" },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) =>
s.assertFileCount(1).assertContent("renamed.md", "original-content"),
verify: (s: AssertableState): void => {
s.assertFileCount(1).assertContent(
"renamed.md",
"original-content"
);
}
}
]
};

View file

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

View file

@ -1,3 +1,4 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const renameRoundtripTest: TestDefinition = {
@ -8,31 +9,32 @@ export const renameRoundtripTest: TestDefinition = {
{ type: "create", client: 0, path: "A.md", content: "original" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{
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: "sync" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) =>
s.assertFileNotExists("A.md").assertContent("B.md", "original"),
verify: (s: AssertableState): void => {
s.assertFileNotExists("A.md").assertContent("B.md", "original");
}
},
{ type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" },
{ type: "sync" },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) =>
s.assertFileNotExists("B.md").assertContent("A.md", "original"),
verify: (s: AssertableState): void => {
s.assertFileNotExists("B.md").assertContent("A.md", "original");
}
}
]
};

View file

@ -1,11 +1,11 @@
import type { AssertableState } from "../utils/assertable-state";
import type { TestDefinition } from "../test-definition";
export const renameSwapTest: TestDefinition = {
description:
"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. " +
"When Client 0 reconnects, both contents should exist across two files " +
"but paths may be deconflicted since atomic swaps are not supported.",
"When Client 0 reconnects, both contents should exist across two files.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "A.md", content: "content-a" },
@ -15,8 +15,12 @@ export const renameSwapTest: TestDefinition = {
{ type: "barrier" },
{
type: "assert-consistent",
verify: (s) =>
s.assertContent("A.md", "content-a").assertContent("B.md", "content-b"),
verify: (s: AssertableState): void => {
s.assertContent("A.md", "content-a").assertContent(
"B.md",
"content-b"
);
}
},
{ type: "disable-sync", client: 0 },
@ -29,12 +33,12 @@ export const renameSwapTest: TestDefinition = {
{
type: "assert-consistent",
verify: (s) =>
s
.assertFileNotExists("temp.md")
verify: (s: AssertableState): void => {
s.assertFileNotExists("temp.md")
.assertFileCount(2)
.assertContent("A.md", "content-b")
.assertContent("B.md", "content-a"),
.assertAnyFileContains("content-b")
.assertAnyFileContains("content-a");
}
}
]
};

View file

@ -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