diff --git a/.vscode/settings.json b/.vscode/settings.json index e5963c20..98187650 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,4 +7,4 @@ "**/.sqlx": true, "**/target": true } -} \ No newline at end of file +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..39161e39 --- /dev/null +++ b/CLAUDE.md @@ -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_.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 `. + +## 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` — primary record store. +- `byLocalPath: Map` — 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` 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-.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`. diff --git a/frontend/deterministic-tests/README.md b/frontend/deterministic-tests/README.md index 678cd0fe..c422406d 100644 --- a/frontend/deterministic-tests/README.md +++ b/frontend/deterministic-tests/README.md @@ -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 }; ``` diff --git a/frontend/deterministic-tests/src/cli.ts b/frontend/deterministic-tests/src/cli.ts index 57cee963..6e0e764f 100644 --- a/frontend/deterministic-tests/src/cli.ts +++ b/frontend/deterministic-tests/src/cli.ts @@ -38,137 +38,6 @@ interface NamedTestResult { result: TestResult; } - -async function main(): Promise { - 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 { + 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); +}); diff --git a/frontend/deterministic-tests/src/deterministic-agent.ts b/frontend/deterministic-tests/src/deterministic-agent.ts index 71f6a272..74ec2b8d 100644 --- a/frontend/deterministic-tests/src/deterministic-agent.ts +++ b/frontend/deterministic-tests/src/deterministic-agent.ts @@ -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>(); private readonly wsFactory = new ManagedWebSocketFactory(); + private nextWriteRename: + | { + oldPath: RelativePath; + newPath: RelativePath; + } + | undefined; + private nextCreateResponseDrop: + | { + dropped: Promise; + 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 { 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((resolve) => { + resolveDropped = resolve; + }); + this.nextCreateResponseDrop = { + dropped, + resolveDropped + }; + this.log("Armed next create response drop"); + } + + public async waitForDroppedCreateResponse(): Promise { + 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 { + const existing = this.client.getHistoryEntries().find(matches); + if (existing !== undefined) { + onMatch?.(existing); + return; + } + + await withTimeout( + new Promise((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 { 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 { + 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 { 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 { 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 { 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 { 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 { 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 { 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 { @@ -243,7 +379,7 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { */ private async drainPendingSyncOperations(): Promise { 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); + } } diff --git a/frontend/deterministic-tests/src/managed-websocket.ts b/frontend/deterministic-tests/src/managed-websocket.ts index c09b44d7..421561fd 100644 --- a/frontend/deterministic-tests/src/managed-websocket.ts +++ b/frontend/deterministic-tests/src/managed-websocket.ts @@ -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(); } diff --git a/frontend/deterministic-tests/src/parse-concurrency.ts b/frontend/deterministic-tests/src/parse-concurrency.ts index a6622a04..f926d1fa 100644 --- a/frontend/deterministic-tests/src/parse-concurrency.ts +++ b/frontend/deterministic-tests/src/parse-concurrency.ts @@ -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; diff --git a/frontend/deterministic-tests/src/server-control.ts b/frontend/deterministic-tests/src/server-control.ts index de0dbe4b..f903cc4c 100644 --- a/frontend/deterministic-tests/src/server-control.ts +++ b/frontend/deterministic-tests/src/server-control.ts @@ -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; } } - } diff --git a/frontend/deterministic-tests/src/server-manager.ts b/frontend/deterministic-tests/src/server-manager.ts index 8764e669..a9697eb0 100644 --- a/frontend/deterministic-tests/src/server-manager.ts +++ b/frontend/deterministic-tests/src/server-manager.ts @@ -19,7 +19,9 @@ export class ServerManager { } public async stopAll(): Promise { - 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)); }); } diff --git a/frontend/deterministic-tests/src/test-definition.ts b/frontend/deterministic-tests/src/test-definition.ts index 826c6014..bd832a50 100644 --- a/frontend/deterministic-tests/src/test-definition.ts +++ b/frontend/deterministic-tests/src/test-definition.ts @@ -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; diff --git a/frontend/deterministic-tests/src/test-registry.ts b/frontend/deterministic-tests/src/test-registry.ts index 4a16db2b..ed4fe026 100644 --- a/frontend/deterministic-tests/src/test-registry.ts +++ b/frontend/deterministic-tests/src/test-registry.ts @@ -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> = { "rename-create-conflict": renameCreateConflictTest, @@ -101,11 +115,12 @@ export const TESTS: Partial> = { "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> = { "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> = { "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> = { "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 }; diff --git a/frontend/deterministic-tests/src/test-runner.ts b/frontend/deterministic-tests/src/test-runner.ts index 2d469fa2..c8cbadd0 100644 --- a/frontend/deterministic-tests/src/test-runner.ts +++ b/frontend/deterministic-tests/src/test-runner.ts @@ -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 { 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. diff --git a/frontend/deterministic-tests/src/tests/1-text-pending-create-not-displaced.test.ts b/frontend/deterministic-tests/src/tests/1-text-pending-create-not-displaced.test.ts index fced7c5f..28243525 100644 --- a/frontend/deterministic-tests/src/tests/1-text-pending-create-not-displaced.test.ts +++ b/frontend/deterministic-tests/src/tests/1-text-pending-create-not-displaced.test.ts @@ -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"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/10-concurrent-update-diff-consistency.test.ts b/frontend/deterministic-tests/src/tests/10-concurrent-update-diff-consistency.test.ts index 94e6914e..d21ce16b 100644 --- a/frontend/deterministic-tests/src/tests/10-concurrent-update-diff-consistency.test.ts +++ b/frontend/deterministic-tests/src/tests/10-concurrent-update-diff-consistency.test.ts @@ -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" + ); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/10-user-parenthesized-file-not-deleted.test.ts b/frontend/deterministic-tests/src/tests/10-user-parenthesized-file-not-deleted.test.ts index 8be438e2..ef6cd771 100644 --- a/frontend/deterministic-tests/src/tests/10-user-parenthesized-file-not-deleted.test.ts +++ b/frontend/deterministic-tests/src/tests/10-user-parenthesized-file-not-deleted.test.ts @@ -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") diff --git a/frontend/deterministic-tests/src/tests/11-create-delete-noop.test.ts b/frontend/deterministic-tests/src/tests/11-create-delete-noop.test.ts index b1239217..6c766001 100644 --- a/frontend/deterministic-tests/src/tests/11-create-delete-noop.test.ts +++ b/frontend/deterministic-tests/src/tests/11-create-delete-noop.test.ts @@ -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"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/12-create-merge-delete.test.ts b/frontend/deterministic-tests/src/tests/12-create-merge-delete.test.ts index 4b121939..ef7ea5c3 100644 --- a/frontend/deterministic-tests/src/tests/12-create-merge-delete.test.ts +++ b/frontend/deterministic-tests/src/tests/12-create-merge-delete.test.ts @@ -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"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/13-move-identical-content-ambiguity.test.ts b/frontend/deterministic-tests/src/tests/13-move-identical-content-ambiguity.test.ts index 9c0f7245..2a9ce0b4 100644 --- a/frontend/deterministic-tests/src/tests/13-move-identical-content-ambiguity.test.ts +++ b/frontend/deterministic-tests/src/tests/13-move-identical-content-ambiguity.test.ts @@ -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") diff --git a/frontend/deterministic-tests/src/tests/15-create-update-coalesce-server-pause.test.ts b/frontend/deterministic-tests/src/tests/15-create-update-coalesce-server-pause.test.ts index 608f845d..9b752d05 100644 --- a/frontend/deterministic-tests/src/tests/15-create-update-coalesce-server-pause.test.ts +++ b/frontend/deterministic-tests/src/tests/15-create-update-coalesce-server-pause.test.ts @@ -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"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/16-create-during-reconciliation.test.ts b/frontend/deterministic-tests/src/tests/16-create-during-reconciliation.test.ts index 54dc3f98..0fe51106 100644 --- a/frontend/deterministic-tests/src/tests/16-create-during-reconciliation.test.ts +++ b/frontend/deterministic-tests/src/tests/16-create-during-reconciliation.test.ts @@ -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") diff --git a/frontend/deterministic-tests/src/tests/17-create-merge-preserves-renamed-update.test.ts b/frontend/deterministic-tests/src/tests/17-create-merge-preserves-renamed-update.test.ts index f600c40e..a9bc37d4 100644 --- a/frontend/deterministic-tests/src/tests/17-create-merge-preserves-renamed-update.test.ts +++ b/frontend/deterministic-tests/src/tests/17-create-merge-preserves-renamed-update.test.ts @@ -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"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/18-create-rename-create-same-path.test.ts b/frontend/deterministic-tests/src/tests/18-create-rename-create-same-path.test.ts index 2b169a1d..b9e16c90 100644 --- a/frontend/deterministic-tests/src/tests/18-create-rename-create-same-path.test.ts +++ b/frontend/deterministic-tests/src/tests/18-create-rename-create-same-path.test.ts @@ -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") diff --git a/frontend/deterministic-tests/src/tests/19-move-chain-three-files.test.ts b/frontend/deterministic-tests/src/tests/19-move-chain-three-files.test.ts index a6c6851b..fe9267d4 100644 --- a/frontend/deterministic-tests/src/tests/19-move-chain-three-files.test.ts +++ b/frontend/deterministic-tests/src/tests/19-move-chain-three-files.test.ts @@ -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") diff --git a/frontend/deterministic-tests/src/tests/2-binary-pending-create-not-displaced.test.ts b/frontend/deterministic-tests/src/tests/2-binary-pending-create-not-displaced.test.ts index 0616136b..467c19f0 100644 --- a/frontend/deterministic-tests/src/tests/2-binary-pending-create-not-displaced.test.ts +++ b/frontend/deterministic-tests/src/tests/2-binary-pending-create-not-displaced.test.ts @@ -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" + ); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/3-coalesce-update-remote-update-data-loss.test.ts b/frontend/deterministic-tests/src/tests/3-coalesce-update-remote-update-data-loss.test.ts index 33fb8107..69a5ff10 100644 --- a/frontend/deterministic-tests/src/tests/3-coalesce-update-remote-update-data-loss.test.ts +++ b/frontend/deterministic-tests/src/tests/3-coalesce-update-remote-update-data-loss.test.ts @@ -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" + ); } } ] diff --git a/frontend/deterministic-tests/src/tests/4-coalesced-remote-update-watermark-loss.test.ts b/frontend/deterministic-tests/src/tests/4-coalesced-remote-update-watermark-loss.test.ts index 15fe3e82..aceb8baa 100644 --- a/frontend/deterministic-tests/src/tests/4-coalesced-remote-update-watermark-loss.test.ts +++ b/frontend/deterministic-tests/src/tests/4-coalesced-remote-update-watermark-loss.test.ts @@ -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"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/5-concurrent-delete-during-remote-update.test.ts b/frontend/deterministic-tests/src/tests/5-concurrent-delete-during-remote-update.test.ts index 3108ecfe..88376f22 100644 --- a/frontend/deterministic-tests/src/tests/5-concurrent-delete-during-remote-update.test.ts +++ b/frontend/deterministic-tests/src/tests/5-concurrent-delete-during-remote-update.test.ts @@ -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); + } + } ] }; - diff --git a/frontend/deterministic-tests/src/tests/6-concurrent-edit-exact-same-position.test.ts b/frontend/deterministic-tests/src/tests/6-concurrent-edit-exact-same-position.test.ts index 08778488..5c141a0e 100644 --- a/frontend/deterministic-tests/src/tests/6-concurrent-edit-exact-same-position.test.ts +++ b/frontend/deterministic-tests/src/tests/6-concurrent-edit-exact-same-position.test.ts @@ -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"); diff --git a/frontend/deterministic-tests/src/tests/7-concurrent-rename-and-create-at-target.test.ts b/frontend/deterministic-tests/src/tests/7-concurrent-rename-and-create-at-target.test.ts index 3e71ed7d..63dee0db 100644 --- a/frontend/deterministic-tests/src/tests/7-concurrent-rename-and-create-at-target.test.ts +++ b/frontend/deterministic-tests/src/tests/7-concurrent-rename-and-create-at-target.test.ts @@ -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" + ); } } ] diff --git a/frontend/deterministic-tests/src/tests/8-concurrent-rename-and-create-at-target.test.ts b/frontend/deterministic-tests/src/tests/8-concurrent-rename-and-create-at-target.test.ts index 9f0b0318..a6f34102 100644 --- a/frontend/deterministic-tests/src/tests/8-concurrent-rename-and-create-at-target.test.ts +++ b/frontend/deterministic-tests/src/tests/8-concurrent-rename-and-create-at-target.test.ts @@ -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") diff --git a/frontend/deterministic-tests/src/tests/9-concurrent-rename-same-target.test.ts b/frontend/deterministic-tests/src/tests/9-concurrent-rename-same-target.test.ts index 230c7a1d..0b72c0f3 100644 --- a/frontend/deterministic-tests/src/tests/9-concurrent-rename-same-target.test.ts +++ b/frontend/deterministic-tests/src/tests/9-concurrent-rename-same-target.test.ts @@ -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") diff --git a/frontend/deterministic-tests/src/tests/binary-to-text-transition.test.ts b/frontend/deterministic-tests/src/tests/binary-to-text-transition.test.ts index f6e14152..8b934c1b 100644 --- a/frontend/deterministic-tests/src/tests/binary-to-text-transition.test.ts +++ b/frontend/deterministic-tests/src/tests/binary-to-text-transition.test.ts @@ -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"); + } + } + ] }; diff --git a/frontend/deterministic-tests/src/tests/catchup-create-and-update-not-skipped.test.ts b/frontend/deterministic-tests/src/tests/catchup-create-and-update-not-skipped.test.ts new file mode 100644 index 00000000..2d40228f --- /dev/null +++ b/frontend/deterministic-tests/src/tests/catchup-create-and-update-not-skipped.test.ts @@ -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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/concurrent-rename-first-wins.test.ts b/frontend/deterministic-tests/src/tests/concurrent-rename-first-wins.test.ts index 1dddcf7a..5337649d 100644 --- a/frontend/deterministic-tests/src/tests/concurrent-rename-first-wins.test.ts +++ b/frontend/deterministic-tests/src/tests/concurrent-rename-first-wins.test.ts @@ -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"); + } + } + ] }; diff --git a/frontend/deterministic-tests/src/tests/create-rename-response-skips-file.test.ts b/frontend/deterministic-tests/src/tests/create-rename-response-skips-file.test.ts index 5bec2bcb..aa24b110 100644 --- a/frontend/deterministic-tests/src/tests/create-rename-response-skips-file.test.ts +++ b/frontend/deterministic-tests/src/tests/create-rename-response-skips-file.test.ts @@ -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"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/delete-by-other-client-then-recreate.test.ts b/frontend/deterministic-tests/src/tests/delete-by-other-client-then-recreate.test.ts index 204e9896..dfef9961 100644 --- a/frontend/deterministic-tests/src/tests/delete-by-other-client-then-recreate.test.ts +++ b/frontend/deterministic-tests/src/tests/delete-by-other-client-then-recreate.test.ts @@ -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"); + } + } + ] }; diff --git a/frontend/deterministic-tests/src/tests/delete-during-pending-create.test.ts b/frontend/deterministic-tests/src/tests/delete-during-pending-create.test.ts index f6236060..3ba393b8 100644 --- a/frontend/deterministic-tests/src/tests/delete-during-pending-create.test.ts +++ b/frontend/deterministic-tests/src/tests/delete-during-pending-create.test.ts @@ -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"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/delete-recreate-concurrent-update.test.ts b/frontend/deterministic-tests/src/tests/delete-recreate-concurrent-update.test.ts index c95c6aa4..6cb4cb98 100644 --- a/frontend/deterministic-tests/src/tests/delete-recreate-concurrent-update.test.ts +++ b/frontend/deterministic-tests/src/tests/delete-recreate-concurrent-update.test.ts @@ -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"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/delete-recreate-different-content.test.ts b/frontend/deterministic-tests/src/tests/delete-recreate-different-content.test.ts index 02197b8d..782c3cd5 100644 --- a/frontend/deterministic-tests/src/tests/delete-recreate-different-content.test.ts +++ b/frontend/deterministic-tests/src/tests/delete-recreate-different-content.test.ts @@ -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" + ); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/delete-recreate-same-path.test.ts b/frontend/deterministic-tests/src/tests/delete-recreate-same-path.test.ts index 10b00f70..dde8d341 100644 --- a/frontend/deterministic-tests/src/tests/delete-recreate-same-path.test.ts +++ b/frontend/deterministic-tests/src/tests/delete-recreate-same-path.test.ts @@ -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"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/delete-recreated-pending-create-with-stale-deleting-record.test.ts b/frontend/deterministic-tests/src/tests/delete-recreated-pending-create-with-stale-deleting-record.test.ts new file mode 100644 index 00000000..80e95f48 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/delete-recreated-pending-create-with-stale-deleting-record.test.ts @@ -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); + } + } + ] + }; diff --git a/frontend/deterministic-tests/src/tests/delete-rename-conflict.test.ts b/frontend/deterministic-tests/src/tests/delete-rename-conflict.test.ts index 4cbeed25..91e6289b 100644 --- a/frontend/deterministic-tests/src/tests/delete-rename-conflict.test.ts +++ b/frontend/deterministic-tests/src/tests/delete-rename-conflict.test.ts @@ -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") + ); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/displaced-file-not-marked-deleted.test.ts b/frontend/deterministic-tests/src/tests/displaced-file-not-marked-deleted.test.ts index 99d5f716..cb995243 100644 --- a/frontend/deterministic-tests/src/tests/displaced-file-not-marked-deleted.test.ts +++ b/frontend/deterministic-tests/src/tests/displaced-file-not-marked-deleted.test.ts @@ -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"); } } ] diff --git a/frontend/deterministic-tests/src/tests/double-offline-cycle.test.ts b/frontend/deterministic-tests/src/tests/double-offline-cycle.test.ts index 1034ce27..744d862e 100644 --- a/frontend/deterministic-tests/src/tests/double-offline-cycle.test.ts +++ b/frontend/deterministic-tests/src/tests/double-offline-cycle.test.ts @@ -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"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/failed-vfs-move-falls-back.test.ts b/frontend/deterministic-tests/src/tests/failed-vfs-move-falls-back.test.ts deleted file mode 100644 index f9ae2a3f..00000000 --- a/frontend/deterministic-tests/src/tests/failed-vfs-move-falls-back.test.ts +++ /dev/null @@ -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") } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/idempotency-after-server-pause.test.ts b/frontend/deterministic-tests/src/tests/idempotency-after-server-pause.test.ts index ce12df0c..551c702d 100644 --- a/frontend/deterministic-tests/src/tests/idempotency-after-server-pause.test.ts +++ b/frontend/deterministic-tests/src/tests/idempotency-after-server-pause.test.ts @@ -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"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/interrupted-delete-retry.test.ts b/frontend/deterministic-tests/src/tests/interrupted-delete-retry.test.ts index ef8404fb..3ae7eda5 100644 --- a/frontend/deterministic-tests/src/tests/interrupted-delete-retry.test.ts +++ b/frontend/deterministic-tests/src/tests/interrupted-delete-retry.test.ts @@ -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); + } + } + ] }; diff --git a/frontend/deterministic-tests/src/tests/key-migration-event-drop.test.ts b/frontend/deterministic-tests/src/tests/key-migration-event-drop.test.ts index 9d9a870d..cc40e6b0 100644 --- a/frontend/deterministic-tests/src/tests/key-migration-event-drop.test.ts +++ b/frontend/deterministic-tests/src/tests/key-migration-event-drop.test.ts @@ -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"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/local-edit-lost-during-create-merge.test.ts b/frontend/deterministic-tests/src/tests/local-edit-lost-during-create-merge.test.ts index 66c832db..20925889 100644 --- a/frontend/deterministic-tests/src/tests/local-edit-lost-during-create-merge.test.ts +++ b/frontend/deterministic-tests/src/tests/local-edit-lost-during-create-merge.test.ts @@ -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" - ), + ); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/local-rename-survives-remote-rename.test.ts b/frontend/deterministic-tests/src/tests/local-rename-survives-remote-rename.test.ts new file mode 100644 index 00000000..c2b80af3 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/local-rename-survives-remote-rename.test.ts @@ -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); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/local-update-survives-remote-rename.test.ts b/frontend/deterministic-tests/src/tests/local-update-survives-remote-rename.test.ts new file mode 100644 index 00000000..0d8348c0 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/local-update-survives-remote-rename.test.ts @@ -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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/mc-cross-create-rename-same-target.test.ts b/frontend/deterministic-tests/src/tests/mc-cross-create-rename-same-target.test.ts index ce991df3..d986a733 100644 --- a/frontend/deterministic-tests/src/tests/mc-cross-create-rename-same-target.test.ts +++ b/frontend/deterministic-tests/src/tests/mc-cross-create-rename-same-target.test.ts @@ -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") diff --git a/frontend/deterministic-tests/src/tests/mc-delete-then-offline-rename.test.ts b/frontend/deterministic-tests/src/tests/mc-delete-then-offline-rename.test.ts index 98504f03..6727e99d 100644 --- a/frontend/deterministic-tests/src/tests/mc-delete-then-offline-rename.test.ts +++ b/frontend/deterministic-tests/src/tests/mc-delete-then-offline-rename.test.ts @@ -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") + ); } } ] diff --git a/frontend/deterministic-tests/src/tests/mc-multi-delete-offline-rename.test.ts b/frontend/deterministic-tests/src/tests/mc-multi-delete-offline-rename.test.ts index 26a095d5..8db90aab 100644 --- a/frontend/deterministic-tests/src/tests/mc-multi-delete-offline-rename.test.ts +++ b/frontend/deterministic-tests/src/tests/mc-multi-delete-offline-rename.test.ts @@ -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") + ); } } ] diff --git a/frontend/deterministic-tests/src/tests/mc-three-client-rename-offline-update.test.ts b/frontend/deterministic-tests/src/tests/mc-three-client-rename-offline-update.test.ts index 8144bbb5..4167b925 100644 --- a/frontend/deterministic-tests/src/tests/mc-three-client-rename-offline-update.test.ts +++ b/frontend/deterministic-tests/src/tests/mc-three-client-rename-offline-update.test.ts @@ -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"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/merging-update-response-survives-user-rename.test.ts b/frontend/deterministic-tests/src/tests/merging-update-response-survives-user-rename.test.ts new file mode 100644 index 00000000..e93240f9 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/merging-update-response-survives-user-rename.test.ts @@ -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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/migrate-key-preserves-existing.test.ts b/frontend/deterministic-tests/src/tests/migrate-key-preserves-existing.test.ts index a4f6d3d3..bb669e45 100644 --- a/frontend/deterministic-tests/src/tests/migrate-key-preserves-existing.test.ts +++ b/frontend/deterministic-tests/src/tests/migrate-key-preserves-existing.test.ts @@ -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" + ); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/move-and-concurrent-remote-update.test.ts b/frontend/deterministic-tests/src/tests/move-and-concurrent-remote-update.test.ts index f590f5b4..86657f0f 100644 --- a/frontend/deterministic-tests/src/tests/move-and-concurrent-remote-update.test.ts +++ b/frontend/deterministic-tests/src/tests/move-and-concurrent-remote-update.test.ts @@ -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"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/move-preserves-remote-update.test.ts b/frontend/deterministic-tests/src/tests/move-preserves-remote-update.test.ts index 59bedbbe..13e27349 100644 --- a/frontend/deterministic-tests/src/tests/move-preserves-remote-update.test.ts +++ b/frontend/deterministic-tests/src/tests/move-preserves-remote-update.test.ts @@ -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}"` + ); } } - }, - ], + } + ] }; diff --git a/frontend/deterministic-tests/src/tests/move-remote-update-reverts-rename.test.ts b/frontend/deterministic-tests/src/tests/move-remote-update-reverts-rename.test.ts index 95fcfe26..433bf01b 100644 --- a/frontend/deterministic-tests/src/tests/move-remote-update-reverts-rename.test.ts +++ b/frontend/deterministic-tests/src/tests/move-remote-update-reverts-rename.test.ts @@ -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" + ); } } ] diff --git a/frontend/deterministic-tests/src/tests/move-then-delete-stale-path.test.ts b/frontend/deterministic-tests/src/tests/move-then-delete-stale-path.test.ts index 77814669..4f5feab5 100644 --- a/frontend/deterministic-tests/src/tests/move-then-delete-stale-path.test.ts +++ b/frontend/deterministic-tests/src/tests/move-then-delete-stale-path.test.ts @@ -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"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/multi-file-operations.test.ts b/frontend/deterministic-tests/src/tests/multi-file-operations.test.ts index 66efd778..a47f5a2a 100644 --- a/frontend/deterministic-tests/src/tests/multi-file-operations.test.ts +++ b/frontend/deterministic-tests/src/tests/multi-file-operations.test.ts @@ -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") + ); } } ] diff --git a/frontend/deterministic-tests/src/tests/offline-concurrent-renames.test.ts b/frontend/deterministic-tests/src/tests/offline-concurrent-renames.test.ts index 56ecc00d..6c946b9c 100644 --- a/frontend/deterministic-tests/src/tests/offline-concurrent-renames.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-concurrent-renames.test.ts @@ -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") ); } } diff --git a/frontend/deterministic-tests/src/tests/offline-create-same-path-binary-conflict.test.ts b/frontend/deterministic-tests/src/tests/offline-create-same-path-binary-conflict.test.ts index ca777563..cbd59a4a 100644 --- a/frontend/deterministic-tests/src/tests/offline-create-same-path-binary-conflict.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-create-same-path-binary-conflict.test.ts @@ -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" - ) + ); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/offline-delete-remote-rename.test.ts b/frontend/deterministic-tests/src/tests/offline-delete-remote-rename.test.ts index ed242b20..1e9ea8f7 100644 --- a/frontend/deterministic-tests/src/tests/offline-delete-remote-rename.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-delete-remote-rename.test.ts @@ -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" + ); } } ] diff --git a/frontend/deterministic-tests/src/tests/offline-delete-vs-remote-update.test.ts b/frontend/deterministic-tests/src/tests/offline-delete-vs-remote-update.test.ts index d86e3066..21e81aa6 100644 --- a/frontend/deterministic-tests/src/tests/offline-delete-vs-remote-update.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-delete-vs-remote-update.test.ts @@ -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); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/offline-edit-remote-rename.test.ts b/frontend/deterministic-tests/src/tests/offline-edit-remote-rename.test.ts index fc4383e4..ffc41b89 100644 --- a/frontend/deterministic-tests/src/tests/offline-edit-remote-rename.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-edit-remote-rename.test.ts @@ -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"); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/offline-edit-then-move-same-content.test.ts b/frontend/deterministic-tests/src/tests/offline-edit-then-move-same-content.test.ts index 77d50099..970eabd3 100644 --- a/frontend/deterministic-tests/src/tests/offline-edit-then-move-same-content.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-edit-then-move-same-content.test.ts @@ -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); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/offline-mixed-operations.test.ts b/frontend/deterministic-tests/src/tests/offline-mixed-operations.test.ts index 68453a0e..da875b6e 100644 --- a/frontend/deterministic-tests/src/tests/offline-mixed-operations.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-mixed-operations.test.ts @@ -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); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/offline-move-then-remote-delete.test.ts b/frontend/deterministic-tests/src/tests/offline-move-then-remote-delete.test.ts index d1522528..f8e92bd9 100644 --- a/frontend/deterministic-tests/src/tests/offline-move-then-remote-delete.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-move-then-remote-delete.test.ts @@ -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); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/offline-multiple-edits.test.ts b/frontend/deterministic-tests/src/tests/offline-multiple-edits.test.ts index e242223a..6341fe8f 100644 --- a/frontend/deterministic-tests/src/tests/offline-multiple-edits.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-multiple-edits.test.ts @@ -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"); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/offline-rename-and-edit.test.ts b/frontend/deterministic-tests/src/tests/offline-rename-and-edit.test.ts index c446d459..836c7fb2 100644 --- a/frontend/deterministic-tests/src/tests/offline-rename-and-edit.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-rename-and-edit.test.ts @@ -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"); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/offline-rename-remote-create-old-path.test.ts b/frontend/deterministic-tests/src/tests/offline-rename-remote-create-old-path.test.ts index 24f4ff2a..c1b2913a 100644 --- a/frontend/deterministic-tests/src/tests/offline-rename-remote-create-old-path.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-rename-remote-create-old-path.test.ts @@ -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" + ); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/offline-update-both-then-delete-one.test.ts b/frontend/deterministic-tests/src/tests/offline-update-both-then-delete-one.test.ts index 47a88328..3442cda7 100644 --- a/frontend/deterministic-tests/src/tests/offline-update-both-then-delete-one.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-update-both-then-delete-one.test.ts @@ -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"); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/online-both-create-same-path-deconflict.test.ts b/frontend/deterministic-tests/src/tests/online-both-create-same-path-deconflict.test.ts index 1639ed90..b951b0be 100644 --- a/frontend/deterministic-tests/src/tests/online-both-create-same-path-deconflict.test.ts +++ b/frontend/deterministic-tests/src/tests/online-both-create-same-path-deconflict.test.ts @@ -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 "); diff --git a/frontend/deterministic-tests/src/tests/online-create-rename-concurrent-create-orphan.test.ts b/frontend/deterministic-tests/src/tests/online-create-rename-concurrent-create-orphan.test.ts index 3449e676..f86b3347 100644 --- a/frontend/deterministic-tests/src/tests/online-create-rename-concurrent-create-orphan.test.ts +++ b/frontend/deterministic-tests/src/tests/online-create-rename-concurrent-create-orphan.test.ts @@ -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); } } diff --git a/frontend/deterministic-tests/src/tests/online-create-update-while-other-creates-same-path.test.ts b/frontend/deterministic-tests/src/tests/online-create-update-while-other-creates-same-path.test.ts index f59a92e3..e0ddc21a 100644 --- a/frontend/deterministic-tests/src/tests/online-create-update-while-other-creates-same-path.test.ts +++ b/frontend/deterministic-tests/src/tests/online-create-update-while-other-creates-same-path.test.ts @@ -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"); } } ] diff --git a/frontend/deterministic-tests/src/tests/online-delete-recreate-rapid-cycle.test.ts b/frontend/deterministic-tests/src/tests/online-delete-recreate-rapid-cycle.test.ts index b575aa58..de5d6c89 100644 --- a/frontend/deterministic-tests/src/tests/online-delete-recreate-rapid-cycle.test.ts +++ b/frontend/deterministic-tests/src/tests/online-delete-recreate-rapid-cycle.test.ts @@ -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"); + } + } + ] }; diff --git a/frontend/deterministic-tests/src/tests/online-edit-vs-delete-convergence.test.ts b/frontend/deterministic-tests/src/tests/online-edit-vs-delete-convergence.test.ts index 16ed7236..d3a9d84e 100644 --- a/frontend/deterministic-tests/src/tests/online-edit-vs-delete-convergence.test.ts +++ b/frontend/deterministic-tests/src/tests/online-edit-vs-delete-convergence.test.ts @@ -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); } - }, - ], + } + ] }; diff --git a/frontend/deterministic-tests/src/tests/overlapping-edits-same-section.test.ts b/frontend/deterministic-tests/src/tests/overlapping-edits-same-section.test.ts index eeb705de..a93a6f69 100644 --- a/frontend/deterministic-tests/src/tests/overlapping-edits-same-section.test.ts +++ b/frontend/deterministic-tests/src/tests/overlapping-edits-same-section.test.ts @@ -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" + ); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/queue-reset-loses-coalesced-local-edit.test.ts b/frontend/deterministic-tests/src/tests/queue-reset-loses-coalesced-local-edit.test.ts index ecf58d05..6d89acf4 100644 --- a/frontend/deterministic-tests/src/tests/queue-reset-loses-coalesced-local-edit.test.ts +++ b/frontend/deterministic-tests/src/tests/queue-reset-loses-coalesced-local-edit.test.ts @@ -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" + ); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/queued-create-delete-does-not-hijack-reused-path.test.ts b/frontend/deterministic-tests/src/tests/queued-create-delete-does-not-hijack-reused-path.test.ts new file mode 100644 index 00000000..a29f8314 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/queued-create-delete-does-not-hijack-reused-path.test.ts @@ -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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rapid-create-update-delete-cycle.test.ts b/frontend/deterministic-tests/src/tests/rapid-create-update-delete-cycle.test.ts index 45f90144..f9c58753 100644 --- a/frontend/deterministic-tests/src/tests/rapid-create-update-delete-cycle.test.ts +++ b/frontend/deterministic-tests/src/tests/rapid-create-update-delete-cycle.test.ts @@ -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" + ); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/rapid-edit-delete-online-convergence.test.ts b/frontend/deterministic-tests/src/tests/rapid-edit-delete-online-convergence.test.ts index 042942b3..48c062e0 100644 --- a/frontend/deterministic-tests/src/tests/rapid-edit-delete-online-convergence.test.ts +++ b/frontend/deterministic-tests/src/tests/rapid-edit-delete-online-convergence.test.ts @@ -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}"` ); } } } - }, - }, - ], + } + } + ] }; diff --git a/frontend/deterministic-tests/src/tests/rapid-updates-after-merge.test.ts b/frontend/deterministic-tests/src/tests/rapid-updates-after-merge.test.ts index bf0ed488..6f97ff05 100644 --- a/frontend/deterministic-tests/src/tests/rapid-updates-after-merge.test.ts +++ b/frontend/deterministic-tests/src/tests/rapid-updates-after-merge.test.ts @@ -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"); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/recently-deleted-cleared-on-reconnect.test.ts b/frontend/deterministic-tests/src/tests/recently-deleted-cleared-on-reconnect.test.ts index 128cd90e..c8e70243 100644 --- a/frontend/deterministic-tests/src/tests/recently-deleted-cleared-on-reconnect.test.ts +++ b/frontend/deterministic-tests/src/tests/recently-deleted-cleared-on-reconnect.test.ts @@ -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" + ); + } + } + ] }; diff --git a/frontend/deterministic-tests/src/tests/remote-quick-write-rename-before-record.test.ts b/frontend/deterministic-tests/src/tests/remote-quick-write-rename-before-record.test.ts new file mode 100644 index 00000000..ca184b27 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/remote-quick-write-rename-before-record.test.ts @@ -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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/remote-rename-collides-with-pending-local-create.test.ts b/frontend/deterministic-tests/src/tests/remote-rename-collides-with-pending-local-create.test.ts new file mode 100644 index 00000000..d30fdc67 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/remote-rename-collides-with-pending-local-create.test.ts @@ -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`). + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/remote-update-resurrects-deleted-doc.test.ts b/frontend/deterministic-tests/src/tests/remote-update-resurrects-deleted-doc.test.ts new file mode 100644 index 00000000..eb2ed86d --- /dev/null +++ b/frontend/deterministic-tests/src/tests/remote-update-resurrects-deleted-doc.test.ts @@ -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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/remote-update-survives-user-rename.test.ts b/frontend/deterministic-tests/src/tests/remote-update-survives-user-rename.test.ts new file mode 100644 index 00000000..b78ad143 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/remote-update-survives-user-rename.test.ts @@ -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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rename-chain-during-pending-create.test.ts b/frontend/deterministic-tests/src/tests/rename-chain-during-pending-create.test.ts new file mode 100644 index 00000000..822e83df --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rename-chain-during-pending-create.test.ts @@ -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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rename-chain-then-delete.test.ts b/frontend/deterministic-tests/src/tests/rename-chain-then-delete.test.ts index 27787e4f..03196919 100644 --- a/frontend/deterministic-tests/src/tests/rename-chain-then-delete.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-chain-then-delete.test.ts @@ -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); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/rename-chain.test.ts b/frontend/deterministic-tests/src/tests/rename-chain.test.ts index 8cc3bde3..8f9d7a7f 100644 --- a/frontend/deterministic-tests/src/tests/rename-chain.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-chain.test.ts @@ -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"); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/rename-circular.test.ts b/frontend/deterministic-tests/src/tests/rename-circular.test.ts index 5c85ca71..44a65149 100644 --- a/frontend/deterministic-tests/src/tests/rename-circular.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-circular.test.ts @@ -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"); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts b/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts index c29b1dc5..fc6a00a7 100644 --- a/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts @@ -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"); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/rename-overwrites-pending-create-then-delete.test.ts b/frontend/deterministic-tests/src/tests/rename-overwrites-pending-create-then-delete.test.ts new file mode 100644 index 00000000..0b47c781 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rename-overwrites-pending-create-then-delete.test.ts @@ -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); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rename-pending-create-before-response.test.ts b/frontend/deterministic-tests/src/tests/rename-pending-create-before-response.test.ts index d38a0392..26623c43 100644 --- a/frontend/deterministic-tests/src/tests/rename-pending-create-before-response.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-pending-create-before-response.test.ts @@ -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" + ); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/rename-pending-create-onto-pending-delete-path.test.ts b/frontend/deterministic-tests/src/tests/rename-pending-create-onto-pending-delete-path.test.ts new file mode 100644 index 00000000..0906f209 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rename-pending-create-onto-pending-delete-path.test.ts @@ -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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rename-roundtrip.test.ts b/frontend/deterministic-tests/src/tests/rename-roundtrip.test.ts index bdf043f4..0373debf 100644 --- a/frontend/deterministic-tests/src/tests/rename-roundtrip.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-roundtrip.test.ts @@ -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"); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/rename-swap.test.ts b/frontend/deterministic-tests/src/tests/rename-swap.test.ts index 18489f33..9910e8ef 100644 --- a/frontend/deterministic-tests/src/tests/rename-swap.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-swap.test.ts @@ -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"); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/rename-to-existing-path.test.ts b/frontend/deterministic-tests/src/tests/rename-to-existing-path.test.ts deleted file mode 100644 index b1d09c7f..00000000 --- a/frontend/deterministic-tests/src/tests/rename-to-existing-path.test.ts +++ /dev/null @@ -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"), - } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/rename-to-path-of-unconfirmed-delete.test.ts b/frontend/deterministic-tests/src/tests/rename-to-path-of-unconfirmed-delete.test.ts index b5745e3b..34a3867c 100644 --- a/frontend/deterministic-tests/src/tests/rename-to-path-of-unconfirmed-delete.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-to-path-of-unconfirmed-delete.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const renameToPathOfUnconfirmedDeleteTest: TestDefinition = { @@ -32,10 +33,12 @@ export const renameToPathOfUnconfirmedDeleteTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s - .assertFileNotExists("B.md") - .assertContains("A.md", "content B"), + verify: (s: AssertableState): void => { + s.assertFileNotExists("B.md").assertContains( + "A.md", + "content B" + ); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/rename-to-pending-path-fallback.test.ts b/frontend/deterministic-tests/src/tests/rename-to-pending-path-fallback.test.ts index a17f52d4..8747218a 100644 --- a/frontend/deterministic-tests/src/tests/rename-to-pending-path-fallback.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-to-pending-path-fallback.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const renameToPendingPathFallbackTest: TestDefinition = { @@ -5,26 +6,38 @@ export const renameToPendingPathFallbackTest: TestDefinition = { "Client 0 creates B.md and syncs. Goes offline, creates A.md, then renames B.md to A.md (overwriting the unsynced A). After reconnecting, B.md should be gone and A.md should have B's content.", clients: 2, steps: [ - { type: "create", client: 0, path: "B.md", content: "tracked B content" }, + { + type: "create", + client: 0, + path: "B.md", + content: "tracked B content" + }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "disable-sync", client: 0 }, - { type: "create", client: 0, path: "A.md", content: "pending A content" }, + { + type: "create", + client: 0, + path: "A.md", + content: "pending A content" + }, { type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" }, { type: "enable-sync", client: 0 }, - { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", - verify: (s) => - s.assertFileNotExists("B.md").assertContains("A.md", "tracked B content"), + verify: (s: AssertableState): void => { + s.assertFileNotExists("B.md").assertContains( + "A.md", + "tracked B content" + ); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/rename-to-recently-deleted-path.test.ts b/frontend/deterministic-tests/src/tests/rename-to-recently-deleted-path.test.ts deleted file mode 100644 index 754c0c18..00000000 --- a/frontend/deterministic-tests/src/tests/rename-to-recently-deleted-path.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { TestDefinition } from "../test-definition"; - -export const renameToRecentlyDeletedPathTest: TestDefinition = { - description: - "Client 0 deletes B.md. Client 1 renames A.md to B.md offline. After reconnecting, only B.md should exist 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: "disable-sync", client: 1 }, - - { type: "delete", client: 0, path: "B.md" }, - { type: "sync", client: 0 }, - - { - 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 - .assertFileCount(1) - .assertFileNotExists("A.md") - .assertContent("B.md", "content-a"), - } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/rename-update-conflict.test.ts b/frontend/deterministic-tests/src/tests/rename-update-conflict.test.ts index 099009fb..18d4c101 100644 --- a/frontend/deterministic-tests/src/tests/rename-update-conflict.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-update-conflict.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const renameUpdateConflictTest: TestDefinition = { @@ -8,11 +9,12 @@ export const renameUpdateConflictTest: 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: 1 }, @@ -20,16 +22,21 @@ export const renameUpdateConflictTest: TestDefinition = { { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, { type: "sync", client: 0 }, - { type: "update", client: 1, path: "A.md", content: "updated by client 1" }, + { + type: "update", + client: 1, + path: "A.md", + content: "updated by client 1" + }, { type: "enable-sync", client: 1 }, - { type: "sync", client: 1 }, { type: "barrier" }, { type: "assert-consistent", - verify: (s) => - s.assertFileNotExists("A.md").assertContains("B.md", "updated"), + verify: (s: AssertableState): void => { + s.assertFileNotExists("A.md").assertContains("B.md", "updated"); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/renamed-pending-create-reused-path-then-delete.test.ts b/frontend/deterministic-tests/src/tests/renamed-pending-create-reused-path-then-delete.test.ts new file mode 100644 index 00000000..3ffb376e --- /dev/null +++ b/frontend/deterministic-tests/src/tests/renamed-pending-create-reused-path-then-delete.test.ts @@ -0,0 +1,65 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const renamedPendingCreateReusedPathThenDeleteTest: TestDefinition = { + description: + "A queued create is renamed away from file-59.md, a newer local " + + "file reuses file-59.md before the queued create drains, and the " + + "renamed-away generation is deleted. The delete must not erase or " + + "orphan the newer file-59.md generation.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { 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-59.md", + content: "old\n" + }, + { + type: "rename", + client: 1, + oldPath: "file-59.md", + newPath: "file-33.md" + }, + { + type: "create", + client: 1, + path: "file-59.md", + content: "new\n" + }, + + { + type: "resume-server-until-history-then-pause", + client: 1, + syncType: "CREATE", + path: "file-33.md" + }, + { type: "delete", client: 1, path: "file-33.md" }, + { type: "resume-server" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileCount(2) + .assertContent("blocker.md", "blocker\n") + .assertContent("file-59.md", "new\n") + .assertFileNotExists("file-33.md"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/reset-clears-recently-deleted-resurrection.test.ts b/frontend/deterministic-tests/src/tests/reset-clears-recently-deleted-resurrection.test.ts index e7b001e2..e0a1565c 100644 --- a/frontend/deterministic-tests/src/tests/reset-clears-recently-deleted-resurrection.test.ts +++ b/frontend/deterministic-tests/src/tests/reset-clears-recently-deleted-resurrection.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const resetClearsRecentlyDeletedResurrectionTest: TestDefinition = { @@ -15,28 +16,28 @@ export const resetClearsRecentlyDeletedResurrectionTest: TestDefinition = { }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "delete", client: 0, path: "ghost.md" }, - { type: "sync", client: 0 }, - - { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", - verify: (s) => s.assertFileNotExists("ghost.md"), + verify: (s: AssertableState): void => { + s.assertFileNotExists("ghost.md"); + } }, { type: "disable-sync", client: 1 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, + { type: "barrier" }, { type: "assert-consistent", - verify: (s) => s.assertFileCount(0), + verify: (s: AssertableState): void => { + s.assertFileCount(0); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/same-doc-id-collapse-after-remote-quick-write-and-pending-rename.test.ts b/frontend/deterministic-tests/src/tests/same-doc-id-collapse-after-remote-quick-write-and-pending-rename.test.ts new file mode 100644 index 00000000..2a3b5de4 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/same-doc-id-collapse-after-remote-quick-write-and-pending-rename.test.ts @@ -0,0 +1,82 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const sameDocIdCollapseAfterRemoteQuickWriteAndPendingRenameTest: TestDefinition = + { + description: + "A remote create starts quick-writing at doc.md while a local " + + "create for the same path is queued and renamed to renamed.md. " + + "Because the local create was renamed before it reached the " + + "server, the two generations should remain separate tracked " + + "documents.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + + // Create a deleted latest version before client 1 joins. + // Catch-up will advance MinCovered with a non-contiguous id, + // keeping client 1's create lastSeen low enough to exercise + // the server's same-doc merge path from the e2e failure. + { + type: "create", + client: 0, + path: "history.md", + content: "history-v1" + }, + { type: "sync", client: 0 }, + { + type: "update", + client: 0, + path: "history.md", + content: "history-v2" + }, + { type: "sync", client: 0 }, + { type: "delete", client: 0, path: "history.md" }, + { type: "sync", client: 0 }, + + { type: "enable-sync", client: 1 }, + { type: "sync", client: 1 }, + + { type: "pause-websocket", client: 1 }, + + { + type: "create", + client: 0, + path: "doc.md", + content: "remote\n" + }, + { type: "sync", client: 0 }, + + // Let client 1's buffered RemoteCreate enter the quick-write + // path, but hold the content fetch until the local create has + // appeared and moved away from doc.md. + { type: "pause-server" }, + { type: "resume-websocket", client: 1 }, + { type: "sleep", ms: 100 }, + + { + type: "create", + client: 1, + path: "doc.md", + content: "local\n" + }, + { + type: "rename", + client: 1, + oldPath: "doc.md", + newPath: "renamed.md" + }, + + { type: "resume-server" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state.assertFileCount(2); + state.assertContent("doc.md", "remote\n"); + state.assertContent("renamed.md", "local\n"); + } + } + ] + }; diff --git a/frontend/deterministic-tests/src/tests/same-doc-id-collapse-on-local-create-after-remote-create.test.ts b/frontend/deterministic-tests/src/tests/same-doc-id-collapse-on-local-create-after-remote-create.test.ts new file mode 100644 index 00000000..dee3a9ad --- /dev/null +++ b/frontend/deterministic-tests/src/tests/same-doc-id-collapse-on-local-create-after-remote-create.test.ts @@ -0,0 +1,121 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const sameDocIdCollapseOnLocalCreateAfterRemoteCreateTest: TestDefinition = + { + description: + "Client B creates X with content C2; the server commits and " + + "broadcasts. Client A's WS is paused so the RemoteCreate buffers. " + + "Server is then paused so A's about-to-POST LocalCreate will " + + "hang. A creates X with content C1: file lands on disk, " + + "LocalCreate enqueues, drain starts the POST, the POST stalls " + + "at the paused server. A's WS is resumed: the buffered " + + "RemoteCreate for doc-X is delivered to A and enqueues behind " + + "the in-flight LocalCreate. Per the lazy-paths model, when " + + "the RemoteCreate is processed it observes that path X is " + + "occupied locally by A's pending-create bytes, so it tracks " + + "doc-X with `localPath = undefined` / `remoteRelativePath = " + + "X` and does NOT fetch content. The server is then resumed: " + + "A's LocalCreate POST returns. The server, finding X already " + + "taken by doc-X, replies with doc-X's existing documentId " + + "(typically a MergingUpdate carrying the merged bytes). A's " + + "processCreate handler detects that response.documentId " + + "matches the no-localPath record built from the RemoteCreate " + + "and collapses the two: it sets localPath = X on that " + + "record, writes the merged bytes, and resolves the pending " + + "create promise. Final state: exactly one file at X on both " + + "clients, both pointing at doc-X's documentId, content " + + "carrying both contributions, and no conflict-- " + + "stash anywhere.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + // Buffer broadcasts to client 0 (A) so client 1's create + // doesn't reach A's WS handler until we say so. + { type: "pause-websocket", client: 0 }, + + // Client 1 (B) commits doc-X at path X with content C2. + // The server commits, broadcasts (broadcast queued at A's + // paused WS). + { + type: "create", + client: 1, + path: "X.md", + content: "from-client-1 " + }, + { type: "sync", client: 1 }, + + // Pause the server so A's upcoming LocalCreate POST hangs. + // This holds A's drain on the in-flight POST while we + // release the WS so the RemoteCreate enqueues behind it. + { type: "pause-server" }, + + // Client 0 (A) creates X locally with content C1. The + // file lands on A's disk; LocalCreate enqueues; drain + // starts the POST; POST stalls at the paused server. + { + type: "create", + client: 0, + path: "X.md", + content: "from-client-0 " + }, + + // Release A's WS. The buffered RemoteCreate for doc-X is + // delivered to A and enqueues behind the in-flight + // LocalCreate. Whichever of (RemoteCreate processed first + // → no-localPath record, then LocalCreate POST returns + // with merging response that collapses) or (LocalCreate + // POST returns first with merging response that creates + // the canonical record, then RemoteCreate finds the doc + // already tracked by id and no-ops) actually plays out + // depends on the fine-grained interleaving the runtime + // produces, but both paths are required to converge to + // the same single-record same-docId state. + { type: "resume-websocket", client: 0 }, + + // Resume the server: A's LocalCreate POST completes. + // Server returns doc-X's existing documentId (MergingUpdate + // with merged content). processCreate runs the collapse + // path. + { type: "resume-server" }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state.assertFileCount(1); + state.assertFileExists("X.md"); + // Server-side merge of the two text creates must + // carry both contributions through to the + // converged file. + state.assertContains( + "X.md", + "from-client-0", + "from-client-1" + ); + // The lazy-paths collapse path must not leave a + // conflict-- stash on either client. + for (const path of state.files.keys()) { + if (path.startsWith("conflict-")) { + throw new Error( + `Unexpected conflict-uuid stash on a converged client: ${path}` + ); + } + } + for (const perClient of state.clientFiles) { + for (const path of perClient.keys()) { + if (path.startsWith("conflict-")) { + throw new Error( + `Unexpected conflict-uuid stash on a per-client view: ${path}` + ); + } + } + } + } + } + ] + }; diff --git a/frontend/deterministic-tests/src/tests/self-merge-pending-rename-aliases-second-create.test.ts b/frontend/deterministic-tests/src/tests/self-merge-pending-rename-aliases-second-create.test.ts new file mode 100644 index 00000000..ac8ed3ed --- /dev/null +++ b/frontend/deterministic-tests/src/tests/self-merge-pending-rename-aliases-second-create.test.ts @@ -0,0 +1,152 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const selfMergePendingRenameAliasesSecondCreateTest: TestDefinition = { + description: + "Single client makes two distinct creates that briefly share a path. " + + "Client 0 POSTs the first create at primary.md while the server is " + + "paused. While that POST is in flight: a second create is queued at " + + "staging.md, primary.md is renamed to moved.md (rewriting the in- " + + "flight create's event.path to moved.md and pushing a rename " + + "LocalUpdate at the queue tail), and staging.md is renamed onto the " + + "now-vacated primary.md slot (rewriting the second create's " + + "event.path to primary.md and pushing another rename LocalUpdate). " + + "Client 0's WS is paused throughout, so its watermark stays at 0. " + + "On resume the first POST commits Doc-X at primary.md (creation_vuid " + + "= N). The drain then processes the second LocalCreate (POST " + + "relativePath=primary.md, last_seen=0); the server's path-based " + + "dedup sees N > 0 and merges the second create into Doc-X " + + "(MergingUpdate). The buggy behaviour: processCreate's resolveCreate " + + "calls upsertRecord with localPath=primary.md, but the existing " + + "record (from the first create) already holds localPath=moved.md, " + + "and upsertRecord's `existing.localPath !== undefined` guard " + + "silently drops the new claim. The file at primary.md is left " + + "orphaned: tracked by no record, never broadcast, never deleted. " + + "After the user's renames the expected user-visible state is two " + + "distinct files at moved.md and primary.md — both clients must " + + "converge to that.", + clients: 2, + steps: [ + // Both clients online so the WS connection is established before + // the test starts pausing things. + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + // Pause client 0's WS so its MinCovered watermark stays at 0 + // through the whole bug sequence. The merge condition the + // server is going to fire is `creation_vuid > last_seen`; with + // a non-zero gap the same-device second create gets merged + // into the same-device first create. + { type: "pause-websocket", client: 0 }, + + // Client 1 commits a doc to push the server's vuid above 0. + // Without this filler, Doc-X's create vuid could be 1 and + // client 0's last_seen.add(1) would advance min to 1, killing + // the watermark gap that triggers the merge. + { + type: "create", + client: 1, + path: "filler.md", + content: "filler-content " + }, + { type: "sync", client: 1 }, + + // Pause the server so client 0's first create POST hangs in + // flight, giving us a deterministic window in which to enqueue + // the second create and the renames. + { type: "pause-server" }, + + // First create — Doc-X. The wire-loop drains it, captures + // requestPath = event.path = "primary.md", reads the bytes, + // sends the POST, and stalls on the response. + { + type: "create", + client: 0, + path: "primary.md", + content: "primary content " + }, + + // Make sure the POST is actually on the wire with + // relativePath="primary.md" before we rewrite event.path. + // Without this delay the rename can win the race, the POST + // goes out with relativePath="moved.md", and the server-side + // path-collision merge never fires. + { type: "sleep", ms: 100 }, + + // Second create at a staging path. The wire-loop is still + // blocked on Doc-X's POST, so this LocalCreate just queues at + // index 1. + { + type: "create", + client: 0, + path: "staging.md", + content: "secondary content " + }, + + // Rename Doc-X's path. enqueue's pending-create branch + // rewrites Doc-X's event.path in place (moved.md) and pushes + // a LocalUpdate(rename, originalPath=moved.md) at the END of + // the queue. Note the ordering: this LocalUpdate is enqueued + // AFTER the staging LocalCreate above. That ordering is + // load-bearing — it is what makes the second create's POST + // drain (and trigger the server-side merge) before Doc-X's + // rename PUT moves the doc away from primary.md on the + // server. + { + type: "rename", + client: 0, + oldPath: "primary.md", + newPath: "moved.md" + }, + + // Rename the staging file onto Doc-X's now-vacated primary.md + // slot. enqueue rewrites the staging LocalCreate's event.path + // to primary.md and pushes a LocalUpdate(rename, + // originalPath=primary.md) at the queue tail. After this the + // disk has: moved.md = Doc-X's bytes, primary.md = Doc-Y's + // bytes. + { + type: "rename", + client: 0, + oldPath: "staging.md", + newPath: "primary.md" + }, + + // Let everything fly: server processes the queued POSTs; + // client 0 catches up on broadcasts. + { type: "resume-server" }, + { type: "resume-websocket", client: 0 }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + // The user did two distinct creates (Doc-X and Doc-Y); + // both contents must survive on both clients. + state.assertFileCount(3); + state.assertFileExists("filler.md"); + state.assertFileExists("moved.md"); + state.assertFileExists("primary.md"); + + // After the renames the user expects: + // - moved.md = the file that was originally created + // at primary.md (Doc-X's content). + // - primary.md = the file that was originally created + // at staging.md (Doc-Y's content). + state.assertContains("moved.md", "primary content"); + state.assertContains("primary.md", "secondary content"); + + // No content cross-contamination: each contribution + // should land in exactly one of the user-visible + // files. Under the bug, the orphan at primary.md + // carries Doc-X's content (because Doc-Y's PUT was + // aliased onto Doc-X's record and read Doc-X's bytes + // from moved.md), so this catches the leak too. + state.assertContentInAtMostOneFile("primary content"); + state.assertContentInAtMostOneFile("secondary content"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/sequential-create-duplicate-content.test.ts b/frontend/deterministic-tests/src/tests/sequential-create-duplicate-content.test.ts index 968166a9..611e1ae3 100644 --- a/frontend/deterministic-tests/src/tests/sequential-create-duplicate-content.test.ts +++ b/frontend/deterministic-tests/src/tests/sequential-create-duplicate-content.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const sequentialCreateDuplicateContentTest: TestDefinition = { @@ -5,28 +6,38 @@ export const sequentialCreateDuplicateContentTest: TestDefinition = { "Client 0 creates A.md, syncs, then creates B.md with identical content. Both files must remain as separate documents on both clients.", clients: 2, steps: [ - { type: "create", client: 0, path: "A.md", content: "identical content here" }, + { + type: "create", + client: 0, + path: "A.md", + content: "identical content here" + }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", - verify: (s) => s.assertContent("A.md", "identical content here"), + verify: (s: AssertableState): void => { + s.assertContent("A.md", "identical content here"); + } }, - { type: "create", client: 0, path: "B.md", content: "identical content here" }, - { type: "sync" }, + { + type: "create", + client: 0, + path: "B.md", + content: "identical content here" + }, { type: "barrier" }, { type: "assert-consistent", - verify: (s) => - s - .assertFileCount(2) + verify: (s: AssertableState): void => { + s.assertFileCount(2) .assertContent("A.md", "identical content here") - .assertContent("B.md", "identical content here"), + .assertContent("B.md", "identical content here"); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/server-pause-both-clients-create.test.ts b/frontend/deterministic-tests/src/tests/server-pause-both-clients-create.test.ts index fea4adad..f99cf92d 100644 --- a/frontend/deterministic-tests/src/tests/server-pause-both-clients-create.test.ts +++ b/frontend/deterministic-tests/src/tests/server-pause-both-clients-create.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const serverPauseBothClientsCreateTest: TestDefinition = { @@ -7,7 +8,6 @@ export const serverPauseBothClientsCreateTest: TestDefinition = { steps: [ { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { @@ -27,15 +27,16 @@ export const serverPauseBothClientsCreateTest: TestDefinition = { { type: "resume-server" }, - { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", - verify: (s) => - s - .assertContains("alpha.md", "from client 0") - .assertContains("beta.md", "from client 1"), + verify: (s: AssertableState): void => { + s.assertContains("alpha.md", "from client 0").assertContains( + "beta.md", + "from client 1" + ); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/server-pause-both-edit-same-file.test.ts b/frontend/deterministic-tests/src/tests/server-pause-both-edit-same-file.test.ts index 394a531a..ff8cf194 100644 --- a/frontend/deterministic-tests/src/tests/server-pause-both-edit-same-file.test.ts +++ b/frontend/deterministic-tests/src/tests/server-pause-both-edit-same-file.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const serverPauseBothEditSameFileTest: TestDefinition = { @@ -13,7 +14,6 @@ export const serverPauseBothEditSameFileTest: TestDefinition = { }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "pause-server" }, @@ -34,15 +34,17 @@ export const serverPauseBothEditSameFileTest: TestDefinition = { }, { type: "resume-server" }, - { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", - verify: (s) => - s - .assertFileCount(1) - .assertContains("shared.md", "edited by client 0", "edited by client 1"), + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContains( + "shared.md", + "edited by client 0", + "edited by client 1" + ); + } }, { @@ -51,13 +53,16 @@ export const serverPauseBothEditSameFileTest: TestDefinition = { path: "shared.md", content: "post-merge edit from client 0" }, - { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", - verify: (s) => - s.assertFileCount(1).assertContains("shared.md", "post-merge edit from client 0"), + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContains( + "shared.md", + "post-merge edit from client 0" + ); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/server-pause-delete-recreate.test.ts b/frontend/deterministic-tests/src/tests/server-pause-delete-recreate.test.ts index 920259e1..5ac97f0d 100644 --- a/frontend/deterministic-tests/src/tests/server-pause-delete-recreate.test.ts +++ b/frontend/deterministic-tests/src/tests/server-pause-delete-recreate.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const serverPauseDeleteRecreateTest: TestDefinition = { @@ -15,18 +16,23 @@ export const serverPauseDeleteRecreateTest: TestDefinition = { { type: "pause-server" }, - { type: "create", client: 0, path: "A.md", content: "recreated during contention" }, + { + type: "create", + client: 0, + path: "A.md", + content: "recreated during contention" + }, { type: "resume-server" }, { type: "barrier" }, { type: "assert-consistent", - verify: (state) => { + verify: (state: AssertableState): void => { state .assertFileCount(1) .assertContent("A.md", "recreated during contention"); } - }, - ], + } + ] }; diff --git a/frontend/deterministic-tests/src/tests/server-pause-rename-edit-resume.test.ts b/frontend/deterministic-tests/src/tests/server-pause-rename-edit-resume.test.ts index c2d6772e..b1739135 100644 --- a/frontend/deterministic-tests/src/tests/server-pause-rename-edit-resume.test.ts +++ b/frontend/deterministic-tests/src/tests/server-pause-rename-edit-resume.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const serverPauseRenameEditResumeTest: TestDefinition = { @@ -15,11 +16,12 @@ export const serverPauseRenameEditResumeTest: TestDefinition = { path: "A.md", content: "original content" }, - { 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: "pause-server" }, @@ -34,16 +36,15 @@ export const serverPauseRenameEditResumeTest: TestDefinition = { { type: "resume-server" }, - { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", - verify: (s) => - s - .assertFileCount(1) + verify: (s: AssertableState): void => { + s.assertFileCount(1) .assertFileNotExists("A.md") - .assertContent("B.md", "edited after rename during pause"), + .assertContent("B.md", "edited after rename during pause"); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/server-pause-update-and-create.test.ts b/frontend/deterministic-tests/src/tests/server-pause-update-and-create.test.ts index 3523cf79..2389ccf5 100644 --- a/frontend/deterministic-tests/src/tests/server-pause-update-and-create.test.ts +++ b/frontend/deterministic-tests/src/tests/server-pause-update-and-create.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const serverPauseUpdateAndCreateTest: TestDefinition = { @@ -13,11 +14,12 @@ export const serverPauseUpdateAndCreateTest: TestDefinition = { path: "shared.md", content: "initial content" }, - { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", - verify: (s) => s.assertContent("shared.md", "initial content"), + verify: (s: AssertableState): void => { + s.assertContent("shared.md", "initial content"); + } }, { type: "pause-server" }, @@ -37,15 +39,16 @@ export const serverPauseUpdateAndCreateTest: TestDefinition = { { type: "resume-server" }, - { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", - verify: (s) => - s - .assertContent("shared.md", "updated during pause") - .assertContent("new-file.md", "created by client 1"), + verify: (s: AssertableState): void => { + s.assertContent( + "shared.md", + "updated during pause" + ).assertContent("new-file.md", "created by client 1"); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/simultaneous-create-delete-same-path.test.ts b/frontend/deterministic-tests/src/tests/simultaneous-create-delete-same-path.test.ts index 2e74b3a5..7ec116ac 100644 --- a/frontend/deterministic-tests/src/tests/simultaneous-create-delete-same-path.test.ts +++ b/frontend/deterministic-tests/src/tests/simultaneous-create-delete-same-path.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const simultaneousCreateDeleteSamePathTest: TestDefinition = { @@ -10,7 +11,6 @@ export const simultaneousCreateDeleteSamePathTest: TestDefinition = { { type: "create", client: 0, path: "A.md", content: "original from 0" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "disable-sync", client: 1 }, @@ -18,22 +18,21 @@ export const simultaneousCreateDeleteSamePathTest: TestDefinition = { { type: "delete", client: 0, path: "A.md" }, { type: "sync", client: 0 }, - { type: "update", client: 1, path: "A.md", content: "modified by 1 while offline" }, + { + type: "update", + client: 1, + path: "A.md", + content: "modified by 1 while offline" + }, { type: "enable-sync", client: 1 }, - { type: "sync", client: 1 }, { type: "barrier" }, { type: "assert-consistent", - verify: (s) => { - s.ifFileExists("A.md", (s) => - s.assertFileCount(1).assertContent("A.md", "modified by 1 while offline") - ); - if (!s.files.has("A.md")) { - s.assertFileCount(0); - } - }, + verify: (s: AssertableState): void => { + s.assertFileCount(0); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/three-client-rename-create-delete.test.ts b/frontend/deterministic-tests/src/tests/three-client-rename-create-delete.test.ts index 174bcdc4..80478adc 100644 --- a/frontend/deterministic-tests/src/tests/three-client-rename-create-delete.test.ts +++ b/frontend/deterministic-tests/src/tests/three-client-rename-create-delete.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const threeClientRenameCreateDeleteTest: TestDefinition = { @@ -44,10 +45,11 @@ export const threeClientRenameCreateDeleteTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s - .assertFileNotExists("X.md") - .assertAnyFileContains("new from C"), + verify: (s: AssertableState): void => { + s.assertFileNotExists("X.md").assertAnyFileContains( + "new from C" + ); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/update-during-create-processing.test.ts b/frontend/deterministic-tests/src/tests/update-during-create-processing.test.ts index 43536bed..ca53244e 100644 --- a/frontend/deterministic-tests/src/tests/update-during-create-processing.test.ts +++ b/frontend/deterministic-tests/src/tests/update-during-create-processing.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const updateDuringCreateProcessingTest: TestDefinition = { @@ -7,7 +8,6 @@ export const updateDuringCreateProcessingTest: TestDefinition = { steps: [ { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "pause-server" }, @@ -27,13 +27,16 @@ export const updateDuringCreateProcessingTest: TestDefinition = { }, { type: "resume-server" }, - { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", - verify: (s) => - s.assertFileCount(1).assertContent("file.md", "updated during create"), + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent( + "file.md", + "updated during create" + ); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/update-survives-remote-delete.test.ts b/frontend/deterministic-tests/src/tests/update-survives-remote-delete.test.ts index 09ec9427..70a2fc8c 100644 --- a/frontend/deterministic-tests/src/tests/update-survives-remote-delete.test.ts +++ b/frontend/deterministic-tests/src/tests/update-survives-remote-delete.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const updateDoesNotSurvivesRemoteDeleteTest: TestDefinition = { @@ -14,7 +15,12 @@ export const updateDoesNotSurvivesRemoteDeleteTest: TestDefinition = { { type: "disable-sync", client: 1 }, { type: "delete", client: 0, path: "doc.md" }, - { type: "update", client: 1, path: "doc.md", content: "edited by client 1" }, + { + type: "update", + client: 1, + path: "doc.md", + content: "edited by client 1" + }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, @@ -22,8 +28,9 @@ export const updateDoesNotSurvivesRemoteDeleteTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s.assertFileCount(0) - }, - ], + verify: (s: AssertableState): void => { + s.assertFileCount(0); + } + } + ] }; diff --git a/frontend/deterministic-tests/src/tests/watermark-advances-on-skip.test.ts b/frontend/deterministic-tests/src/tests/watermark-advances-on-skip.test.ts index 202bd437..063faff4 100644 --- a/frontend/deterministic-tests/src/tests/watermark-advances-on-skip.test.ts +++ b/frontend/deterministic-tests/src/tests/watermark-advances-on-skip.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const watermarkAdvancesOnSkipTest: TestDefinition = { @@ -7,7 +8,6 @@ export const watermarkAdvancesOnSkipTest: TestDefinition = { steps: [ { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "disable-sync", client: 0 }, @@ -17,19 +17,19 @@ export const watermarkAdvancesOnSkipTest: TestDefinition = { { 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: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", - verify: (s) => s.assertFileCount(1).assertFileExists("doc.md"), - }, - ], + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertFileExists("doc.md"); + } + } + ] }; diff --git a/frontend/deterministic-tests/src/tests/watermark-gap-remote-update-not-recorded.test.ts b/frontend/deterministic-tests/src/tests/watermark-gap-remote-update-not-recorded.test.ts index 0f5ade3d..ac9ba467 100644 --- a/frontend/deterministic-tests/src/tests/watermark-gap-remote-update-not-recorded.test.ts +++ b/frontend/deterministic-tests/src/tests/watermark-gap-remote-update-not-recorded.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const watermarkGapRemoteUpdateNotRecordedTest: TestDefinition = { @@ -8,31 +9,29 @@ export const watermarkGapRemoteUpdateNotRecordedTest: 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: "update", client: 0, path: "doc.md", content: "update 1" }, { type: "sync", client: 0 }, { type: "update", client: 0, path: "doc.md", content: "update 2" }, - { type: "sync", client: 0 }, - { type: "sync", client: 1 }, { type: "barrier" }, { type: "assert-consistent", - verify: (s) => - s.assertFileCount(1).assertContent("doc.md", "update 2"), + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent("doc.md", "update 2"); + } }, { type: "disable-sync", client: 1 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", - verify: (s) => - s.assertFileCount(1).assertContent("doc.md", "update 2"), + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent("doc.md", "update 2"); + } } ] }; diff --git a/frontend/deterministic-tests/src/utils/assertable-state.ts b/frontend/deterministic-tests/src/utils/assertable-state.ts index 05414342..7c6f192c 100644 --- a/frontend/deterministic-tests/src/utils/assertable-state.ts +++ b/frontend/deterministic-tests/src/utils/assertable-state.ts @@ -1,15 +1,15 @@ import type { ClientState } from "../test-definition"; export class AssertableState { - readonly files: Map; - readonly clientFiles: Map[]; + public readonly files: Map; + public readonly clientFiles: Map[]; - constructor(state: ClientState) { + public constructor(state: ClientState) { this.files = state.files; this.clientFiles = state.clientFiles; } - assertFileCount(expected: number): this { + public assertFileCount(expected: number): this { if (this.files.size !== expected) { const keys = Array.from(this.files.keys()).join(", "); throw new Error( @@ -19,17 +19,15 @@ export class AssertableState { return this; } - assertFileExists(path: string): this { + public assertFileExists(path: string): this { if (!this.files.has(path)) { const keys = Array.from(this.files.keys()).join(", "); - throw new Error( - `Expected "${path}" to exist. Files: [${keys}]` - ); + throw new Error(`Expected "${path}" to exist. Files: [${keys}]`); } return this; } - assertFileNotExists(path: string): this { + public assertFileNotExists(path: string): this { if (this.files.has(path)) { const keys = Array.from(this.files.keys()).join(", "); throw new Error( @@ -39,7 +37,7 @@ export class AssertableState { return this; } - assertContent(path: string, expected: string): this { + public assertContent(path: string, expected: string): this { this.assertFileExists(path); const actual = this.files.get(path) ?? ""; if (actual !== expected) { @@ -50,7 +48,7 @@ export class AssertableState { return this; } - assertContains(path: string, ...substrings: string[]): this { + public assertContains(path: string, ...substrings: string[]): this { this.assertFileExists(path); const content = this.files.get(path) ?? ""; const missing = substrings.filter((s) => !content.includes(s)); @@ -62,7 +60,7 @@ export class AssertableState { return this; } - assertContainsAny(path: string, ...substrings: string[]): this { + public assertContainsAny(path: string, ...substrings: string[]): this { this.assertFileExists(path); const content = this.files.get(path) ?? ""; const found = substrings.some((s) => content.includes(s)); @@ -74,7 +72,7 @@ export class AssertableState { return this; } - assertAnyFileContains(...substrings: string[]): this { + public assertAnyFileContains(...substrings: string[]): this { const allContent = Array.from(this.files.values()).join("\n"); const missing = substrings.filter((s) => !allContent.includes(s)); if (missing.length > 0) { @@ -88,7 +86,27 @@ export class AssertableState { return this; } - assertSubstringCount( + public assertNoFileContains(...substrings: string[]): this { + const offenders: { path: string; substring: string }[] = []; + for (const [path, content] of this.files) { + for (const s of substrings) { + if (content.includes(s)) { + offenders.push({ path, substring: s }); + } + } + } + if (offenders.length > 0) { + const dump = Array.from(this.files.entries()) + .map(([k, v]) => ` ${k}: "${v}"`) + .join("\n"); + throw new Error( + `Expected no file to contain ${substrings.map((s) => `"${s}"`).join(", ")}, but found ${offenders.map((o) => `"${o.substring}" in "${o.path}"`).join(", ")}.\nFiles:\n${dump}` + ); + } + return this; + } + + public assertSubstringCount( path: string, substring: string, expected: number @@ -104,7 +122,7 @@ export class AssertableState { return this; } - assertContentInAtMostOneFile(substring: string): this { + public assertContentInAtMostOneFile(substring: string): this { const matches = Array.from(this.files.entries()).filter(([, content]) => content.includes(substring) ); @@ -119,14 +137,14 @@ export class AssertableState { return this; } - ifFileExists(path: string, fn: (state: this) => void): this { + public ifFileExists(path: string, fn: (state: this) => void): this { if (this.files.has(path)) { fn(this); } return this; } - getContent(path: string): string { + public getContent(path: string): string { return this.files.get(path) ?? ""; } } diff --git a/frontend/deterministic-tests/src/utils/find-free-port.ts b/frontend/deterministic-tests/src/utils/find-free-port.ts index 3c965049..0734c1a9 100644 --- a/frontend/deterministic-tests/src/utils/find-free-port.ts +++ b/frontend/deterministic-tests/src/utils/find-free-port.ts @@ -1,6 +1,6 @@ import * as net from "node:net"; -export interface PortReservation { +interface PortReservation { port: number; release: () => void; } diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 61a8bade..eed30760 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -19,6 +19,7 @@ export default [ rules: { "no-console": "error", "no-unused-vars": "off", + "curly": ["error", "all"], "@typescript-eslint/restrict-template-expressions": "off", "@typescript-eslint/no-unused-vars": "off", "@typescript-eslint/no-floating-promises": [ diff --git a/frontend/history-ui/src/components/ActivityFeed.svelte b/frontend/history-ui/src/components/ActivityFeed.svelte index c1c82c29..b20991e2 100644 --- a/frontend/history-ui/src/components/ActivityFeed.svelte +++ b/frontend/history-ui/src/components/ActivityFeed.svelte @@ -1,5 +1,5 @@